From d3bc790aba72fc139a8ffc9c98515d63e0944e29 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 16:41:32 -0500 Subject: [PATCH] craft-media: MXF demuxer and writer from FilmCraft (feature `mxf`) (#877) Moves filmcraft-mxf (storytold/filmcraft@4bec22b) into craft_media::mxf. ByteSource is the crate's trait, Codec::codec_id() and EssenceTrack::codec_params() map tracks to CodecId, and the writer names craft-media as company and product unless the caller sets them. Fixes two panics: EssenceTrack::sync_before on a track without picture samples, and Timecode::format for a drop-frame start near i64::MAX. Adds Container::Mxf, an ffprobe/ffmpeg oracle test for the reader and the writer, and hostile-input tests. --- Cargo.lock | 1 + README.md | 2 +- crates/media/Cargo.toml | 9 +- crates/media/README.md | 182 +++- crates/media/src/id.rs | 3 + crates/media/src/lib.rs | 5 +- crates/media/src/mxf/essence.rs | 410 ++++++++ crates/media/src/mxf/file.rs | 1326 ++++++++++++++++++++++++ crates/media/src/mxf/hostile_tests.rs | 358 +++++++ crates/media/src/mxf/index.rs | 124 +++ crates/media/src/mxf/klv.rs | 250 +++++ crates/media/src/mxf/meta.rs | 215 ++++ crates/media/src/mxf/mod.rs | 107 ++ crates/media/src/mxf/tests.rs | 393 +++++++ crates/media/src/mxf/write.rs | 1350 +++++++++++++++++++++++++ crates/media/src/mxf/write/labels.rs | 144 +++ crates/media/src/mxf/write/tests.rs | 451 +++++++++ crates/media/tests/oracle_mxf.rs | 416 ++++++++ 18 files changed, 5736 insertions(+), 10 deletions(-) create mode 100644 crates/media/src/mxf/essence.rs create mode 100644 crates/media/src/mxf/file.rs create mode 100644 crates/media/src/mxf/hostile_tests.rs create mode 100644 crates/media/src/mxf/index.rs create mode 100644 crates/media/src/mxf/klv.rs create mode 100644 crates/media/src/mxf/meta.rs create mode 100644 crates/media/src/mxf/mod.rs create mode 100644 crates/media/src/mxf/tests.rs create mode 100644 crates/media/src/mxf/write.rs create mode 100644 crates/media/src/mxf/write/labels.rs create mode 100644 crates/media/src/mxf/write/tests.rs create mode 100644 crates/media/tests/oracle_mxf.rs diff --git a/Cargo.lock b/Cargo.lock index 1e88c2a4..ab3dfaeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1067,6 +1067,7 @@ dependencies = [ name = "craft-media" version = "0.1.0" dependencies = [ + "chrono", "craft-media-testkit", "proptest", "serde", diff --git a/README.md b/README.md index 74c58300..c97a1a76 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " + open complete > closed > open; later partitions win ties, so the footer's final durations are + preferred). Dynamic local tags resolve through the primer pack (e.g. `SubDescriptors`). +- **Tracks.** Material package picture/sound tracks → source clip → file package track (track + number, edit rate, origin) → descriptor (a multiple descriptor's sub-descriptor by + `LinkedTrackID`). Without a usable material package the file packages' tracks are used directly. + Elements are matched by track number, or — when the number is 0 or does not match (some OP-Atom + writers) — the only unclaimed element stream of the right kind. +- **Pictures.** One `Sample` per edit unit in stored order: frame wrapping (one element each) or + clip wrapping (one element split by the index: constant `EditUnitByteCount` or VBR entries). + Random access from index flag bit 7 (intra-only codings: every picture); without an index the + essence is scanned (AVC IDR, MPEG-2 sequence header). Presentation order from the index temporal + offsets (display position *n* is stored at *n* + `TemporalOffset[n]`). When the index marks B + pictures but has no temporal offsets (FFmpeg-written AVC), `needs_reorder` is set: the caller + orders by the bitstream (the AVC picture order counts). +- **Codecs.** From the picture essence coding label, then the essence container label, then the + essence bytes: AVC / AVC-Intra, VC-3, ProRes (profile from the label), MPEG-2, + MPEG-4 visual, JPEG 2000, DV, uncompressed. +- **Sound.** PCM chunks (frame- or clip-wrapped) with sample counts; `read_pcm` decodes + little-endian 8/16/24/32-bit PCM (wave / AES3 descriptors) and ST 331 AES3 elements (D-10). +- **Timecode.** The material package timecode component (start frame count, rounded base, + drop frame), falling back to the file package's; `Timecode::format` gives `HH:MM:SS:FF` / + `HH:MM:SS;FF`. + +Not supported: OP1b/OP2x+ edit lists beyond the first source clip, external essence (OP-Atom +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). + +`Codec::codec_id()` and `EssenceTrack::codec_params()` give the crate-wide `CodecId` / +`CodecParams`: AVC is `H264`, VC-3 is `Dnxhd`, PCM and AES3 element data are `Pcm`; JPEG 2000, DV +and uncompressed pictures have no identifier. The size is the displayed frame, sound gets its +sample rate, channels and (for plain PCM) the little-endian `PcmFormat`. MXF essence carries its +configuration in band, so `extradata` is empty. + +Limits, each with a test: a run-in of at most 65 535 bytes before the header partition pack, a +metadata or index value of at most 64 MiB (larger is refused before it is read), 16 MiB scanned +for the next partition pack after a lost KLV sync, an index position that cannot make a sparse +table, at most 256 sound channels (8 in an AES3 element), a PCM block alignment of at most 4096 +bytes, and a sound request of at most 2²⁴ samples past the stored essence. + +### Writing a file + +```rust +use craft_media::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 }); +cfg.company = "My company".into(); // `craft-media` unless set, like `product` +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. + +### Differences from `filmcraft-mxf` + +- `ByteSource` is `craft_media::ByteSource`. +- `WriterConfig::new` sets `company` and `product` to `craft-media` (FilmCraft: `FilmCraft`), and + the identification set's product UID is derived from `craft-media MXF writer`. Apps set their + own names; `write_opatom_pcm` always writes the defaults. +- `EssenceTrack::sync_before` returns 0 for a track without picture samples (a sound track); it + indexed an empty table and panicked. +- `Timecode::format` saturates for a drop-frame start near `i64::MAX`; it overflowed (a panic in + a debug build). +- `Error` is `#[non_exhaustive]`; `Codec::codec_id()` and `EssenceTrack::codec_params()` are new. + +### Tests + +Synthetic OP1a files (temporal offsets, key flags, PCM, timecode), writer output read back (OP1a +with two sound tracks, 29.97 sound per edit unit, long-GOP AVC index, OP-Atom picture and PCM, +multi-segment index), truncation and mutation, and `hostile_tests.rs`: every metadata item, index +item and partition pack field set to extreme values, KLV lengths that lie, and the parsers on +arbitrary bytes. `tests/oracle_mxf.rs`: eight ffmpeg-written files (OP1a H.264 with and without B +pictures, DNxHR, ProRes, MPEG-2; OP-Atom DNxHR and PCM; D-10) compared with ffprobe's packets and +ffmpeg's decoded sound sample for sample; and files written by `MxfWriter` from the same essence, +which ffprobe must describe like their source and ffmpeg must decode to the same pictures and +sound. + ## Containers, codecs and codec parameters Always built, with no dependencies, so codec crates share them: - `Container`: the file format (`Wav`, `Aiff`, `Flac`, `Mp4`, `QuickTime`, `Matroska`, `WebM`, - `Ogg`, `Caf`, `Adts`, `MpegAudio`, `Ac3`, `Avi`), with `name()`. + `Ogg`, `Caf`, `Adts`, `MpegAudio`, `Ac3`, `Avi`, `Mxf`), with `name()`. - `CodecId`: audio and video codecs (`Aac`, `HeAac`, `Opus`, `Eac3`, `Speex`, …, `H264`, `Hevc`, `Av1`, `ProRes`, `Dnxhd`, `Apv`, `Theora`, …, `Unknown`), with `name()`, `is_audio()`, `is_video()`. @@ -518,7 +686,7 @@ All are `#[non_exhaustive]`: new formats and fields are minor changes. `craft_media::ByteSource` (`len`, `read_at(offset, buf)`) is what every demuxer reads from. It is always built, so a crate that uses only `bitstream` can name it too. `isobmff::ByteSource`, -`matroska::ByteSource`, `ogg::ByteSource` and `avi::ByteSource` are the same trait. It is implemented for `[u8]`, +`matroska::ByteSource`, `ogg::ByteSource`, `avi::ByteSource` and `mxf::ByteSource` are the same trait. It is implemented for `[u8]`, `Vec`, `&T`, `Arc`, `Box` and `std::fs::File` (not on wasm). `ReadSeekSource` adapts any `Read + Seek`; it is `Send + Sync` when the reader is `Send`. Implement the trait once for a custom source (a web `Blob`, a network range reader) to use every container with it. diff --git a/crates/media/src/id.rs b/crates/media/src/id.rs index 982acfd2..7862546f 100644 --- a/crates/media/src/id.rs +++ b/crates/media/src/id.rs @@ -38,6 +38,8 @@ pub enum Container { Ac3, /// AVI (RIFF), including OpenDML. Avi, + /// MXF (SMPTE ST 377-1): OP1a, OP-Atom. + Mxf, } impl Container { @@ -57,6 +59,7 @@ impl Container { Container::MpegAudio => "MPEG audio", Container::Ac3 => "AC-3", Container::Avi => "AVI", + Container::Mxf => "MXF", } } } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 9a6a872a..eaaea04e 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -9,10 +9,11 @@ //! - `matroska` (feature `matroska`): a clean-room Matroska (MKV) and WebM demuxer and muxer. //! - `ogg` (feature `ogg`): a clean-room Ogg demuxer with Ogg Opus and Ogg Vorbis stream mapping. //! - `avi` (feature `avi`): a clean-room AVI (RIFF) and OpenDML demuxer. +//! - `mxf` (feature `mxf`, enables `chrono`): a clean-room MXF demuxer and writer (OP1a, OP-Atom). //! - [`Container`] and [`CodecId`] (no feature): the identifiers the codec crates report a file's //! container and codec with. //! -//! `bitstream` and `isobmff` are on by default; `matroska`, `ogg` and `avi` are opt-in. A crate that needs +//! `bitstream` and `isobmff` are on by default; `matroska`, `ogg`, `avi` and `mxf` are opt-in. A crate that needs //! only bit I/O uses `default-features = false, features = ["bitstream"]`. #[cfg(feature = "avi")] @@ -24,6 +25,8 @@ mod id; pub mod isobmff; #[cfg(feature = "matroska")] pub mod matroska; +#[cfg(feature = "mxf")] +pub mod mxf; #[cfg(feature = "ogg")] pub mod ogg; pub mod source; diff --git a/crates/media/src/mxf/essence.rs b/crates/media/src/mxf/essence.rs new file mode 100644 index 00000000..bf6caa9b --- /dev/null +++ b/crates/media/src/mxf/essence.rs @@ -0,0 +1,410 @@ +//! Essence identification and PCM sound decoding. +//! +//! Picture codecs are identified from the descriptor's picture essence coding label, then the +//! essence container label (ST 379-2 mapping numbers), then the first bytes of the essence. + +use crate::mxf::klv::Ul; +use crate::mxf::{Rational, Result}; + +/// The essence codec of a track. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Codec { + /// H.264 / AVC byte stream (ST 381-3). `intra`: AVC-Intra / an intra-only profile. + Avc { + intra: bool, + }, + /// SMPTE VC-3 (DNxHD / DNxHR, ST 2019-4). + Vc3, + /// Apple ProRes (RDD 44). `profile`: byte 14 of the coding label (1 Proxy … 6 4444 XQ). + ProRes { + profile: Option, + }, + /// MPEG-2 video (ST 381-1, or D-10 / IMX). + Mpeg2, + /// MPEG-4 part 2 visual. + Mpeg4Visual, + Jpeg2000, + Dv, + /// Uncompressed pictures (ST 384). + Uncompressed, + /// Linear PCM, little-endian (Broadcast Wave and AES3 descriptors, ST 382). + Pcm, + /// 8-channel AES3 element data (ST 331, D-10 sound). + Aes3Element, + Unknown, +} + +impl Codec { + pub fn name(&self) -> &'static str { + match self { + Codec::Avc { intra: true } => "AVC-Intra", + Codec::Avc { intra: false } => "H.264", + Codec::Vc3 => "VC-3", + Codec::ProRes { .. } => "Apple ProRes", + Codec::Mpeg2 => "MPEG-2 Video", + Codec::Mpeg4Visual => "MPEG-4 Visual", + Codec::Jpeg2000 => "JPEG 2000", + Codec::Dv => "DV", + Codec::Uncompressed => "Uncompressed", + Codec::Pcm => "PCM", + Codec::Aes3Element => "AES3 PCM", + Codec::Unknown => "unknown", + } + } + /// The crate-wide identifier: VC-3 is [`CodecId::Dnxhd`], PCM and AES3 element data are + /// [`CodecId::Pcm`]; JPEG 2000, DV and uncompressed pictures have no identifier. + /// + /// [`CodecId::Dnxhd`]: crate::CodecId::Dnxhd + /// [`CodecId::Pcm`]: crate::CodecId::Pcm + pub fn codec_id(&self) -> crate::CodecId { + use crate::CodecId as Id; + match self { + Codec::Avc { .. } => Id::H264, + Codec::Vc3 => Id::Dnxhd, + Codec::ProRes { .. } => Id::ProRes, + Codec::Mpeg2 => Id::Mpeg2Video, + Codec::Mpeg4Visual => Id::Mpeg4Video, + Codec::Pcm | Codec::Aes3Element => Id::Pcm, + Codec::Jpeg2000 | Codec::Dv | Codec::Uncompressed | Codec::Unknown => Id::Unknown, + } + } + /// Every picture is coded independently. + pub fn intra_only(&self) -> bool { + matches!(self, Codec::Avc { intra: true } | Codec::Vc3 | Codec::ProRes { .. } | Codec::Jpeg2000 | Codec::Dv | Codec::Uncompressed) + } +} + +/// Identify a picture codec from the picture essence coding label and the essence container label. +pub fn identify_picture(coding: Option
    , container: Option
      , mpeg_descriptor: bool) -> Codec { + if let Some(c) = coding.filter(Ul::is_smpte) { + let b = &c.0; + // 04 01 02 02 .. : compressed picture coding + if b[8..12] == [0x04, 0x01, 0x02, 0x02] { + match (b[12], b[13]) { + // 01: MPEG compression (ISO/IEC 13818-2 profiles, 14496-2, 14496-10) + (0x01, 0x01..=0x1F) => return Codec::Mpeg2, + (0x01, 0x20..=0x2F) => return Codec::Mpeg4Visual, + (0x01, 0x32) => return Codec::Avc { intra: true }, + (0x01, 0x30..=0x3F) => return Codec::Avc { intra: false }, + (0x02, _) => return Codec::Dv, + (0x03, 0x01) => return Codec::Jpeg2000, + (0x03, 0x02) => return Codec::Vc3, + (0x03, 0x06) => return Codec::ProRes { profile: Some(b[14]) }, + // Avid-registered VC-3 coding labels + (0x71, _) => return Codec::Vc3, + _ => {} + } + } + if b[8..11] == [0x04, 0x01, 0x02] && b[11] == 0x01 { + return Codec::Uncompressed; + } + } + if let Some(c) = container.filter(Ul::is_smpte) + && c.item_starts_with(&[0x0D, 0x01, 0x03, 0x01, 0x02]) + { + match c.0[13] { + 0x01 => return Codec::Mpeg2, + 0x02 => return Codec::Dv, + 0x04 if mpeg_descriptor => return Codec::Mpeg2, + 0x05 => return Codec::Uncompressed, + 0x0C => return Codec::Jpeg2000, + 0x0F | 0x10 => return Codec::Avc { intra: false }, + 0x11 => return Codec::Vc3, + 0x1C => return Codec::ProRes { profile: None }, + _ => {} + } + } + if mpeg_descriptor { Codec::Mpeg2 } else { Codec::Unknown } +} + +/// Identify a picture codec from the first bytes of an essence element. +pub fn sniff_picture(b: &[u8]) -> Option { + if b.len() >= 8 && &b[4..8] == b"icpf" { + return Some(Codec::ProRes { profile: None }); + } + if b.len() >= 5 && b[..4] == [0x00, 0x00, 0x02, 0x80] { + return Some(Codec::Vc3); + } + if b.len() >= 4 && b[..4] == [0x00, 0x00, 0x01, 0xB3] { + return Some(Codec::Mpeg2); + } + let sc4 = b.len() >= 5 && b[..4] == [0, 0, 0, 1]; + let sc3 = b.len() >= 4 && b[..3] == [0, 0, 1]; + if sc4 || sc3 { + let nal = b[if sc4 { 4 } else { 3 }]; + if nal & 0x80 == 0 && matches!(nal & 0x1F, 1 | 5 | 6 | 7 | 8 | 9) { + return Some(Codec::Avc { intra: false }); + } + } + None +} + +/// Picture descriptor properties (ST 377-1 generic picture / CDCI / RGBA descriptors). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PictureInfo { + pub stored_width: u32, + pub stored_height: u32, + pub display_width: u32, + pub display_height: u32, + /// 0 full frame, 1 separate fields, 2 single field, 3 mixed fields, 4 segmented frame. + pub frame_layout: u8, + /// Display aspect ratio of the whole image. + pub aspect_ratio: Rational, + pub component_depth: u32, + pub horizontal_subsampling: u32, + pub vertical_subsampling: u32, + pub black_ref_level: Option, + pub white_ref_level: Option, + pub alpha_depth: u32, + /// RGBA descriptor (pixel layout present). + pub rgba: bool, + pub picture_coding: Option
        , + pub transfer_characteristic: Option
          , + pub coding_equations: Option
            , + pub color_primaries: Option
              , +} + +impl PictureInfo { + /// Frame height: field-based layouts code the height of one field. + pub fn frame_height(&self) -> u32 { + let h = if self.display_height > 0 { self.display_height } else { self.stored_height }; + if matches!(self.frame_layout, 1 | 3) { h.saturating_mul(2) } else { h } + } + pub fn frame_width(&self) -> u32 { + if self.display_width > 0 { self.display_width } else { self.stored_width } + } + /// Full-range coding (black at 0 and white at the maximum code value). + pub fn full_range(&self) -> bool { + let d = self.component_depth; + d > 0 && self.black_ref_level == Some(0) && self.white_ref_level == Some((1u32 << d.min(31)) - 1) + } +} + +/// PCM layout of a sound track. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SoundFormat { + /// Interleaved little-endian samples (`bits` per sample, `block_align` bytes per frame). + Pcm, + /// ST 331 8-channel AES3 element data (4-byte header, 8 × 32-bit sub-frames per sample). + Aes3Element, + /// A coding we do not decode. + Other, +} + +/// Sound descriptor properties (ST 377-1 generic sound descriptor, ST 382 wave / AES3). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SoundInfo { + pub sample_rate: Rational, + pub channels: u32, + pub bits: u32, + pub block_align: u32, + pub locked: Option, + pub sound_coding: Option
                , +} + +impl SoundInfo { + /// Bytes per sample frame of plain PCM. + pub fn frame_bytes(&self) -> usize { + if self.block_align > 0 { + self.block_align as usize + } else { + (self.channels.max(1) as usize).saturating_mul((self.bits as usize).div_ceil(8).max(1)) + } + } +} + +/// Number of sample frames in a ST 331 AES3 element (from its header; 0 if too short). +pub fn aes3_element_samples(v: &[u8]) -> usize { + if v.len() < 4 { + return 0; + } + let declared = u16::from_le_bytes([v[1], v[2]]) as usize; + declared.min((v.len() - 4) / 32) +} + +/// De-interleave `bps`-byte little-endian samples (`B` = `bps`), one pass per channel. +fn decode_planes(v: &[u8], frame_bytes: usize, out: &mut [Vec], scale: f32) { + for (c, o) in out.iter_mut().enumerate() { + let off = c * B; + o.extend(v.chunks_exact(frame_bytes).map(|f| { + let mut s = [0u8; B]; + s.copy_from_slice(&f[off..off + B]); + let x = match B { + 1 => (s[0] as i32 - 128) << 24, + 2 => i32::from_le_bytes([0, 0, s[0], s[1]]), + 3 => i32::from_le_bytes([0, s[0], s[1], s[2]]), + _ => i32::from_le_bytes([s[0], s[1], s[2], s[3]]), + }; + (x >> (32 - 8 * B)) as f32 * scale + })); + } +} + +/// Decode ST 331 sample frames (without the element header; 32 bytes each, the trailing partial +/// frame is ignored) into `channels` planar channels, at most eight. +pub(crate) fn decode_aes3_frames(frames: &[u8], channels: usize) -> Vec> { + let mut out: Vec> = (0..channels).map(|_| Vec::with_capacity(frames.len() / 32)).collect(); + for f in frames.as_chunks::<32>().0 { + for (c, o) in out.iter_mut().enumerate().take(8) { + let w = u32::from_le_bytes([f[c * 4], f[c * 4 + 1], f[c * 4 + 2], f[c * 4 + 3]]); + let s = (((w >> 4) & 0xFF_FFFF) << 8) as i32 >> 8; + o.push(s as f32 / 8_388_608.0); + } + } + out +} + +/// Decode PCM chunk bytes into planar f32 (`channels` channels). +/// +/// `Pcm`: interleaved little-endian signed integers of `bits` (8-bit is unsigned, per WAVE), +/// `frame_bytes` per sample frame. `Aes3Element`: the 24-bit sample of each ST 331 sub-frame +/// (bits 4-27 of the little-endian 32-bit word), first `channels` of the 8 channels. +pub fn decode_pcm(v: &[u8], format: SoundFormat, channels: usize, bits: u32, frame_bytes: usize) -> Result>> { + let ch = channels.max(1); + if ch > 256 { + return Err(crate::mxf::Error::Invalid("PCM channel count exceeds 256".into())); + } + match format { + SoundFormat::Pcm => { + let bps = (bits as usize).div_ceil(8); + if bps == 0 || bps > 4 || frame_bytes < bps * ch { + return Err(crate::mxf::Error::Unsupported(format!("{bits}-bit PCM with {frame_bytes}-byte frames"))); + } + // `frame_bytes >= bps * ch`, so the output is bounded by the bytes actually supplied. + let n = v.len() / frame_bytes; + let scale = 1.0 / (1u64 << (8 * bps - 1)) as f32; + // `vec![Vec::with_capacity(n); ch]` would clone empty vectors without the capacity. + let mut out: Vec> = (0..ch).map(|_| Vec::with_capacity(n)).collect(); + match bps { + 1 => decode_planes::<1>(v, frame_bytes, &mut out, scale), + 2 => decode_planes::<2>(v, frame_bytes, &mut out, scale), + 3 => decode_planes::<3>(v, frame_bytes, &mut out, scale), + _ => decode_planes::<4>(v, frame_bytes, &mut out, scale), + } + Ok(out) + } + SoundFormat::Aes3Element => { + // SMPTE 331M: an AES3 element carries at most eight channels (and an output buffer per + // declared channel would multiply a hostile header's allocation). + if ch > 8 { + return Err(crate::mxf::Error::Invalid("an AES3 element has at most eight channels".into())); + } + let n = aes3_element_samples(v); + Ok(decode_aes3_frames(&v[4.min(v.len())..][..n * 32], ch)) + } + SoundFormat::Other => Err(crate::mxf::Error::Unsupported("sound coding".into())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ul(item: &[u8]) -> Ul { + let mut b = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0A, 0, 0, 0, 0, 0, 0, 0, 0]; + b[8..8 + item.len()].copy_from_slice(item); + Ul(b) + } + + #[test] + fn interlaced_picture_height_does_not_overflow() { + let picture = PictureInfo { stored_height: u32::MAX, frame_layout: 1, ..Default::default() }; + assert_eq!(picture.frame_height(), u32::MAX); + let picture = PictureInfo { display_height: 240, stored_height: u32::MAX, frame_layout: 3, ..Default::default() }; + assert_eq!(picture.frame_height(), 480); + } + + #[test] + fn identifies_picture_codings() { + assert_eq!(identify_picture(Some(ul(&[4, 1, 2, 2, 1, 0x31, 0x40, 1])), None, false), Codec::Avc { intra: false }); + assert_eq!(identify_picture(Some(ul(&[4, 1, 2, 2, 1, 0x32, 0x21, 1])), None, false), Codec::Avc { intra: true }); + assert_eq!(identify_picture(Some(ul(&[4, 1, 2, 2, 0x71, 0x11])), None, false), Codec::Vc3); + assert_eq!(identify_picture(Some(ul(&[4, 1, 2, 2, 3, 6, 4])), None, false), Codec::ProRes { profile: Some(4) }); + assert_eq!(identify_picture(Some(ul(&[4, 1, 2, 2, 1, 1, 0x11])), None, true), Codec::Mpeg2); + // container fallback + assert_eq!(identify_picture(None, Some(ul(&[0x0D, 1, 3, 1, 2, 0x11, 1])), false), Codec::Vc3); + assert_eq!(identify_picture(None, Some(ul(&[0x0D, 1, 3, 1, 2, 0x04, 0x60, 1])), true), Codec::Mpeg2); + assert_eq!(identify_picture(None, None, false), Codec::Unknown); + } + + #[test] + fn sniffs_essence() { + assert_eq!(sniff_picture(&[0, 0, 0, 1, 0x09, 0x10]), Some(Codec::Avc { intra: false })); + assert_eq!(sniff_picture(&[0, 0, 1, 0xB3, 0]), Some(Codec::Mpeg2)); + assert_eq!(sniff_picture(&[0, 0, 2, 0x80, 1]), Some(Codec::Vc3)); + assert_eq!(sniff_picture(&[0, 0, 0, 9, b'i', b'c', b'p', b'f']), Some(Codec::ProRes { profile: None })); + assert_eq!(sniff_picture(&[1, 2, 3]), None); + } + + #[test] + fn decodes_pcm_and_aes3() { + let v = [0x00, 0x80, 0xFF, 0x7F, 0x01, 0x00, 0x00, 0x00]; + let p = decode_pcm(&v, SoundFormat::Pcm, 2, 16, 4).unwrap(); + assert_eq!(p[0], vec![-1.0, 1.0 / 32768.0]); + assert_eq!(p[1], vec![32767.0 / 32768.0, 0.0]); + let v24 = [0x00, 0x00, 0x80, 0xFF, 0xFF, 0x7F]; + let p = decode_pcm(&v24, SoundFormat::Pcm, 1, 24, 3).unwrap(); + assert_eq!(p[0], vec![-1.0, 8_388_607.0 / 8_388_608.0]); + // one AES3 element: 1 sample, channel 0 = -1, channel 1 = 0.5 + let mut e = vec![0x00, 1, 0, 0xFF]; + for c in 0..8u32 { + let s: i32 = match c { + 0 => -8_388_608, + 1 => 4_194_304, + _ => 0, + }; + let w = (((s as u32) & 0xFF_FFFF) << 4) | c; + e.extend_from_slice(&w.to_le_bytes()); + } + assert_eq!(aes3_element_samples(&e), 1); + let p = decode_pcm(&e, SoundFormat::Aes3Element, 2, 24, 0).unwrap(); + assert_eq!(p, vec![vec![-1.0], vec![0.5]]); + // truncated: no panic, no samples + assert!(decode_pcm(&e[..10], SoundFormat::Aes3Element, 2, 24, 0).unwrap()[0].is_empty()); + } + + /// The per-sample loop `decode_pcm` used before the per-width passes. + fn decode_pcm_reference(v: &[u8], ch: usize, bits: u32, frame_bytes: usize) -> Vec> { + let bps = (bits as usize).div_ceil(8); + let n = v.len() / frame_bytes; + let mut out = vec![Vec::with_capacity(n); ch]; + let scale = 1.0 / (1u64 << (8 * bps - 1)) as f32; + for i in 0..n { + let f = &v[i * frame_bytes..]; + for (c, o) in out.iter_mut().enumerate() { + let s = &f[c * bps..c * bps + bps]; + let x = match bps { + 1 => (s[0] as i32 - 128) << 24, + 2 => i32::from_le_bytes([0, 0, s[0], s[1]]), + 3 => i32::from_le_bytes([0, s[0], s[1], s[2]]), + _ => i32::from_le_bytes([s[0], s[1], s[2], s[3]]), + }; + o.push((x >> (32 - 8 * bps)) as f32 * scale); + } + } + out + } + + #[test] + fn decode_pcm_matches_the_per_sample_reference() { + let mut seed = 0x1234_5678_u32; + let v: Vec = (0..4099) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 24) as u8 + }) + .collect(); + for (bits, ch, pad) in [(8, 1, 0), (8, 2, 2), (16, 1, 0), (16, 2, 0), (16, 6, 3), (24, 1, 0), (24, 2, 0), (24, 3, 5), (32, 2, 0), (32, 1, 4)] + { + let bps = (bits as usize).div_ceil(8); + let fb = bps * ch + pad; + let got = decode_pcm(&v, SoundFormat::Pcm, ch, bits, fb).unwrap(); + let want = decode_pcm_reference(&v, ch, bits, fb); + assert_eq!(got.len(), want.len()); + for (g, w) in got.iter().zip(&want) { + assert!(g.iter().map(|x| x.to_bits()).eq(w.iter().map(|x| x.to_bits())), "{bits}-bit, {ch} channels, {pad} pad"); + assert_eq!(g.capacity(), v.len() / fb); + } + } + } +} diff --git a/crates/media/src/mxf/file.rs b/crates/media/src/mxf/file.rs new file mode 100644 index 00000000..0c09ae0d --- /dev/null +++ b/crates/media/src/mxf/file.rs @@ -0,0 +1,1326 @@ +//! Opening an MXF file: KLV walk, partitions, metadata resolution, sample tables. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::mxf::essence::{Codec, PictureInfo, SoundFormat, SoundInfo, decode_aes3_frames, decode_pcm, identify_picture, sniff_picture}; +use crate::mxf::index::{IndexEntry, IndexSegment}; +use crate::mxf::klv::{KeyClass, Ul, ber_length, classify, find_header_partition}; +use crate::mxf::meta::{Metadata, Primer, SUB_DESCRIPTORS_ITEM, Set, set_type as st, tag}; +use crate::mxf::{ByteSource, read_upto}; +use crate::mxf::{Error, Rational, Result}; + +/// Partition kinds (ST 377-1 §7). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PartitionKind { + Header, + Body, + Footer, +} + +/// A partition pack. +#[derive(Clone, Debug, PartialEq)] +pub struct Partition { + /// File offset of the partition pack key. + pub offset: u64, + pub kind: PartitionKind, + /// 1 open incomplete, 2 closed incomplete, 3 open complete, 4 closed complete. + pub status: u8, + pub major_version: u16, + pub minor_version: u16, + pub kag_size: u32, + pub this_partition: u64, + pub previous_partition: u64, + pub footer_partition: u64, + pub header_byte_count: u64, + pub index_byte_count: u64, + pub index_sid: u32, + pub body_offset: u64, + pub body_sid: u32, + pub operational_pattern: Ul, + pub essence_containers: Vec
                  , + /// File offset of the first essence container KLV in this partition. + pub essence_start: Option, +} + +impl Partition { + fn parse(offset: u64, kind: u8, status: u8, v: &[u8]) -> Option { + let mut c = crate::mxf::klv::Cur::new(v); + let mut p = Partition { + offset, + kind: match kind { + 2 => PartitionKind::Header, + 3 => PartitionKind::Body, + _ => PartitionKind::Footer, + }, + status, + major_version: c.u16()?, + minor_version: c.u16()?, + kag_size: c.u32()?, + this_partition: c.u64()?, + previous_partition: c.u64()?, + footer_partition: c.u64()?, + header_byte_count: c.u64()?, + index_byte_count: c.u64()?, + index_sid: c.u32()?, + body_offset: c.u64()?, + body_sid: c.u32()?, + operational_pattern: c.ul()?, + essence_containers: Vec::new(), + essence_start: None, + }; + let rest = &v[c.pos..]; + p.essence_containers = crate::mxf::klv::batch_of(rest).into_iter().filter_map(crate::mxf::klv::ul_of).collect(); + Some(p) + } + pub fn closed(&self) -> bool { + self.status == 2 || self.status == 4 + } + pub fn complete(&self) -> bool { + self.status >= 3 + } +} + +/// The operational pattern (ST 377-1 §5, ST 378, ST 390). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OperationalPattern { + /// Generalized OP: item complexity 1-3 (1 single item, 2 playlist, 3 edit), package + /// complexity 1-3 (a single, b ganged, c alternate). OP1a = `Generalized { item: 1, package: 1 }`. + Generalized { + item: u8, + package: u8, + }, + /// OP-Atom (ST 390): one essence track per file. + Atom, + Unknown, +} + +impl OperationalPattern { + pub fn from_ul(ul: &Ul) -> Self { + if !ul.is_smpte() || !ul.item_starts_with(&[0x0D, 0x01, 0x02, 0x01]) { + return OperationalPattern::Unknown; + } + match (ul.0[12], ul.0[13]) { + (0x10, _) => OperationalPattern::Atom, + (i @ 1..=3, p @ 1..=3) => OperationalPattern::Generalized { item: i, package: p }, + _ => OperationalPattern::Unknown, + } + } + pub fn name(&self) -> String { + match self { + OperationalPattern::Generalized { item, package } => format!("OP{item}{}", (b'a' + package - 1) as char), + OperationalPattern::Atom => "OP-Atom".into(), + OperationalPattern::Unknown => "OP?".into(), + } + } +} + +/// A timecode component (ST 377-1 §B.?: StartTimecode in frames at RoundedTimecodeBase). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Timecode { + /// Frame count of the first frame. + pub start: i64, + pub rounded_base: u16, + pub drop_frame: bool, +} + +impl Timecode { + /// `HH:MM:SS:FF` (`;` before the frames when drop-frame). + pub fn format(&self) -> String { + let base = self.rounded_base.max(1) as i64; + let mut f = self.start.max(0); + if self.drop_frame && base % 30 == 0 { + // SMPTE 12M drop-frame: skip 2 (4 at 60) frame numbers each minute except every tenth. + let drop = 2 * base / 30; + let per_10min = base * 600 - 9 * drop; + let per_min = base * 60 - drop; + let d = f / per_10min; + let m = f % per_10min; + // a start near i64::MAX (a damaged file) must not overflow + f = f.saturating_add(9 * drop * d + if m > drop { drop * ((m - drop) / per_min) } else { 0 }); + } + let (hh, mm, ss, ff) = (f / (base * 3600), (f / (base * 60)) % 60, (f / base) % 60, f % base); + format!("{hh:02}:{mm:02}:{ss:02}{}{ff:02}", if self.drop_frame { ';' } else { ':' }) + } +} + +/// Essence track kinds (from the data definition label). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TrackKind { + Picture, + Sound, + Data, +} + +/// Frame wrapping (one element per edit unit) or clip wrapping (one element for the whole clip). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Wrapping { + Frame, + Clip, +} + +/// One picture edit unit, in stored (decode) order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Sample { + /// File offset of the picture data. + pub offset: u64, + pub size: u32, + /// Presentation position in edit units (stored position when the order is unknown). + pub pts: i64, + /// A decoder can start here. + pub key: bool, + /// The index marks this picture as bidirectionally predicted. + pub b_picture: bool, +} + +/// A run of sound sample frames stored contiguously. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Chunk { + pub offset: u64, + pub size: u64, + /// Index of the chunk's first sample frame in the track. + pub first_sample: u64, + pub samples: u64, +} + +/// A track of the file (source) package with essence in this file, as presented by the material +/// package. +#[derive(Clone, Debug)] +pub struct EssenceTrack { + pub track_id: u32, + pub track_number: u32, + pub kind: TrackKind, + pub name: Option, + /// Edit rate of the file package track. + pub edit_rate: Rational, + /// File package track origin (edit units of pre-charge before the zero point). + pub origin: i64, + /// Material package source clip start position (edit units after the origin). + pub start_position: i64, + /// Material package duration (edit units of `edit_rate`), when known. + pub duration: Option, + pub codec: Codec, + pub wrapping: Wrapping, + pub essence_container: Option
                    , + pub picture: Option, + pub sound: Option, + pub sound_format: SoundFormat, + pub body_sid: u32, + pub index_sid: u32, + /// Pictures in stored order. + pub samples: Vec, + /// Display position → stored index (identity unless the index has temporal offsets). + pub display_order: Vec, + /// Sound data. + pub chunks: Vec, + /// The index carries temporal offsets (exact presentation order). + pub temporal_offsets: bool, + /// The index marks bidirectionally predicted pictures but has no temporal offsets: the + /// presentation order must come from the bitstream. + pub needs_reorder: bool, + /// Random-access flags came from an index table. + pub indexed: bool, +} + +impl EssenceTrack { + /// What a decoder needs for this track, in the container-neutral form the codec crates take: + /// the codec ([`Codec::codec_id`]), the frame size of a picture track, and the sample rate, + /// channels and layout of plain PCM (little-endian; 8-bit is unsigned, as in WAVE). MXF essence + /// carries its configuration in band (AVC is a byte stream with its parameter sets), so + /// `extradata` is empty. AES3 element data (D-10 sound) is not plain PCM and gets no layout: + /// [`MxfFile::read_pcm`] decodes it. + pub fn codec_params(&self) -> crate::CodecParams { + let mut p = crate::CodecParams::new(self.codec.codec_id()); + if let Some(pic) = &self.picture { + p = p.with_size(pic.frame_width(), pic.frame_height()); + } + if let Some(s) = &self.sound { + let rate = if s.sample_rate.is_valid() { s.sample_rate.as_f64().round().clamp(0.0, f64::from(u32::MAX)) as u32 } else { 0 }; + p = p.with_sample_rate(rate).with_channels(u16::try_from(s.channels).unwrap_or(0)); + if self.sound_format == SoundFormat::Pcm + && let Ok(bits @ 1..=32) = u16::try_from(s.bits) + { + p = p.with_pcm(crate::PcmFormat::int(bits, bits > 8, false)); + } + } + p + } + + /// First presented edit unit (stored position of the material package's zero point). + pub fn first_edit_unit(&self) -> i64 { + self.origin.saturating_add(self.start_position).max(0) + } + /// Total sound sample frames stored. + pub fn stored_sample_frames(&self) -> u64 { + self.chunks.last().map_or(0, |c| c.first_sample.saturating_add(c.samples)) + } + /// Sound sample frames per edit unit (rational), e.g. 48000/25. + pub fn samples_per_edit_unit(&self) -> (i64, i64) { + let sr = self.sound.as_ref().map(|s| s.sample_rate).unwrap_or_default(); + let er = self.edit_rate; + if !sr.is_valid() || !er.is_valid() { + return (1, 1); + } + (sr.num as i64 * er.den as i64, sr.den as i64 * er.num as i64) + } + /// Nearest random-access sample at or before stored index `i` (0 for a track without + /// picture samples). + pub fn sync_before(&self, i: usize) -> usize { + let i = i.min(self.samples.len().saturating_sub(1)); + (0..=i).rev().find(|&k| self.samples.get(k).is_some_and(|s| s.key)).unwrap_or(0) + } + /// The stored sample presented at display position `d`. + pub fn sample_at(&self, d: i64) -> Option { + if d < 0 { + return None; + } + self.display_order.get(d as usize).copied() + } +} + +/// An opened MXF file. +#[derive(Clone, Debug)] +pub struct MxfFile { + /// Bytes before the header partition pack. + pub run_in: u64, + pub operational_pattern: OperationalPattern, + pub operational_pattern_label: Ul, + pub partitions: Vec, + pub index_segments: Vec, + pub tracks: Vec, + /// Start timecode of the material package (file package when the material package has none). + pub timecode: Option, + pub material_package_name: Option, + pub product_name: Option, + pub company_name: Option, + /// Index of the partition whose header metadata was used. + pub metadata_partition: usize, + /// Problems recovered from (truncation, unresolved references…). + pub warnings: Vec, + pub file_size: u64, +} + +/// An essence element found by the KLV walk. +#[derive(Clone, Copy, Debug)] +struct Element { + key: Ul, + value_offset: u64, + size: u64, + /// Cut short by the end of the file. + truncated: bool, +} + +struct WalkResult { + run_in: u64, + partitions: Vec, + /// Header metadata per partition index: (primer, sets as (key, value)). + metadata: Vec<(usize, Primer, Vec<(Ul, Vec)>)>, + index: Vec, + elements: Vec, + warnings: Vec, +} + +/// Values larger than this are never read while opening (metadata and index values are small). +const MAX_META_VALUE: u64 = 64 << 20; +/// Samples (frames x channels) of zero padding `read_pcm` will produce past the stored essence. +const PCM_PAD_FRAMES_LIMIT: usize = 1 << 24; + +/// Read size of a `Window` refill while the KLVs are small. +const WINDOW_LARGE: usize = 64 << 10; +/// Read size of a refill while big values are around: after a value of `WINDOW_LARGE` bytes or more +/// (a picture, a clip wrapped sound element) the next header is only a few bytes, and the rest of a +/// big read would be skipped again by the next jump. +const WINDOW_SMALL: usize = 4 << 10; +/// KLVs after a big value that still use `WINDOW_SMALL`. A frame holds several elements (picture, +/// sound) and only some of them are big, so one small KLV must not switch back to big reads. +const SMALL_KLVS: u32 = 4; + +/// Buffered reads of KLV headers. +struct Window<'a, S: ByteSource + ?Sized> { + src: &'a S, + off: u64, + buf: Vec, + /// Read size of the next refill (see `WINDOW_LARGE`/`WINDOW_SMALL`). + refill: usize, + /// KLVs left before `refill` is `WINDOW_LARGE` again. + small_left: u32, +} + +impl Window<'_, S> { + /// Choose the next refill size after walking a KLV with a value of `vlen` bytes. + fn skipped(&mut self, vlen: u64) { + self.small_left = if vlen >= WINDOW_LARGE as u64 { SMALL_KLVS } else { self.small_left.saturating_sub(1) }; + self.refill = if self.small_left > 0 { WINDOW_SMALL } else { WINDOW_LARGE }; + } +} + +impl Window<'_, S> { + fn get(&mut self, at: u64, n: usize) -> std::io::Result<&[u8]> { + let have = at >= self.off && at + n as u64 <= self.off + self.buf.len() as u64; + if !have { + self.buf = read_upto(self.src, at, n.max(self.refill))?; + self.off = at; + } + let a = (at - self.off) as usize; + Ok(&self.buf[a..(a + n).min(self.buf.len())]) + } +} + +fn walk(src: &(impl ByteSource + ?Sized)) -> Result { + let len = src.len(); + let head = read_upto(src, 0, 65_536 + 32)?; + let run_in = find_header_partition(&head).ok_or_else(|| Error::NotMxf("no header partition pack".into()))? as u64; + let mut w = WalkResult { run_in, partitions: Vec::new(), metadata: Vec::new(), index: Vec::new(), elements: Vec::new(), warnings: Vec::new() }; + let mut win = Window { src, off: 0, buf: Vec::new(), refill: WINDOW_LARGE, small_left: 0 }; + let mut pos = run_in; + let mut cur_meta: Option = None; + let mut resync_budget = 64 << 20; + while pos + 17 <= len { + let mut hdr_buf = [0u8; 25]; + let got = win.get(pos, 25)?; + let hdr_len = got.len(); + hdr_buf[..hdr_len].copy_from_slice(got); + let hdr = &hdr_buf[..hdr_len]; + let key = Ul(hdr[..16].try_into().unwrap_or([0; 16])); + if !key.is_smpte() { + // Lost sync (corrupt data): look for the next partition pack. + match resync(src, pos + 1, len, &mut resync_budget)? { + Some(p) => { + w.warnings.push(format!("lost KLV sync at byte {pos}; resumed at {p}")); + pos = p; + continue; + } + None => { + w.warnings.push(format!("lost KLV sync at byte {pos}")); + break; + } + } + } + let Some((vlen, n)) = ber_length(&hdr[16..]) else { + w.warnings.push(format!("bad KLV length at byte {pos}")); + break; + }; + let vstart = pos + 16 + n as u64; + let Some(vend) = vstart.checked_add(vlen) else { break }; + let class = classify(&key); + if vend > len { + w.warnings.push(format!("file truncated: KLV at byte {pos} needs {} bytes, {} left", vlen, len.saturating_sub(vstart))); + if class == KeyClass::Essence && vstart < len && len - vstart > 0 && is_essence_item(&key) { + // keep a truncated clip-wrapped element's whole edit units + w.elements.push(Element { key, value_offset: vstart, size: len - vstart, truncated: true }); + } + break; + } + let read_value = || -> Result> { + if vlen > MAX_META_VALUE { + return Err(Error::Invalid(format!("metadata value of {vlen} bytes at {pos}"))); + } + Ok(read_upto(src, vstart, vlen as usize)?) + }; + match class { + KeyClass::Partition { kind, status } => { + let v = read_value()?; + match Partition::parse(pos, kind, status, &v) { + Some(p) => w.partitions.push(p), + None => w.warnings.push(format!("short partition pack at byte {pos}")), + } + cur_meta = None; + } + KeyClass::Primer => { + let v = read_value()?; + if let Some(pi) = w.partitions.len().checked_sub(1) { + w.metadata.push((pi, Primer::parse(&v), Vec::new())); + cur_meta = Some(w.metadata.len() - 1); + } + } + KeyClass::LocalSet => { + if let Some(m) = cur_meta { + let v = read_value()?; + w.metadata[m].2.push((key, v)); + } + } + KeyClass::IndexSegment => { + let v = read_value()?; + w.index.push(IndexSegment::parse(&v)); + cur_meta = None; + } + KeyClass::Essence => { + cur_meta = None; + if let Some(p) = w.partitions.last_mut() + && p.essence_start.is_none() + { + p.essence_start = Some(pos); + } + if is_essence_item(&key) { + w.elements.push(Element { key, value_offset: vstart, size: vlen, truncated: false }); + } + } + KeyClass::Fill | KeyClass::RandomIndexPack | KeyClass::Other => {} + } + win.skipped(vlen); + pos = vend; + } + if w.partitions.is_empty() { + return Err(Error::NotMxf("no readable partition pack".into())); + } + Ok(w) +} + +/// Generic container picture / sound / data / compound items (not system items). +fn is_essence_item(k: &Ul) -> bool { + matches!(k.0[12], 0x05 | 0x06 | 0x07 | 0x15 | 0x16 | 0x17 | 0x18) +} + +/// The next partition pack key at or after `from` (scans at most 16 MiB and shares `budget` +/// with every other recovery attempt in this file). +fn resync(src: &(impl ByteSource + ?Sized), from: u64, len: u64, budget: &mut usize) -> Result> { + let mut at = from; + let end = len.min(from + (16 << 20)); + while at < end && *budget > 0 { + let chunk = read_upto(src, at, (1 << 20).min(*budget))?; + *budget = budget.saturating_sub(chunk.len()); + if chunk.len() < 16 { + break; + } + for i in 0..=chunk.len() - 16 { + if chunk[i..i + 13] == crate::mxf::klv::PARTITION_PREFIX && (2..=4).contains(&chunk[i + 13]) { + return Ok(Some(at + i as u64)); + } + } + at += (chunk.len() - 15) as u64; + } + Ok(None) +} + +/// Data definition labels (ST 377-1 Annex / RP 224): 06 0E 2B 34 04 01 01 01 01 03 02 kk tt 00 00 00. +fn track_kind_of(def: Option
                      ) -> Option<(bool, TrackKind)> { + let d = def?; + if !d.item_starts_with(&[0x01, 0x03, 0x02]) { + return None; + } + match (d.0[11], d.0[12]) { + (0x01, _) => Some((true, TrackKind::Data)), + (0x02, 0x01) => Some((false, TrackKind::Picture)), + (0x02, 0x02) => Some((false, TrackKind::Sound)), + _ => Some((false, TrackKind::Data)), + } +} + +/// The structural components of a track's sequence (or the component itself). +fn components<'a>(md: &'a Metadata, track: &Set) -> (Option<&'a Set>, Vec<&'a Set>) { + let Some(seq) = track.reference(tag::TRACK_SEQUENCE).and_then(|r| md.get(&r)) else { return (None, Vec::new()) }; + if seq.kind() == Some(st::SEQUENCE) { + (Some(seq), seq.references(tag::STRUCTURAL_COMPONENTS).iter().filter_map(|r| md.get(r)).collect()) + } else { + (Some(seq), vec![seq]) + } +} + +fn timecode_of(c: &Set) -> Option { + Some(Timecode { + start: c.i(tag::START_TIMECODE)?, + rounded_base: c.u(tag::ROUNDED_TIMECODE_BASE).unwrap_or(0) as u16, + drop_frame: c.u(tag::DROP_FRAME).unwrap_or(0) != 0, + }) +} + +/// The first timecode component of a package's timecode tracks. +fn package_timecode(md: &Metadata, pkg: &Set) -> Option { + for r in pkg.references(tag::PACKAGE_TRACKS) { + let Some(t) = md.get(&r) else { continue }; + let (_, comps) = components(md, t); + if let Some(tc) = comps.iter().filter(|c| c.kind() == Some(st::TIMECODE_COMPONENT)).find_map(|c| timecode_of(c)) { + return Some(tc); + } + } + None +} + +/// The descriptor of file package track `track_id`: the package descriptor, or the matching +/// sub-descriptor of a multiple descriptor. +fn descriptor_for<'a>(md: &'a Metadata, pkg: &Set, track_id: u32, kind: TrackKind) -> Option<&'a Set> { + let d = md.get(&pkg.reference(tag::SOURCE_PACKAGE_DESCRIPTOR)?)?; + if d.kind() != Some(st::MULTIPLE_DESCRIPTOR) { + return Some(d); + } + let subs: Vec<&Set> = d.references(tag::SUB_DESCRIPTORS).iter().filter_map(|r| md.get(r)).collect(); + subs.iter() + .find(|s| s.u(tag::LINKED_TRACK_ID) == Some(track_id as i64)) + .or_else(|| subs.iter().find(|s| descriptor_kind(s) == Some(kind))) + .copied() +} + +fn descriptor_kind(s: &Set) -> Option { + match s.kind()? { + st::GENERIC_PICTURE_DESCRIPTOR | st::CDCI_DESCRIPTOR | st::RGBA_DESCRIPTOR | st::MPEG2_VIDEO_DESCRIPTOR => Some(TrackKind::Picture), + st::GENERIC_SOUND_DESCRIPTOR | st::AES3_DESCRIPTOR | st::WAVE_DESCRIPTOR => Some(TrackKind::Sound), + st::GENERIC_DATA_DESCRIPTOR => Some(TrackKind::Data), + _ => None, + } +} + +fn picture_info(d: &Set) -> PictureInfo { + let u = |t| d.u(t).unwrap_or(0) as u32; + PictureInfo { + stored_width: u(tag::STORED_WIDTH), + stored_height: u(tag::STORED_HEIGHT), + display_width: u(tag::DISPLAY_WIDTH), + display_height: u(tag::DISPLAY_HEIGHT), + frame_layout: u(tag::FRAME_LAYOUT) as u8, + aspect_ratio: d.rational(tag::ASPECT_RATIO).unwrap_or_default(), + component_depth: u(tag::COMPONENT_DEPTH), + horizontal_subsampling: u(tag::HORIZONTAL_SUBSAMPLING), + vertical_subsampling: u(tag::VERTICAL_SUBSAMPLING), + black_ref_level: d.u(tag::BLACK_REF_LEVEL).map(|v| v as u32), + white_ref_level: d.u(tag::WHITE_REF_LEVEL).map(|v| v as u32), + alpha_depth: u(tag::ALPHA_SAMPLE_DEPTH), + rgba: d.kind() == Some(st::RGBA_DESCRIPTOR), + picture_coding: d.ul(tag::PICTURE_ESSENCE_CODING), + transfer_characteristic: d.ul(tag::TRANSFER_CHARACTERISTIC), + coding_equations: d.ul(tag::CODING_EQUATIONS), + color_primaries: d.ul(tag::COLOR_PRIMARIES), + } +} + +fn sound_info(d: &Set) -> SoundInfo { + SoundInfo { + sample_rate: d.rational(tag::AUDIO_SAMPLING_RATE).filter(Rational::is_valid).or_else(|| d.rational(tag::SAMPLE_RATE)).unwrap_or_default(), + channels: d.u(tag::CHANNEL_COUNT).unwrap_or(1) as u32, + bits: d.u(tag::QUANTIZATION_BITS).unwrap_or(16) as u32, + block_align: d.u(tag::BLOCK_ALIGN).unwrap_or(0) as u32, + locked: d.u(tag::LOCKED).map(|v| v != 0), + sound_coding: d.ul(tag::SOUND_ESSENCE_CODING), + } +} + +/// Resolved track before the sample tables are built. +struct TrackRef { + track_id: u32, + track_number: u32, + kind: TrackKind, + name: Option, + edit_rate: Rational, + origin: i64, + start_position: i64, + duration: Option, + descriptor: Option>, + sub_descriptors: Vec>, + body_sid: u32, + index_sid: u32, +} + +fn umid_of(s: &Set, t: u16) -> Option<[u8; 32]> { + s.get(t).and_then(|v| v.get(..32)).map(|b| b.try_into().unwrap_or([0; 32])) +} + +/// Material package tracks → file package tracks with descriptors. +fn resolve_tracks(md: &Metadata, warnings: &mut Vec) -> (Vec, Option, Option) { + let sources: Vec<&Set> = md.of_kind(st::SOURCE_PACKAGE).collect(); + let file_pkg = |umid: &[u8; 32]| sources.iter().find(|p| umid_of(p, tag::PACKAGE_UID).as_ref() == Some(umid)).copied(); + // EssenceContainerData: package → (body SID, index SID) + let mut sids: HashMap<[u8; 32], (u32, u32)> = HashMap::new(); + for e in md.of_kind(st::ESSENCE_CONTAINER_DATA) { + if let Some(u) = umid_of(e, tag::ESSENCE_CONTAINER_DATA_LINKED_PACKAGE) { + sids.insert(u, (e.u(tag::BODY_SID).unwrap_or(0) as u32, e.u(tag::INDEX_SID).unwrap_or(0) as u32)); + } + } + let material = md.of_kind(st::MATERIAL_PACKAGE).next(); + let mut out = Vec::new(); + let mut descriptor_cache: HashMap<[u8; 16], Arc> = HashMap::new(); + let intern_descriptor = |set: &Set, cache: &mut HashMap<[u8; 16], Arc>| { + if set.instance_uid == [0; 16] { + Arc::new(set.clone()) + } else { + cache.entry(set.instance_uid).or_insert_with(|| Arc::new(set.clone())).clone() + } + }; + let mut timecode = material.and_then(|m| package_timecode(md, m)); + let name = material.and_then(|m| m.string(tag::PACKAGE_NAME)); + let mut file_track = + |pkg: &Set, kind: Option, id: Option, start: i64, duration: Option, name: Option| -> Option { + let umid = umid_of(pkg, tag::PACKAGE_UID).unwrap_or([0; 32]); + for r in pkg.references(tag::PACKAGE_TRACKS) { + let Some(t) = md.get(&r) else { continue }; + let tid = t.u(tag::TRACK_ID).unwrap_or(0) as u32; + if id.is_some_and(|i| i != tid) { + continue; + } + let (seq, _) = components(md, t); + let k = track_kind_of(seq.and_then(|s| s.ul(tag::DATA_DEFINITION))); + let tk = match (k, kind) { + (Some((false, k)), _) => k, + (_, Some(k)) => k, + _ => continue, + }; + if tk == TrackKind::Data && k.is_some_and(|(tc, _)| tc) { + continue; + } + let desc = descriptor_for(md, pkg, tid, tk); + let descriptor = desc.map(|d| intern_descriptor(d, &mut descriptor_cache)); + let subs: Vec> = desc + .and_then(|d| d.dynamic_item(&SUB_DESCRIPTORS_ITEM)) + .map(|v| { + crate::mxf::klv::batch_of(v) + .into_iter() + .filter_map(|b| b.get(..16)) + .filter_map(|b| md.get(&b.try_into().unwrap_or([0; 16]))) + .map(|s| intern_descriptor(s, &mut descriptor_cache)) + .collect() + }) + .unwrap_or_default(); + let (body_sid, index_sid) = sids.get(&umid).copied().unwrap_or((0, 0)); + return Some(TrackRef { + track_id: tid, + track_number: t.u(tag::TRACK_NUMBER).unwrap_or(0) as u32, + kind: tk, + name: name.clone().or_else(|| t.string(tag::TRACK_NAME)), + edit_rate: t.rational(tag::EDIT_RATE).unwrap_or_default(), + origin: t.i(tag::ORIGIN).unwrap_or(0), + start_position: start, + duration, + descriptor, + sub_descriptors: subs, + body_sid, + index_sid, + }); + } + None + }; + if let Some(m) = material { + for r in m.references(tag::PACKAGE_TRACKS) { + let Some(t) = md.get(&r) else { continue }; + let (seq, comps) = components(md, t); + let Some((false, kind)) = track_kind_of(seq.and_then(|s| s.ul(tag::DATA_DEFINITION))) else { continue }; + if kind == TrackKind::Data { + continue; + } + let Some(clip) = comps.iter().find(|c| c.kind() == Some(st::SOURCE_CLIP)) else { continue }; + let Some(umid) = umid_of(clip, tag::SOURCE_PACKAGE_ID) else { continue }; + let Some(pkg) = file_pkg(&umid) else { + warnings.push(format!("material track {} refers to a package not in this file", t.u(tag::TRACK_ID).unwrap_or(0))); + continue; + }; + let src_track = clip.u(tag::SOURCE_TRACK_ID).map(|v| v as u32); + let duration = clip.i(tag::DURATION).or_else(|| seq.and_then(|s| s.i(tag::DURATION))).filter(|d| *d >= 0); + // durations are in the material track's edit rate; convert when the file track's differs + let mrate = t.rational(tag::EDIT_RATE).unwrap_or_default(); + if let Some(mut tr) = + file_track(pkg, Some(kind), src_track, clip.i(tag::START_POSITION).unwrap_or(0), duration, t.string(tag::TRACK_NAME)) + { + if mrate.is_valid() && tr.edit_rate.is_valid() && mrate != tr.edit_rate { + let conv = + |v: i64| (v as i128 * tr.edit_rate.num as i128 * mrate.den as i128 / (tr.edit_rate.den as i128 * mrate.num as i128)) as i64; + tr.duration = tr.duration.map(conv); + tr.start_position = conv(tr.start_position); + } + if timecode.is_none() { + timecode = package_timecode(md, pkg); + } + if !out.iter().any(|o: &TrackRef| o.track_id == tr.track_id && o.track_number == tr.track_number) { + out.push(tr); + } + } + } + } + if out.is_empty() { + // No usable material package: present the file packages' essence tracks directly. + for pkg in &sources { + if pkg.reference(tag::SOURCE_PACKAGE_DESCRIPTOR).is_none() { + continue; + } + // A package that names one track many times would otherwise build (and retain) its sample tables per mention. + let mut seen = std::collections::HashSet::new(); + for r in pkg.references(tag::PACKAGE_TRACKS) { + let Some(t) = md.get(&r) else { continue }; + let id = t.u(tag::TRACK_ID).unwrap_or(0) as u32; + if !seen.insert(id) { + continue; + } + if let Some(tr) = file_track(pkg, None, Some(id), 0, None, None) { + out.push(tr); + } + } + if timecode.is_none() { + timecode = package_timecode(md, pkg); + } + } + } + (out, timecode, name) +} + +/// Open an MXF file: walk the KLV structure and resolve the essence tracks. +pub fn open(src: &(impl ByteSource + ?Sized)) -> Result { + let w = walk(src)?; + let mut warnings = w.warnings; + // Metadata of the most complete partition (closed complete > open complete > closed > open; + // later partitions win ties: the footer has the final durations). + let best = w + .metadata + .iter() + .enumerate() + .filter(|(_, m)| !m.2.is_empty()) + .max_by_key(|(_, m)| { + let p = &w.partitions[m.0]; + (p.complete(), p.closed(), p.offset) + }) + .map(|(i, _)| i); + let Some(best) = best else { return Err(Error::Invalid("no header metadata".into())) }; + let (meta_partition, primer, sets) = &w.metadata[best]; + let mut md = Metadata::default(); + for (k, v) in sets { + md.push(Set::parse(*k, v, primer)); + } + let preface = md.of_kind(st::PREFACE).next(); + let op_label = preface.and_then(|p| p.ul(tag::OPERATIONAL_PATTERN)).unwrap_or(w.partitions[0].operational_pattern); + let ident = md.of_kind(st::IDENTIFICATION).last(); + let product_name = ident.and_then(|i| i.string(tag::PRODUCT_NAME)); + let company_name = ident.and_then(|i| i.string(tag::COMPANY_NAME)); + let (refs, timecode, material_name) = resolve_tracks(&md, &mut warnings); + if refs.is_empty() { + return Err(Error::Invalid("no essence tracks in the header metadata".into())); + } + let mut tracks = Vec::new(); + let mut claimed: Vec<[u8; 4]> = Vec::new(); + let all_numbers: Vec<[u8; 4]> = { + let mut v: Vec<[u8; 4]> = w.elements.iter().map(|e| e.key.0[12..16].try_into().unwrap_or([0; 4])).collect(); + v.dedup(); + v.sort_unstable(); + v.dedup(); + v + }; + for r in &refs { + let number = r.track_number.to_be_bytes(); + let mut key4 = all_numbers.iter().find(|n| **n == number).copied(); + if key4.is_none() { + // Track number missing or not matching (some OP-Atom writers): the only unclaimed + // element stream of the right kind. + let kind_items: &[u8] = match r.kind { + TrackKind::Picture => &[0x05, 0x15], + TrackKind::Sound => &[0x06, 0x16], + TrackKind::Data => &[0x07, 0x17], + }; + let cands: Vec<[u8; 4]> = all_numbers.iter().filter(|n| kind_items.contains(&n[0]) && !claimed.contains(n)).copied().collect(); + if cands.len() == 1 { + key4 = Some(cands[0]); + } + } + let Some(key4) = key4 else { + warnings.push(format!("no essence in this file for track {} (number {:08x})", r.track_id, r.track_number)); + continue; + }; + claimed.push(key4); + let elements: Vec = w.elements.iter().filter(|e| e.key.0[12..16] == key4).copied().collect(); + tracks.push(build_track(src, r, &elements, key4, &w.index, &mut warnings)?); + } + if tracks.is_empty() { + return Err(Error::Unsupported("no essence of the material package is stored in this file".into())); + } + Ok(MxfFile { + run_in: w.run_in, + operational_pattern: OperationalPattern::from_ul(&op_label), + operational_pattern_label: op_label, + index_segments: w.index, + partitions: w.partitions.clone(), + tracks, + timecode, + material_package_name: material_name, + product_name, + company_name, + metadata_partition: *meta_partition, + warnings, + file_size: src.len(), + }) +} + +/// Index entries per stored edit unit for an index SID (all segments when `sid` is 0 or unmatched). +fn index_entries(index: &[IndexSegment], sid: u32) -> (std::collections::BTreeMap, u32) { + let any = index.iter().any(|s| s.index_sid == sid); + let segs: Vec<&IndexSegment> = index.iter().filter(|s| !any || s.index_sid == sid).collect(); + // ST 377-1 segments use absolute edit-unit positions, which need not start at zero. + // Keep positions sparse so even hostile gaps allocate only for real (already parsed) entries. + let mut out = std::collections::BTreeMap::new(); + let mut eubc = 0; + for s in segs { + if s.edit_unit_byte_count > 0 { + eubc = s.edit_unit_byte_count; + } + for (k, e) in s.entries.iter().enumerate() { + let Some(position) = s.start_position.checked_add(k as i64) else { break }; + let Ok(p) = usize::try_from(position) else { continue }; + out.insert(p, *e); + } + } + (out, eubc) +} + +fn build_track( + src: &(impl ByteSource + ?Sized), + r: &TrackRef, + elements: &[Element], + key4: [u8; 4], + index: &[IndexSegment], + warnings: &mut Vec, +) -> Result { + let desc = r.descriptor.as_ref(); + let container = desc.and_then(|d| d.ul(tag::ESSENCE_CONTAINER)); + let mut t = EssenceTrack { + track_id: r.track_id, + track_number: u32::from_be_bytes(key4), + kind: r.kind, + name: r.name.clone(), + edit_rate: r.edit_rate, + origin: r.origin, + start_position: r.start_position, + duration: r.duration, + codec: Codec::Unknown, + wrapping: Wrapping::Frame, + essence_container: container, + picture: None, + sound: None, + sound_format: SoundFormat::Other, + body_sid: r.body_sid, + index_sid: r.index_sid, + samples: Vec::new(), + display_order: Vec::new(), + chunks: Vec::new(), + temporal_offsets: false, + needs_reorder: false, + indexed: false, + }; + match r.kind { + TrackKind::Picture => { + let pi = desc.map(|d| picture_info(d)).unwrap_or_default(); + let mpeg_desc = desc.is_some_and(|d| d.kind() == Some(st::MPEG2_VIDEO_DESCRIPTOR)); + let mut codec = identify_picture(pi.picture_coding, container, mpeg_desc); + if r.sub_descriptors.iter().any(|s| s.kind() == Some(st::AVC_SUBDESCRIPTOR)) && !matches!(codec, Codec::Avc { .. }) { + codec = Codec::Avc { intra: false }; + } + // A wrong or missing label: trust the essence bytes. + if let Some(first) = elements.first() { + let head = read_upto(src, first.value_offset, 64)?; + if let Some(sniffed) = sniff_picture(&head) { + let agree = std::mem::discriminant(&sniffed) == std::mem::discriminant(&codec); + if !agree && !matches!(codec, Codec::Avc { intra: true }) { + codec = sniffed; + } + } + } + t.codec = codec; + t.picture = Some(pi); + build_pictures(src, &mut t, elements, index, warnings)?; + } + TrackKind::Sound => { + let si = desc.map(|d| sound_info(d)).unwrap_or(SoundInfo { channels: 1, bits: 16, ..Default::default() }); + let d10 = container.is_some_and(|c| c.item_starts_with(&[0x0D, 0x01, 0x03, 0x01, 0x02, 0x01])); + let aes_element = d10 || key4[0] == 0x06 && key4[2] == 0x10; + let pcm_desc = desc.is_some_and(|d| matches!(d.kind(), Some(st::AES3_DESCRIPTOR) | Some(st::WAVE_DESCRIPTOR))); + let pcm_coding = si.sound_coding.is_none_or(|c| !c.is_smpte() || c.item_starts_with(&[0x04, 0x02, 0x02, 0x01]) || c.0 == [0; 16]); + t.sound_format = if aes_element { + SoundFormat::Aes3Element + } else if pcm_desc || pcm_coding { + SoundFormat::Pcm + } else { + SoundFormat::Other + }; + t.codec = match t.sound_format { + SoundFormat::Aes3Element => Codec::Aes3Element, + SoundFormat::Pcm => Codec::Pcm, + SoundFormat::Other => Codec::Unknown, + }; + let fb = si.frame_bytes() as u64; + let mut first = 0u64; + for e in elements { + let n = match t.sound_format { + SoundFormat::Aes3Element => e.size.saturating_sub(4) / 32, + _ => e.size / fb.max(1), + }; + t.chunks.push(Chunk { offset: e.value_offset, size: e.size, first_sample: first, samples: n }); + first += n; + } + t.wrapping = if elements.len() == 1 && r.duration.is_some_and(|d| d > 1) { Wrapping::Clip } else { Wrapping::Frame }; + t.sound = Some(si); + } + TrackKind::Data => {} + } + Ok(t) +} + +fn build_pictures( + src: &(impl ByteSource + ?Sized), + t: &mut EssenceTrack, + elements: &[Element], + index: &[IndexSegment], + warnings: &mut Vec, +) -> Result<()> { + let (entries, eubc) = index_entries(index, t.index_sid); + let expected = t.duration.map(|d| d.saturating_add(t.first_edit_unit()).max(0) as usize); + let clip = elements.len() == 1 && expected.is_none_or(|n| n > 1) && (eubc > 0 || entries.len() > 1); + let mut samples: Vec = Vec::new(); + if clip { + t.wrapping = Wrapping::Clip; + let e = elements[0]; + if eubc > 0 { + let n = e.size / eubc as u64; + for i in 0..n { + samples.push(Sample { offset: e.value_offset + i * eubc as u64, size: eubc, pts: i as i64, key: true, b_picture: false }); + } + } else { + let offs: Vec = + entries.iter().enumerate().map_while(|(position, (&indexed, entry))| (position == indexed).then_some(entry.stream_offset)).collect(); + let base = offs.first().copied().unwrap_or(0); + for (i, o) in offs.iter().enumerate() { + let start = o - base.min(*o); + let end = offs.get(i + 1).map_or(e.size, |n| n - base.min(*n)).min(e.size); + if end <= start { + break; + } + samples.push(Sample { offset: e.value_offset + start, size: (end - start) as u32, pts: i as i64, key: true, b_picture: false }); + } + } + } else { + for (i, e) in elements.iter().filter(|e| !e.truncated).enumerate() { + samples.push(Sample { offset: e.value_offset, size: e.size.min(u32::MAX as u64) as u32, pts: i as i64, key: true, b_picture: false }); + } + } + let n = samples.len(); + // Key frames and B pictures from the index. + let flagged = entries.values().any(|e| e.random_access()); + if !t.codec.intra_only() { + if flagged { + t.indexed = true; + for (i, s) in samples.iter_mut().enumerate() { + let e = entries.get(&i).copied(); + s.key = e.is_some_and(|e| e.random_access()) || i == 0; + s.b_picture = e.is_some_and(|e| e.is_b()); + } + } else { + // No index flags: find the random-access pictures in the bitstream. + if !samples.is_empty() { + warnings.push("no index table with random-access flags: key frames found by scanning the essence".into()); + } + for (i, s) in samples.iter_mut().enumerate() { + let head = read_upto(src, s.offset, (s.size as usize).min(1024))?; + s.key = i == 0 || is_random_access(t.codec, &head); + } + } + } + // Presentation order from the temporal offsets: display d is stored at d + TO[d]. + let mut display: Vec = (0..n).collect(); + let has_to = entries.range(..n).any(|(_, e)| e.temporal_offset != 0); + if has_to { + let mut pts = vec![i64::MIN; n]; + let mut ok = true; + for d in 0..n { + let to = entries.get(&d).copied().map_or(0, |e| e.temporal_offset as i64); + let s = d as i64 + to; + if s < 0 || s >= n as i64 || pts[s as usize] != i64::MIN { + ok = false; + break; + } + pts[s as usize] = d as i64; + display[d] = s as usize; + } + if ok { + t.temporal_offsets = true; + for (s, p) in samples.iter_mut().zip(pts) { + s.pts = p; + } + } else { + warnings.push("inconsistent index temporal offsets: using stored order".into()); + display = (0..n).collect(); + } + } else { + t.needs_reorder = samples.iter().any(|s| s.b_picture); + } + t.samples = samples; + t.display_order = display; + Ok(()) +} + +/// Whether a picture can start decoding (no index): AVC IDR / recovery point, MPEG-2 sequence +/// header + I picture; other codecs: always. +fn is_random_access(codec: Codec, head: &[u8]) -> bool { + match codec { + Codec::Avc { .. } => { + let mut i = 0; + while i + 3 < head.len() { + if head[i] == 0 && head[i + 1] == 0 && head[i + 2] == 1 { + let t = head[i + 3] & 0x1F; + if t == 5 { + return true; + } + if t == 1 { + return false; + } + i += 3; + } else { + i += 1; + } + } + false + } + Codec::Mpeg2 => head.windows(4).any(|w| w == [0, 0, 1, 0xB3]), + _ => true, + } +} + +impl MxfFile { + /// The first track of a kind. + pub fn track_of_kind(&self, kind: TrackKind) -> Option { + self.tracks.iter().position(|t| t.kind == kind && (!t.samples.is_empty() || !t.chunks.is_empty())) + } + + /// The bytes of picture sample `i` (stored order) of track `track`. + pub fn read_sample(&self, src: &(impl ByteSource + ?Sized), track: usize, i: usize) -> Result> { + let t = self.tracks.get(track).ok_or_else(|| Error::Invalid(format!("no track {track}")))?; + let s = t.samples.get(i).ok_or_else(|| Error::Invalid(format!("no sample {i}")))?; + if u64::from(s.size) > src.len().saturating_sub(s.offset) { + return Err(Error::Invalid("picture sample extends past the end of the source".into())); + } + let mut v = vec![0u8; s.size as usize]; + src.read_at(s.offset, &mut v)?; + Ok(v) + } + + /// `frames` sound sample frames from stored sample `start` of track `track`, planar f32 + /// (zero past the end of the essence). + pub fn read_pcm(&self, src: &(impl ByteSource + ?Sized), track: usize, start: u64, frames: usize) -> Result>> { + let t = self.tracks.get(track).ok_or_else(|| Error::Invalid(format!("no track {track}")))?; + let si = t.sound.as_ref().ok_or_else(|| Error::Invalid("not a sound track".into()))?; + let ch = si.channels.max(1) as usize; + if ch > 256 { + return Err(Error::Invalid("sound track declares more than 256 channels".into())); + } + // Bound the output by what the file can actually hold (every stored sample frame takes at + // least one byte per channel), plus slack for zero padding past the end: header-declared + // sizes cannot force a giant allocation, while any legitimately long read still works. + let backed = t.stored_sample_frames().saturating_sub(start).min(src.len() / ch as u64); + let allowed = usize::try_from(backed).unwrap_or(usize::MAX).saturating_add(PCM_PAD_FRAMES_LIMIT / ch); + if frames > allowed { + return Err(Error::Invalid("sound request extends far past the stored essence".into())); + } + if t.sound_format == SoundFormat::Pcm && (si.bits == 0 || si.bits > 32) { + return Err(Error::Unsupported("invalid PCM bit depth or block alignment".into())); + } + let fb = if t.sound_format == SoundFormat::Pcm { si.frame_bytes() } else { 0 }; + if t.sound_format == SoundFormat::Pcm && (fb == 0 || fb > 4096) { + return Err(Error::Unsupported("invalid PCM block alignment".into())); + } + let mut out = vec![vec![0f32; frames]; ch]; + let end = start.saturating_add(frames as u64); + let mut ci = t.chunks.partition_point(|c| c.first_sample.saturating_add(c.samples) <= start); + while let Some(c) = t.chunks.get(ci) { + if c.first_sample >= end { + break; + } + let a = start.max(c.first_sample); + let b = end.min(c.first_sample.saturating_add(c.samples)); + if b > a { + let decoded = match t.sound_format { + SoundFormat::Pcm => { + let off = (a - c.first_sample) + .checked_mul(fb as u64) + .and_then(|n| c.offset.checked_add(n)) + .ok_or_else(|| Error::Invalid("PCM chunk offset overflows".into()))?; + let bytes = ((b - a) as usize).checked_mul(fb).ok_or_else(|| Error::Invalid("PCM chunk size overflows".into()))?; + // Never allocate past the end of the source (a truncated or lying element). + let in_file = usize::try_from(src.len().saturating_sub(off)).unwrap_or(usize::MAX); + let bytes = bytes.min(in_file / fb * fb); + let mut v = vec![0u8; bytes]; + src.read_at(off, &mut v)?; + decode_pcm(&v, SoundFormat::Pcm, ch, si.bits, fb)? + } + SoundFormat::Aes3Element => { + // Read only the requested sample frames (32 bytes each behind the 4-byte + // element header), not the whole element: a clip-wrapped or hostile + // element would otherwise be read and decoded in full on every call. + if ch > 8 { + return Err(Error::Invalid("an AES3 element has at most eight channels".into())); + } + let avail = c.size.min(src.len().saturating_sub(c.offset)); + let mut head = [0u8; 4]; + let total = if avail >= 4 { + src.read_at(c.offset, &mut head)?; + // same bound as `aes3_element_samples` + u64::from(u16::from_le_bytes([head[1], head[2]])).min((avail - 4) / 32) + } else { + 0 + }; + let s0 = a - c.first_sample; + let n = (b - a).min(total.saturating_sub(s0)); + let mut v = vec![0u8; n as usize * 32]; + if n > 0 { + src.read_at(c.offset + 4 + s0 * 32, &mut v)?; + } + decode_aes3_frames(&v, ch) + } + SoundFormat::Other => return Err(Error::Unsupported("sound essence coding".into())), + }; + let at = (a - start) as usize; + for (c, d) in decoded.iter().enumerate().take(ch) { + for (k, v) in d.iter().take((b - a) as usize).enumerate() { + out[c][at + k] = *v; + } + } + } + ci += 1; + } + Ok(out) + } +} + +#[cfg(test)] +mod index_bounds_tests { + use super::*; + + #[test] + fn nonzero_segment_positions_and_sparse_gaps_preserve_entries() { + let entry = IndexEntry { flags: 0x80, stream_offset: 1234, ..Default::default() }; + for start_position in [5, 49_999_999, i64::MAX] { + let segment = IndexSegment { start_position, entries: vec![entry, entry], ..Default::default() }; + let (entries, _) = index_entries(&[segment], 0); + let start = usize::try_from(start_position).unwrap(); + assert_eq!(entries.get(&start), Some(&entry)); + assert_eq!(entries.len(), if start_position == i64::MAX { 1 } else { 2 }); + } + } + + #[test] + fn negative_preroll_positions_do_not_drop_later_valid_entries() { + let segment = IndexSegment { start_position: -1, entries: vec![IndexEntry::default(); 3], ..Default::default() }; + let (entries, _) = index_entries(&[segment], 0); + assert_eq!(entries.keys().copied().collect::>(), vec![0, 1]); + } +} + +#[cfg(test)] +mod aes3_read_tests { + use std::cell::Cell; + + use super::*; + use crate::mxf::ByteSource; + + /// A source that remembers its largest single read. + struct Counting<'a> { + data: &'a [u8], + biggest: Cell, + } + + impl ByteSource for Counting<'_> { + fn len(&self) -> u64 { + self.data.len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + self.biggest.set(self.biggest.get().max(buf.len())); + self.data.read_at(offset, buf) + } + } + + /// One clip-wrapped AES3 element of `declared` sample frames in a file that holds `stored`. + fn aes3_file(declared: u16, stored: usize, channels: u32) -> (MxfFile, Vec) { + let mut data = vec![0x00, declared as u8, (declared >> 8) as u8, 0xFF]; + for i in 0..stored * 8 { + let s = ((i as i32 * 7919) % 1_000_000) - 500_000; + data.extend_from_slice(&((((s as u32) & 0xFF_FFFF) << 4) | (i as u32 & 15)).to_le_bytes()); + } + let samples = (data.len() as u64 - 4) / 32; + let track = EssenceTrack { + track_id: 1, + track_number: 0, + kind: TrackKind::Sound, + name: None, + edit_rate: Rational::default(), + origin: 0, + start_position: 0, + duration: None, + codec: Codec::Aes3Element, + wrapping: Wrapping::Clip, + essence_container: None, + picture: None, + sound: Some(SoundInfo { channels, bits: 24, ..Default::default() }), + sound_format: SoundFormat::Aes3Element, + body_sid: 1, + index_sid: 0, + samples: Vec::new(), + display_order: Vec::new(), + chunks: vec![Chunk { offset: 0, size: data.len() as u64, first_sample: 0, samples }], + temporal_offsets: false, + needs_reorder: false, + indexed: false, + }; + let file = MxfFile { + run_in: 0, + operational_pattern: OperationalPattern::Atom, + operational_pattern_label: Ul::default(), + partitions: Vec::new(), + index_segments: Vec::new(), + tracks: vec![track], + timecode: None, + material_package_name: None, + product_name: None, + company_name: None, + metadata_partition: 0, + warnings: Vec::new(), + file_size: data.len() as u64, + }; + (file, data) + } + + #[test] + fn aes3_reads_decode_only_the_requested_frames() { + for (declared, stored) in [(5000, 5000), (3000, 5000)] { + let (file, data) = aes3_file(declared, stored, 2); + let whole = decode_pcm(&data, SoundFormat::Aes3Element, 2, 24, 0).unwrap(); + assert_eq!(whole[0].len(), declared as usize); + for (start, frames) in [(0u64, 1920usize), (1920, 1920), (2999, 4), (2990, 2000), (4990, 100), (6000, 10)] { + let src = Counting { data: &data, biggest: Cell::new(0) }; + let got = file.read_pcm(&src, 0, start, frames).unwrap(); + for (c, g) in got.iter().enumerate() { + let want: Vec = (0..frames).map(|k| whole[c].get(start as usize + k).copied().unwrap_or(0.0)).collect(); + assert_eq!(g, &want, "declared {declared}, start {start}, frames {frames}, channel {c}"); + } + assert!(src.biggest.get() <= frames * 32, "read {} bytes for {frames} frames", src.biggest.get()); + } + } + } + + #[test] + fn aes3_read_rejects_more_than_eight_channels() { + let (file, data) = aes3_file(10, 10, 9); + assert!(file.read_pcm(&data, 0, 0, 4).is_err()); + } +} + +#[cfg(test)] +mod fallback_tracks_tests { + use super::*; + + fn set(kind: u8, uid: u8, props: &[(u16, Vec)]) -> Set { + let mut key = [0x06, 0x0E, 0x2B, 0x34, 0x02, 0x53, 0x01, 0x01, 0x0D, 0x01, 0x01, 0x01, 0x01, 0x01, kind, 0x00]; + key[15] = 0; + let mut s = Set { key: Ul(key), instance_uid: [uid; 16], ..Default::default() }; + s.props.extend(props.iter().cloned()); + s + } + + fn batch(uids: &[u8]) -> Vec { + let mut v = (uids.len() as u32).to_be_bytes().to_vec(); + v.extend(16u32.to_be_bytes()); + for u in uids { + v.extend([*u; 16]); + } + v + } + + #[test] + fn a_track_named_many_times_resolves_once() { + let mut md = Metadata::default(); + let picture = Ul([0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x02, 0x01, 0x00, 0x00, 0x00]); + md.push(set(st::SOURCE_PACKAGE, 1, &[(tag::PACKAGE_TRACKS, batch(&[2; 500])), (tag::SOURCE_PACKAGE_DESCRIPTOR, vec![3; 16])])); + md.push(set(st::TIMELINE_TRACK, 2, &[(tag::TRACK_ID, vec![0, 0, 0, 1]), (tag::TRACK_SEQUENCE, vec![4; 16])])); + md.push(set(st::GENERIC_PICTURE_DESCRIPTOR, 3, &[])); + md.push(set(st::SEQUENCE, 4, &[(tag::DATA_DEFINITION, picture.0.to_vec())])); + let (tracks, _, _) = resolve_tracks(&md, &mut Vec::new()); + assert_eq!(tracks.len(), 1); + } +} diff --git a/crates/media/src/mxf/hostile_tests.rs b/crates/media/src/mxf/hostile_tests.rs new file mode 100644 index 00000000..084f7be0 --- /dev/null +++ b/crates/media/src/mxf/hostile_tests.rs @@ -0,0 +1,358 @@ +//! Hostile input: written files with single fields of their metadata, index tables and partition +//! packs set to extreme values, KLV lengths that lie, and the public parsers on arbitrary bytes. +//! Errors, warnings and empty tables are fine; a panic (an index out of range, an overflow or a +//! giant allocation in a debug build) is not. + +use std::io::Cursor; + +use super::write::{ + CodedKind, FrameInfo, MxfWriter, OpAtomPcm, PackageIds, Pattern, PictureCoding, PictureDesc, SoundDesc, StartTimecode, WriterConfig, + write_opatom_pcm, +}; +use super::{Error, IndexSegment, Rational, SoundFormat, aes3_element_samples, decode_pcm, klv, open, sniff}; + +/// Deterministic xorshift. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +/// OP1a: long-GOP AVC (temporal offsets, key and B flags) with stereo 16-bit and mono 24-bit sound. +fn op1a_avc() -> Vec { + let rate = Rational::new(30_000, 1001); + let mut cfg = WriterConfig::new(Pattern::Op1a, rate, PackageIds::from_seed("hostile", "Hostile")); + cfg.picture = Some(PictureDesc::new(PictureCoding::Avc { profile_idc: 100, intra: false }, 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: 107_892, rate, drop_frame: true }); + let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap(); + let order = + [(0, CodedKind::Intra, true), (3, CodedKind::Predicted, false), (1, CodedKind::Bidirectional, false), (2, CodedKind::Bidirectional, false)]; + for gop in 0..3i64 { + for (i, (d, kind, key)) in order.iter().enumerate() { + let au = [&[0, 0, 0, 1, if *key { 0x65 } else { 0x41 }][..], &[i as u8; 40]].concat(); + w.push_picture(&au, FrameInfo { key: *key, kind: *kind, display: Some(gop * 4 + d) }).unwrap(); + w.push_sound(0, &vec![i as u8; 1602 * 4]).unwrap(); + w.push_sound(1, &vec![gop as u8; 1602 * 3]).unwrap(); + } + } + w.finish().unwrap().into_inner() +} + +/// OP-Atom: a clip-wrapped VC-3 picture file. +fn atom_vc3() -> Vec { + let mut cfg = WriterConfig::new(Pattern::OpAtom, Rational::new(24, 1), PackageIds::from_seed("atom", "Atom")); + cfg.picture = Some(PictureDesc::new(PictureCoding::Vc3 { cid: 1272 }, 64, 36)); + let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap(); + for i in 0..9u8 { + w.push_picture(&[&[0, 0, 2, 0x80, 1][..], &vec![i; 300 + 7 * i as usize]].concat(), FrameInfo::intra()).unwrap(); + } + w.finish().unwrap().into_inner() +} + +/// OP-Atom PCM, with the sample rate or a frame rate as the edit rate. +fn atom_pcm(edit_rate: Option) -> Vec { + let opts = OpAtomPcm { sample_rate: 48_000, bits: 24, channels: 2, edit_rate, ids: PackageIds::from_seed("pcm", "PCM"), timecode: None }; + write_opatom_pcm(&opts, &(0..6000).collect::>()).unwrap() +} + +fn bases() -> Vec> { + vec![op1a_avc(), atom_vc3(), atom_pcm(None), atom_pcm(Some(Rational::new(25, 1)))] +} + +/// Opens and uses everything the API offers. Returns whether the file opened. +fn exercise(data: &[u8]) -> bool { + let Ok(m) = open(data) else { return false }; + let _ = (m.operational_pattern.name(), m.timecode.map(|t| t.format())); + for p in &m.partitions { + let _ = (p.closed(), p.complete()); + } + for (ti, t) in m.tracks.iter().enumerate() { + let _ = (t.codec_params(), t.first_edit_unit(), t.stored_sample_frames(), t.samples_per_edit_unit(), t.codec.name()); + let _ = t.picture.as_ref().map(|p| (p.frame_width(), p.frame_height(), p.full_range())); + let _ = t.sound.as_ref().map(|s| s.frame_bytes()); + let n = t.samples.len(); + for i in (0..n).step_by((n / 32).max(1)).chain(n.checked_sub(1)) { + let _ = m.read_sample(data, ti, i); + let _ = t.sync_before(i); + } + let _ = (t.sync_before(usize::MAX), t.sample_at(-1), t.sample_at(i64::MAX), t.sample_at(0)); + // sound: the start, a request crossing chunks, one past the end, and absurd ones + for (start, frames) in + [(0u64, 100usize), (1500, 4000), (t.stored_sample_frames(), 16), (u64::MAX, 1), (0, usize::MAX), (u64::MAX - 3, 1 << 20)] + { + let _ = m.read_pcm(data, ti, start, frames); + } + } + let _ = (m.read_sample(data, m.tracks.len(), 0), m.read_pcm(data, m.tracks.len(), 0, 1)); + true +} + +/// The KLV packets of a written file: (key offset, value offset, value length). +fn klvs(f: &[u8]) -> Vec<(usize, usize, usize)> { + let mut out = Vec::new(); + let mut pos = 0; + while pos + 17 <= f.len() { + let Some((len, n)) = klv::ber_length(&f[pos + 16..]) else { break }; + let v = pos + 16 + n; + if v + len as usize > f.len() { + break; + } + out.push((pos, v, len as usize)); + pos = v + len as usize; + } + out +} + +/// The items of a local set value: (value offset, value length) of each 2-byte tag / 2-byte length. +fn items(f: &[u8], at: usize, len: usize) -> Vec<(usize, usize)> { + let mut out = Vec::new(); + let mut p = at; + while p + 4 <= at + len { + let n = usize::from(u16::from_be_bytes([f[p + 2], f[p + 3]])); + if p + 4 + n > at + len { + break; + } + out.push((p + 4, n)); + p += 4 + n; + } + out +} + +/// Overwrite `f[at..at + len]` with one of the extremes of an integer, a rational, a batch header +/// or a reference. +fn extreme(f: &mut [u8], at: usize, len: usize, r: &mut Rng) { + let field = &mut f[at..at + len]; + match r.below(8) { + 0 => field.fill(0), + 1 => field.fill(0xFF), + 2 => { + field.fill(0xFF); + field[0] = 0x7F; + } + 3 => { + field.fill(0); + field[0] = 0x80; + } + // a batch header that promises more items, or longer ones, than there are bytes + 4 if len >= 8 => field[..8].copy_from_slice(&[0x7F, 0xFF, 0xFF, 0xFF, 0, 0, 0, 1]), + 5 if len >= 8 => field[..8].copy_from_slice(&[0, 0, 0, 1, 0xFF, 0xFF, 0xFF, 0xF0]), + 6 => { + let i = r.below(len); + field[i] ^= 1 << r.below(8); + } + _ => field.iter_mut().for_each(|b| *b = r.next() as u8), + } +} + +/// Every metadata item, index table item and partition pack field of every kind of written file, +/// set to an extreme value, a few at a time. +#[test] +fn extreme_metadata_index_and_partition_fields_never_panic() { + let mut r = Rng(0x00C0_FFEE_1234_5678); + let mut opened = 0; + for base in bases() { + assert!(exercise(&base), "the unchanged file opens"); + // local sets (header metadata, index table segments) and partition packs + let sets: Vec<(usize, usize, usize)> = + klvs(&base).into_iter().filter(|&(k, _, len)| len > 0 && base[k + 4] == 0x02 && matches!(base[k + 5], 0x53 | 0x05)).collect(); + assert!(sets.len() > 10, "{} packets to damage", sets.len()); + for round in 0..600 { + let mut f = base.clone(); + for _ in 0..1 + round % 3 { + let (k, v, len) = sets[r.below(sets.len())]; + if base[k + 5] == 0x53 { + let it = items(&base, v, len); + let (at, n) = it[r.below(it.len())]; + if n > 0 { + extreme(&mut f, at, n, &mut r); + } + } else { + // a fixed pack (partition, primer): a whole 4- or 8-byte field + let n = [4, 8][r.below(2)].min(len); + extreme(&mut f, v + r.below(len - n + 1), n, &mut r); + } + } + opened += usize::from(exercise(&f)); + } + } + assert!(opened > 1200, "most damaged files still open and are read: {opened}"); +} + +/// KLV lengths that lie (longer than the file, the largest BER lengths, zero) and keys that stop +/// being SMPTE labels. +#[test] +fn lying_klv_lengths_and_lost_keys_never_panic() { + let mut r = Rng(0xBAD0_BE12_3456_789A); + for base in bases() { + let all = klvs(&base); + for _ in 0..300 { + let mut f = base.clone(); + for _ in 0..1 + r.below(2) { + let (k, v, _) = all[r.below(all.len())]; + match r.below(5) { + 0 => f[k + 16..v].fill(0xFF), + 1 => f[k + 16..v].fill(0), + // an 8-byte BER length of 2^63 and more, written over the packet's start + 2 if k + 25 <= f.len() => { + f[k + 16..k + 25].copy_from_slice(&[0x88, 0x80 | r.next() as u8, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) + } + 3 => f[k + r.below(16)] ^= 0xFF, + _ => f[v - 1] = f[v - 1].wrapping_add(1 + r.below(200) as u8), + } + } + if r.below(3) == 0 { + f.truncate(r.below(f.len())); + } + exercise(&f); + } + } +} + +#[test] +fn parsers_never_panic_on_any_bytes() { + let mut r = Rng(0x5EED_0000_0000_0001); + for _ in 0..20_000 { + let n = r.below(96); + let b: Vec = (0..n).map(|_| [0u8, 0xFF, 0x80, 0x7F, r.next() as u8, r.next() as u8][r.below(6)]).collect(); + let seg = IndexSegment::parse(&b); + for e in &seg.entries { + let _ = (e.random_access(), e.is_b()); + } + let _ = ( + sniff(&b), + aes3_element_samples(&b), + klv::ber_length(&b), + klv::batch_of(&b).len(), + klv::utf16_of(&b), + klv::int_of(&b, n.is_multiple_of(2)), + ); + for (format, channels, bits, frame_bytes) in [ + (SoundFormat::Pcm, r.below(300), r.below(70) as u32, r.below(40)), + (SoundFormat::Aes3Element, r.below(12), 24, 0), + (SoundFormat::Pcm, usize::MAX, u32::MAX, usize::MAX), + (SoundFormat::Other, 2, 16, 4), + ] { + if let Ok(planes) = decode_pcm(&b, format, channels, bits, frame_bytes) { + assert!(planes.iter().all(|p| p.len() <= b.len()), "more samples than bytes"); + } + } + } +} + +/// A run-in may hold at most 65 535 bytes before the header partition pack. +#[test] +fn a_header_partition_behind_too_long_a_run_in_is_not_found() { + let file = atom_pcm(None); + let behind = |n: usize| [vec![0x55u8; n], file.clone()].concat(); + for n in [1, 4096, 65_535] { + let f = behind(n); + assert!(sniff(&f[..(n + 64).min(f.len())]), "run-in of {n}"); + let m = open(&f).unwrap(); + assert_eq!(m.run_in, n as u64); + } + let f = behind(65_536 + 17); + assert!(!sniff(&f)); + assert!(matches!(open(&f), Err(Error::NotMxf(_)))); +} + +/// A sound track has no picture samples: asking it for a random-access sample is not a panic. +#[test] +fn sync_before_on_a_track_without_samples_is_zero() { + let f = atom_pcm(None); + let m = open(&f).unwrap(); + assert!(m.tracks[0].samples.is_empty()); + assert_eq!((m.tracks[0].sync_before(0), m.tracks[0].sync_before(7)), (0, 0)); + let f = op1a_avc(); + let m = open(&f).unwrap(); + let v = &m.tracks[0]; + assert_eq!((v.sync_before(3), v.sync_before(4), v.sync_before(usize::MAX)), (0, 4, 8)); +} + +/// A start timecode at the end of the 64-bit range (a damaged timecode component) formats. +#[test] +fn a_drop_frame_timecode_at_the_end_of_the_range_formats() { + use super::Timecode; + for base in [30, 60] { + for start in [i64::MAX, i64::MAX - 1, i64::MAX / 2, i64::MIN] { + let text = Timecode { start, rounded_base: base, drop_frame: true }.format(); + assert!(text.contains(';'), "{text}"); + } + } + assert_eq!(Timecode { start: 107_892, rounded_base: 30, drop_frame: true }.format(), "01:00:00;00"); +} + +/// A source that is `.1` long and zero after its real bytes, and counts the bytes read. +struct Sparse(Vec, u64, std::cell::Cell); + +impl super::ByteSource for Sparse { + fn len(&self) -> u64 { + self.1 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + self.2.set(self.2.get() + buf.len() as u64); + buf.fill(0); + let real = self.0.get(offset.min(self.0.len() as u64) as usize..).unwrap_or_default(); + let n = real.len().min(buf.len()); + buf[..n].copy_from_slice(&real[..n]); + Ok(()) + } +} + +/// The header of a written file up to and including its primer pack, then the key of its first +/// metadata set. +fn header_then_a_set_key() -> Vec { + let f = atom_pcm(None); + let all = klvs(&f); + let primer = all.iter().position(|&(k, _, _)| klv::classify(&klv::Ul(f[k..k + 16].try_into().unwrap())) == klv::KeyClass::Primer).unwrap(); + let (next, _, _) = all[primer + 1]; + assert_eq!(klv::classify(&klv::Ul(f[next..next + 16].try_into().unwrap())), klv::KeyClass::LocalSet); + f[..next + 16].to_vec() +} + +/// A metadata value of more than 64 MiB is refused before it is read or allocated. +#[test] +fn a_metadata_value_past_the_limit_is_refused_unread() { + let limit = 64u32 << 20; + let with_len = |len: u32| { + let mut f = header_then_a_set_key(); + f.push(0x84); + f.extend(len.to_be_bytes()); + let total = f.len() as u64 + u64::from(len); + Sparse(f, total, Default::default()) + }; + let src = with_len(limit + 1); + match open(&src) { + Err(Error::Invalid(text)) => assert!(text.contains("metadata value"), "{text}"), + other => panic!("expected the value to be refused, got {:?}", other.map(|m| m.warnings)), + } + assert!(src.2.get() < 1 << 20, "{} bytes read", src.2.get()); + // at the limit it is read + let src = with_len(limit); + let _ = open(&src); + assert!(src.2.get() >= u64::from(limit)); +} + +/// After a key that is no SMPTE label, the next partition pack is looked for in at most 16 MiB. +#[test] +fn lost_sync_scans_a_bounded_stretch_for_the_next_partition() { + // a written file cut after the key of its essence element, that key damaged + let f = atom_pcm(None); + let (essence, _, _) = + klvs(&f).into_iter().find(|&(k, _, _)| klv::classify(&klv::Ul(f[k..k + 16].try_into().unwrap())) == klv::KeyClass::Essence).unwrap(); + let mut f = f[..essence + 16].to_vec(); + f[essence..].fill(0x11); + let src = Sparse(f, 1 << 30, Default::default()); + // nothing follows, so the file has no essence: what matters is how much was read to find out + assert!(matches!(open(&src), Err(Error::Unsupported(_)))); + assert!(src.2.get() > 15 << 20, "the scan ran: {} bytes read", src.2.get()); + assert!(src.2.get() < 18 << 20, "{} bytes read of a 1 GiB file", src.2.get()); +} diff --git a/crates/media/src/mxf/index.rs b/crates/media/src/mxf/index.rs new file mode 100644 index 00000000..cb19aabf --- /dev/null +++ b/crates/media/src/mxf/index.rs @@ -0,0 +1,124 @@ +//! Index table segments (ST 377-1 §11). + +use crate::mxf::Rational; +use crate::mxf::klv::{Cur, int_of, rational_of}; + +/// One index entry (ST 377-1 §11.2.4), for one edit unit in stored order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct IndexEntry { + /// Display-order position `n` is stored at `n + temporal_offset` (the offset belongs to the + /// entry at the display position). + pub temporal_offset: i8, + /// Stored-order offset from this edit unit to the key frame it depends on (≤ 0). + pub key_frame_offset: i8, + /// Bit 7 random access, bit 6 sequence header, bits 5-4 prediction (00 I, 10 P, 11 B). + pub flags: u8, + /// Byte offset of the edit unit in the essence container stream. + pub stream_offset: u64, +} + +impl IndexEntry { + pub fn random_access(&self) -> bool { + self.flags & 0x80 != 0 + } + /// Uses backward prediction (a B picture). + pub fn is_b(&self) -> bool { + self.flags & 0x30 == 0x30 + } +} + +/// An index table segment. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct IndexSegment { + pub edit_rate: Rational, + pub start_position: i64, + /// Edit units covered (0 with a constant byte count: the whole container). + pub duration: i64, + /// Constant bytes per edit unit (CBR); 0 when the entries give the offsets (VBR). + pub edit_unit_byte_count: u32, + pub index_sid: u32, + pub body_sid: u32, + pub slice_count: u8, + pub pos_table_count: u8, + /// (pos table index, slice, element delta) per element of a content package. + pub delta_entries: Vec<(i8, u8, u32)>, + pub entries: Vec, +} + +impl IndexSegment { + pub fn parse(v: &[u8]) -> IndexSegment { + let mut s = IndexSegment::default(); + let mut c = Cur::new(v); + let mut entry_array: Option<&[u8]> = None; + while c.remaining() >= 4 { + let (Some(tag), Some(len)) = (c.u16(), c.u16()) else { break }; + let Some(val) = c.bytes(len as usize) else { break }; + match tag { + 0x3F0B => s.edit_rate = rational_of(val).unwrap_or_default(), + 0x3F0C => s.start_position = int_of(val, true).unwrap_or(0), + 0x3F0D => s.duration = int_of(val, true).unwrap_or(0), + 0x3F05 => s.edit_unit_byte_count = int_of(val, false).unwrap_or(0) as u32, + 0x3F06 => s.index_sid = int_of(val, false).unwrap_or(0) as u32, + 0x3F07 => s.body_sid = int_of(val, false).unwrap_or(0) as u32, + 0x3F08 => s.slice_count = val.first().copied().unwrap_or(0), + 0x3F0E => s.pos_table_count = val.first().copied().unwrap_or(0), + 0x3F09 => { + for d in crate::mxf::klv::batch_of(val) { + if d.len() >= 6 { + s.delta_entries.push((d[0] as i8, d[1], u32::from_be_bytes([d[2], d[3], d[4], d[5]]))); + } + } + } + 0x3F0A => entry_array = Some(val), + _ => {} + } + } + if let Some(val) = entry_array { + for e in crate::mxf::klv::batch_of(val) { + if e.len() >= 11 { + s.entries.push(IndexEntry { + temporal_offset: e[0] as i8, + key_frame_offset: e[1] as i8, + flags: e[2], + stream_offset: u64::from_be_bytes(e[3..11].try_into().unwrap_or([0; 8])), + }); + } + } + } + s + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_vbr_segment() { + let mut v = Vec::new(); + let mut prop = |tag: u16, val: &[u8]| { + v.extend_from_slice(&tag.to_be_bytes()); + v.extend_from_slice(&(val.len() as u16).to_be_bytes()); + v.extend_from_slice(val); + }; + prop(0x3F0B, &[0, 0, 0, 25, 0, 0, 0, 1]); + prop(0x3F0C, &0i64.to_be_bytes()); + prop(0x3F0D, &2i64.to_be_bytes()); + prop(0x3F06, &2u32.to_be_bytes()); + prop(0x3F07, &1u32.to_be_bytes()); + let mut arr = vec![0, 0, 0, 2, 0, 0, 0, 11]; + arr.extend_from_slice(&[0, 0, 0xC0]); + arr.extend_from_slice(&0u64.to_be_bytes()); + arr.extend_from_slice(&[0xFF, 0xFF, 0x33]); + arr.extend_from_slice(&1000u64.to_be_bytes()); + prop(0x3F0A, &arr); + let s = IndexSegment::parse(&v); + assert_eq!(s.edit_rate, Rational::new(25, 1)); + assert_eq!(s.duration, 2); + assert_eq!(s.entries.len(), 2); + assert!(s.entries[0].random_access()); + assert_eq!(s.entries[1].temporal_offset, -1); + assert!(s.entries[1].is_b()); + assert_eq!(s.entries[1].stream_offset, 1000); + } +} diff --git a/crates/media/src/mxf/klv.rs b/crates/media/src/mxf/klv.rs new file mode 100644 index 00000000..fe32de3c --- /dev/null +++ b/crates/media/src/mxf/klv.rs @@ -0,0 +1,250 @@ +//! KLV coding (SMPTE ST 336): 16-byte universal label keys, BER lengths, and the key classes used +//! by ST 377-1. + +use std::fmt; + +/// A SMPTE universal label (16 bytes). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct Ul(pub [u8; 16]); + +impl Ul { + /// Every SMPTE label starts with the object identifier 06 0E 2B 34. + pub fn is_smpte(&self) -> bool { + self.0[..4] == [0x06, 0x0E, 0x2B, 0x34] + } + /// Compare ignoring byte 7 (the registry version, which varies between writers). + pub fn matches(&self, other: &[u8; 16]) -> bool { + self.0.iter().zip(other).enumerate().all(|(i, (a, b))| i == 7 || a == b) + } + /// Bytes 8.. start with `prefix` (registry-independent comparison of the item designator). + pub fn item_starts_with(&self, prefix: &[u8]) -> bool { + self.0[8..].starts_with(prefix) + } +} + +impl fmt::Debug for Ul { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, b) in self.0.iter().enumerate() { + if i > 0 { + f.write_str(".")?; + } + write!(f, "{b:02x}")?; + } + Ok(()) + } +} + +impl fmt::Display for Ul { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(self, f) + } +} + +/// A rational number (edit rates, sample rates, aspect ratios). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct Rational { + pub num: i32, + pub den: i32, +} + +impl Rational { + pub fn new(num: i32, den: i32) -> Self { + Rational { num, den } + } + pub fn is_valid(&self) -> bool { + self.num > 0 && self.den > 0 + } + pub fn as_f64(&self) -> f64 { + if self.den == 0 { 0.0 } else { self.num as f64 / self.den as f64 } + } +} + +/// Partition pack keys: 06 0E 2B 34 02 05 01 01 0D 01 02 01 01 kk ss 00 (kk: 02 header, 03 body, +/// 04 footer; ss: status). +pub const PARTITION_PREFIX: [u8; 13] = [0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01]; + +/// The kinds of KLV packets the demuxer distinguishes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum KeyClass { + /// Partition pack: kind byte (2 header, 3 body, 4 footer) and status byte. + Partition { + kind: u8, + status: u8, + }, + Primer, + /// Header metadata local set (structural or descriptive). + LocalSet, + IndexSegment, + RandomIndexPack, + Fill, + /// Generic container content (system items and essence elements, ST 379-1). + Essence, + Other, +} + +pub fn classify(k: &Ul) -> KeyClass { + let b = &k.0; + if !k.is_smpte() { + return KeyClass::Other; + } + if b[..13] == PARTITION_PREFIX && (2..=4).contains(&b[13]) && b[15] == 0 { + return KeyClass::Partition { kind: b[13], status: b[14] }; + } + // 06 0E 2B 34 02 05 01 01 0D 01 02 01 01 05 01 00 + if b[4] == 0x02 && b[5] == 0x05 && b[8..16] == [0x0D, 0x01, 0x02, 0x01, 0x01, 0x05, 0x01, 0x00] { + return KeyClass::Primer; + } + // 06 0E 2B 34 02 05 01 01 0D 01 02 01 01 11 01 00 + if b[4] == 0x02 && b[5] == 0x05 && b[8..16] == [0x0D, 0x01, 0x02, 0x01, 0x01, 0x11, 0x01, 0x00] { + return KeyClass::RandomIndexPack; + } + // 06 0E 2B 34 02 53 01 01 0D 01 02 01 01 10 01 00 (also seen with 2-byte BER lengths: 02 13 / 02 33) + if b[4] == 0x02 && b[8..16] == [0x0D, 0x01, 0x02, 0x01, 0x01, 0x10, 0x01, 0x00] { + return KeyClass::IndexSegment; + } + // 06 0E 2B 34 01 01 01 0x 03 01 02 10 01 00 00 00 (KLV fill; the "01 02 10 01" legacy variant too) + if b[4] == 0x01 && b[5] == 0x01 && b[8..12] == [0x03, 0x01, 0x02, 0x10] { + return KeyClass::Fill; + } + // Generic container content: 06 0E 2B 34 xx xx xx xx 0D 01 03 01 ... + if b[8..12] == [0x0D, 0x01, 0x03, 0x01] { + return KeyClass::Essence; + } + // Header metadata sets: 06 0E 2B 34 02 53 ... (local sets with 2-byte tags and lengths) + if b[4] == 0x02 && b[5] == 0x53 { + return KeyClass::LocalSet; + } + KeyClass::Other +} + +/// Decode a BER length at `b[0..]`: (length, bytes used). `None` if truncated or longer than 8 bytes. +pub fn ber_length(b: &[u8]) -> Option<(u64, usize)> { + let first = *b.first()?; + if first < 0x80 { + return Some((first as u64, 1)); + } + let n = (first & 0x7F) as usize; + if n == 0 || n > 8 || b.len() < 1 + n { + return None; + } + Some((b[1..=n].iter().fold(0u64, |a, &x| (a << 8) | x as u64), 1 + n)) +} + +/// Offset of the header partition pack key in the run-in (ST 377-1: at most 65 535 bytes). +pub fn find_header_partition(head: &[u8]) -> Option { + let limit = head.len().min(65_536 + 16); + let mut i = 0; + while i + 16 <= limit { + if head[i..i + 13] == PARTITION_PREFIX && head[i + 13] == 0x02 { + return Some(i); + } + i += 1; + } + None +} + +/// Big-endian cursor over a value. +pub struct Cur<'a> { + b: &'a [u8], + pub pos: usize, +} + +impl<'a> Cur<'a> { + pub fn new(b: &'a [u8]) -> Self { + Cur { b, pos: 0 } + } + pub fn remaining(&self) -> usize { + self.b.len().saturating_sub(self.pos) + } + pub fn bytes(&mut self, n: usize) -> Option<&'a [u8]> { + let s = self.b.get(self.pos..self.pos.checked_add(n)?)?; + self.pos += n; + Some(s) + } + pub fn u16(&mut self) -> Option { + self.bytes(2).map(|b| u16::from_be_bytes([b[0], b[1]])) + } + pub fn u32(&mut self) -> Option { + self.bytes(4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])) + } + pub fn u64(&mut self) -> Option { + self.bytes(8).map(|b| u64::from_be_bytes(b.try_into().unwrap_or([0; 8]))) + } + pub fn ul(&mut self) -> Option
                        { + self.bytes(16).map(|b| Ul(b.try_into().unwrap_or([0; 16]))) + } +} + +/// Integer value of a property (1, 2, 4 or 8 bytes, big-endian, sign-extended when `signed`). +pub fn int_of(v: &[u8], signed: bool) -> Option { + if v.is_empty() || v.len() > 8 { + return None; + } + let u = v.iter().fold(0u64, |a, &x| (a << 8) | x as u64); + if signed && v.len() < 8 && v[0] & 0x80 != 0 { + let shift = 64 - 8 * v.len(); + return Some(((u << shift) as i64) >> shift); + } + Some(u as i64) +} + +/// A rational property (two big-endian i32). +pub fn rational_of(v: &[u8]) -> Option { + if v.len() < 8 { + return None; + } + Some(Rational::new(i32::from_be_bytes([v[0], v[1], v[2], v[3]]), i32::from_be_bytes([v[4], v[5], v[6], v[7]]))) +} + +/// A UL property. +pub fn ul_of(v: &[u8]) -> Option
                          { + v.get(..16).map(|b| Ul(b.try_into().unwrap_or([0; 16]))) +} + +/// A batch / array property: count (u32), item size (u32), items. +pub fn batch_of(v: &[u8]) -> Vec<&[u8]> { + let mut c = Cur::new(v); + let (Some(n), Some(size)) = (c.u32(), c.u32()) else { return Vec::new() }; + let size = size as usize; + if size == 0 { + return Vec::new(); + } + let mut out = Vec::new(); + for _ in 0..n { + match c.bytes(size) { + Some(b) => out.push(b), + None => break, + } + } + out +} + +/// A UTF-16BE string property (trailing NULs removed). +pub fn utf16_of(v: &[u8]) -> String { + let units: Vec = v.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).take_while(|&u| u != 0).collect(); + String::from_utf16_lossy(&units) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ber_lengths() { + assert_eq!(ber_length(&[0x05]), Some((5, 1))); + assert_eq!(ber_length(&[0x83, 0x01, 0x00, 0x00]), Some((65536, 4))); + assert_eq!(ber_length(&[0x88, 0, 0, 0, 0, 0, 0, 1, 2]), Some((258, 9))); + assert_eq!(ber_length(&[0x83, 0x01]), None); + assert_eq!(ber_length(&[0x80]), None); + assert_eq!(ber_length(&[0x89, 0, 0, 0, 0, 0, 0, 0, 0, 0]), None); + } + + #[test] + fn integers_and_strings() { + assert_eq!(int_of(&[0xFF, 0xFE], true), Some(-2)); + assert_eq!(int_of(&[0xFF, 0xFE], false), Some(0xFFFE)); + assert_eq!(utf16_of(&[0, b'A', 0, b'b', 0, 0]), "Ab"); + let b = [0, 0, 0, 2, 0, 0, 0, 1, 7, 9]; + assert_eq!(batch_of(&b), vec![&[7u8][..], &[9u8][..]]); + } +} diff --git a/crates/media/src/mxf/meta.rs b/crates/media/src/mxf/meta.rs new file mode 100644 index 00000000..bee396f6 --- /dev/null +++ b/crates/media/src/mxf/meta.rs @@ -0,0 +1,215 @@ +//! Header metadata (ST 377-1 §9, Annex A/B): the primer pack and local sets. +//! +//! Local sets code each property as a 2-byte local tag and a 2-byte length. Tags below 0x8000 are +//! static (fixed by ST 377-1 and the mapping documents); dynamic tags (0x8000 and up) are mapped +//! to their property UL by the partition's primer pack. + +use std::collections::HashMap; + +use crate::mxf::klv::{Cur, Ul, batch_of, int_of, rational_of, ul_of, utf16_of}; + +/// Local tag → property UL (ST 377-1 §9.2). +#[derive(Clone, Debug, Default)] +pub struct Primer(pub HashMap); + +impl Primer { + pub fn parse(v: &[u8]) -> Primer { + let mut map = HashMap::new(); + for item in batch_of(v) { + if item.len() >= 18 { + map.insert(u16::from_be_bytes([item[0], item[1]]), Ul(item[2..18].try_into().unwrap_or([0; 16]))); + } + } + Primer(map) + } +} + +/// Header metadata set types: byte 14 of `06 0E 2B 34 02 53 01 01 0D 01 01 01 01 01 tt 00`. +#[allow(dead_code)] +pub mod set_type { + pub const PREFACE: u8 = 0x2F; + pub const IDENTIFICATION: u8 = 0x30; + pub const CONTENT_STORAGE: u8 = 0x18; + pub const ESSENCE_CONTAINER_DATA: u8 = 0x23; + pub const MATERIAL_PACKAGE: u8 = 0x36; + pub const SOURCE_PACKAGE: u8 = 0x37; + pub const TIMELINE_TRACK: u8 = 0x3B; + pub const EVENT_TRACK: u8 = 0x39; + pub const STATIC_TRACK: u8 = 0x3A; + pub const SEQUENCE: u8 = 0x0F; + pub const SOURCE_CLIP: u8 = 0x11; + pub const TIMECODE_COMPONENT: u8 = 0x14; + pub const FILLER: u8 = 0x09; + pub const MULTIPLE_DESCRIPTOR: u8 = 0x44; + pub const GENERIC_PICTURE_DESCRIPTOR: u8 = 0x27; + pub const CDCI_DESCRIPTOR: u8 = 0x28; + pub const RGBA_DESCRIPTOR: u8 = 0x29; + pub const GENERIC_SOUND_DESCRIPTOR: u8 = 0x42; + pub const GENERIC_DATA_DESCRIPTOR: u8 = 0x43; + pub const AES3_DESCRIPTOR: u8 = 0x47; + pub const WAVE_DESCRIPTOR: u8 = 0x48; + pub const MPEG2_VIDEO_DESCRIPTOR: u8 = 0x51; + pub const JPEG2000_SUBDESCRIPTOR: u8 = 0x5A; + pub const AVC_SUBDESCRIPTOR: u8 = 0x6E; +} + +/// One header metadata local set. +#[derive(Clone, Debug, Default)] +pub struct Set { + pub key: Ul, + pub instance_uid: [u8; 16], + /// Static-tag properties. + pub props: HashMap>, + /// Dynamic-tag properties, by property UL. + pub dynamic: HashMap>, +} + +impl Set { + pub fn parse(key: Ul, v: &[u8], primer: &Primer) -> Set { + let mut s = Set { key, ..Default::default() }; + let mut c = Cur::new(v); + while c.remaining() >= 4 { + let (Some(tag), Some(len)) = (c.u16(), c.u16()) else { break }; + let Some(val) = c.bytes(len as usize) else { break }; + if tag == 0x3C0A && val.len() >= 16 { + s.instance_uid.copy_from_slice(&val[..16]); + } + if tag >= 0x8000 { + if let Some(ul) = primer.0.get(&tag) { + s.dynamic.insert(*ul, val.to_vec()); + } + } else { + s.props.insert(tag, val.to_vec()); + } + } + s + } + + /// The set type byte when this is a structural metadata set. + pub fn kind(&self) -> Option { + self.key.item_starts_with(&[0x0D, 0x01, 0x01, 0x01, 0x01, 0x01]).then_some(self.key.0[14]) + } + pub fn get(&self, tag: u16) -> Option<&[u8]> { + self.props.get(&tag).map(Vec::as_slice) + } + pub fn u(&self, tag: u16) -> Option { + self.get(tag).and_then(|v| int_of(v, false)) + } + pub fn i(&self, tag: u16) -> Option { + self.get(tag).and_then(|v| int_of(v, true)) + } + pub fn rational(&self, tag: u16) -> Option { + self.get(tag).and_then(rational_of) + } + pub fn ul(&self, tag: u16) -> Option
                            { + self.get(tag).and_then(ul_of) + } + pub fn string(&self, tag: u16) -> Option { + self.get(tag).map(utf16_of).filter(|s| !s.is_empty()) + } + /// A strong / weak reference (16-byte UUID). + pub fn reference(&self, tag: u16) -> Option<[u8; 16]> { + self.get(tag).and_then(|v| v.get(..16)).map(|b| b.try_into().unwrap_or([0; 16])) + } + /// A batch of references. + pub fn references(&self, tag: u16) -> Vec<[u8; 16]> { + self.get(tag) + .map(|v| batch_of(v).into_iter().filter_map(|b| b.get(..16).map(|x| x.try_into().unwrap_or([0; 16]))).collect()) + .unwrap_or_default() + } + /// A dynamic property whose UL's item designator (bytes 8..) equals `item`. + pub fn dynamic_item(&self, item: &[u8]) -> Option<&[u8]> { + self.dynamic.iter().find(|(k, _)| k.item_starts_with(item)).map(|(_, v)| v.as_slice()) + } +} + +/// All sets of one partition's header metadata, by instance UID. +#[derive(Clone, Debug, Default)] +pub struct Metadata { + pub sets: Vec, + by_uid: HashMap<[u8; 16], usize>, +} + +impl Metadata { + pub fn push(&mut self, s: Set) { + if s.instance_uid != [0; 16] { + self.by_uid.insert(s.instance_uid, self.sets.len()); + } + self.sets.push(s); + } + pub fn get(&self, uid: &[u8; 16]) -> Option<&Set> { + self.by_uid.get(uid).map(|&i| &self.sets[i]) + } + pub fn of_kind(&self, kind: u8) -> impl Iterator { + self.sets.iter().filter(move |s| s.kind() == Some(kind)) + } +} + +/// Static local tags (ST 377-1 Annex B and the mapping documents). +#[allow(dead_code)] +pub mod tag { + // Generic package + pub const PACKAGE_UID: u16 = 0x4401; + pub const PACKAGE_NAME: u16 = 0x4402; + pub const PACKAGE_TRACKS: u16 = 0x4403; + pub const SOURCE_PACKAGE_DESCRIPTOR: u16 = 0x4701; + // Track + pub const TRACK_ID: u16 = 0x4801; + pub const TRACK_NUMBER: u16 = 0x4804; + pub const TRACK_NAME: u16 = 0x4802; + pub const TRACK_SEQUENCE: u16 = 0x4803; + pub const EDIT_RATE: u16 = 0x4B01; + pub const ORIGIN: u16 = 0x4B02; + // Structural components + pub const DATA_DEFINITION: u16 = 0x0201; + pub const DURATION: u16 = 0x0202; + pub const STRUCTURAL_COMPONENTS: u16 = 0x1001; + pub const START_POSITION: u16 = 0x1201; + pub const SOURCE_PACKAGE_ID: u16 = 0x1101; + pub const SOURCE_TRACK_ID: u16 = 0x1102; + pub const START_TIMECODE: u16 = 0x1501; + pub const ROUNDED_TIMECODE_BASE: u16 = 0x1502; + pub const DROP_FRAME: u16 = 0x1503; + // Preface / identification / content storage + pub const OPERATIONAL_PATTERN: u16 = 0x3B09; + pub const PRODUCT_NAME: u16 = 0x3C02; + pub const COMPANY_NAME: u16 = 0x3C01; + pub const ESSENCE_CONTAINER_DATA_LINKED_PACKAGE: u16 = 0x2701; + pub const INDEX_SID: u16 = 0x3F06; + pub const BODY_SID: u16 = 0x3F07; + // Descriptors + pub const SUB_DESCRIPTORS: u16 = 0x3F01; + pub const LINKED_TRACK_ID: u16 = 0x3006; + pub const SAMPLE_RATE: u16 = 0x3001; + pub const CONTAINER_DURATION: u16 = 0x3002; + pub const ESSENCE_CONTAINER: u16 = 0x3004; + pub const CODEC: u16 = 0x3005; + pub const FRAME_LAYOUT: u16 = 0x320C; + pub const STORED_WIDTH: u16 = 0x3203; + pub const STORED_HEIGHT: u16 = 0x3202; + pub const SAMPLED_WIDTH: u16 = 0x3205; + pub const SAMPLED_HEIGHT: u16 = 0x3204; + pub const DISPLAY_WIDTH: u16 = 0x3209; + pub const DISPLAY_HEIGHT: u16 = 0x3208; + pub const ASPECT_RATIO: u16 = 0x320E; + pub const TRANSFER_CHARACTERISTIC: u16 = 0x3210; + pub const PICTURE_ESSENCE_CODING: u16 = 0x3201; + pub const CODING_EQUATIONS: u16 = 0x321A; + pub const COLOR_PRIMARIES: u16 = 0x3219; + pub const COMPONENT_DEPTH: u16 = 0x3301; + pub const HORIZONTAL_SUBSAMPLING: u16 = 0x3302; + pub const VERTICAL_SUBSAMPLING: u16 = 0x3308; + pub const BLACK_REF_LEVEL: u16 = 0x3304; + pub const WHITE_REF_LEVEL: u16 = 0x3305; + pub const ALPHA_SAMPLE_DEPTH: u16 = 0x3309; + pub const PIXEL_LAYOUT: u16 = 0x3401; + pub const AUDIO_SAMPLING_RATE: u16 = 0x3D03; + pub const LOCKED: u16 = 0x3D02; + pub const CHANNEL_COUNT: u16 = 0x3D07; + pub const QUANTIZATION_BITS: u16 = 0x3D01; + pub const SOUND_ESSENCE_CODING: u16 = 0x3D06; + pub const BLOCK_ALIGN: u16 = 0x3D0A; +} + +/// The `SubDescriptors` property (ST 377-1:2009, dynamic tag): 06 0E 2B 34 01 01 01 09 06 01 01 04 06 10 00 00. +pub const SUB_DESCRIPTORS_ITEM: [u8; 8] = [0x06, 0x01, 0x01, 0x04, 0x06, 0x10, 0x00, 0x00]; diff --git a/crates/media/src/mxf/mod.rs b/crates/media/src/mxf/mod.rs new file mode 100644 index 00000000..40807170 --- /dev/null +++ b/crates/media/src/mxf/mod.rs @@ -0,0 +1,107 @@ +//! Clean-room MXF demuxer, written from the SMPTE specifications (editions in the crate README): +//! ST 377-1 (file format: KLV, partitions, primer pack, header metadata, index tables, random +//! index pack), ST 378 (OP1a), ST 390 (OP-Atom), ST 379-1/2 (generic container), ST 381-1 (MPEG +//! video mapping), ST 381-3 (AVC mapping), ST 382 (AES3 / Broadcast Wave audio mapping), ST 331 +//! (AES3 element data, D-10 sound), ST 2019-4 (VC-3 mapping) and RDD 44 (ProRes mapping). +//! +//! [`open`] walks the file's KLV packets (keys and lengths only; essence payloads are never read +//! while opening), parses the partition packs, the primer pack and header metadata of the most +//! complete partition, and every index table segment, then resolves the material package to the +//! file (source) package tracks that carry essence in this file. The result is a list of +//! [`EssenceTrack`]s with per-edit-unit [`Sample`] tables for pictures (file offset, size, +//! 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`] ([`mod@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. +//! +//! One dependency (`chrono`, for the writer's calendar dates), no `unsafe`, builds for +//! `wasm32-unknown-unknown`. +//! +//! From FilmCraft's `filmcraft-mxf` (storytold/filmcraft@4bec22b). Public items keep their names. +//! `ByteSource` is the crate's one source trait. + +// FilmCraft allows these lints workspace-wide: index loops follow the specifications' tables. +#![allow(clippy::needless_range_loop, clippy::type_complexity)] + +mod essence; +mod file; +mod index; +mod klv; +mod meta; +pub mod write; + +pub use crate::source::{ByteSource, ReadSeekSource}; +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 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; + +/// Up to `len` bytes at `offset` (fewer at the end of the source). +pub(crate) fn read_upto(src: &(impl ByteSource + ?Sized), offset: u64, len: usize) -> io::Result> { + let n = src.len().saturating_sub(offset).min(len as u64) as usize; + let mut v = vec![0u8; n]; + src.read_at(offset, &mut v)?; + Ok(v) +} + +/// Errors produced by the demuxer. +#[non_exhaustive] +#[derive(Debug)] +pub enum Error { + /// Underlying I/O failure. + Io(io::Error), + /// Not an MXF file (no header partition pack). + NotMxf(String), + /// Structurally invalid data that could not be recovered from. + Invalid(String), + /// Valid but unsupported (e.g. essence stored in another file). + Unsupported(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Io(e) => write!(f, "I/O error: {e}"), + Error::NotMxf(s) => write!(f, "not an MXF file: {s}"), + Error::Invalid(s) => write!(f, "invalid MXF: {s}"), + Error::Unsupported(s) => write!(f, "unsupported MXF: {s}"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Io(e) => Some(e), + _ => None, + } + } +} + +impl From for Error { + fn from(e: io::Error) -> Self { + Error::Io(e) + } +} + +/// Result alias for this module. +pub type Result = std::result::Result; + +/// Whether `head` (the first bytes of a file) looks like MXF: a header partition pack key within +/// the run-in (at most 64 KiB, ST 377-1 §6.5). +pub fn sniff(head: &[u8]) -> bool { + klv::find_header_partition(head).is_some() +} + +#[cfg(test)] +mod hostile_tests; +#[cfg(test)] +mod tests; diff --git a/crates/media/src/mxf/tests.rs b/crates/media/src/mxf/tests.rs new file mode 100644 index 00000000..e892024f --- /dev/null +++ b/crates/media/src/mxf/tests.rs @@ -0,0 +1,393 @@ +//! Synthetic files: a small OP1a writer (enough of ST 377-1 to exercise the demuxer without +//! ffmpeg) and robustness checks. + +use super::*; + +fn ul(b: &[u8]) -> [u8; 16] { + let mut k = [0u8; 16]; + k[..b.len()].copy_from_slice(b); + k +} + +fn ber(n: usize) -> Vec { + let mut v = vec![0x83]; + v.extend_from_slice(&(n as u32).to_be_bytes()[1..]); + v +} + +fn klv(out: &mut Vec, key: [u8; 16], v: &[u8]) { + out.extend_from_slice(&key); + out.extend_from_slice(&ber(v.len())); + out.extend_from_slice(v); +} + +struct LocalSet(Vec); +impl LocalSet { + fn new(uid: u8) -> Self { + let mut s = LocalSet(Vec::new()); + s.p(0x3C0A, &uuid(uid)); + s + } + fn p(&mut self, tag: u16, v: &[u8]) -> &mut Self { + self.0.extend_from_slice(&tag.to_be_bytes()); + self.0.extend_from_slice(&(v.len() as u16).to_be_bytes()); + self.0.extend_from_slice(v); + self + } +} + +fn uuid(n: u8) -> [u8; 16] { + let mut u = [0xAAu8; 16]; + u[15] = n; + u +} + +fn umid(n: u8) -> [u8; 32] { + let mut u = [0x55u8; 32]; + u[31] = n; + u +} + +fn batch(items: &[&[u8]]) -> Vec { + let mut v = (items.len() as u32).to_be_bytes().to_vec(); + v.extend_from_slice(&(items.first().map_or(0, |i| i.len()) as u32).to_be_bytes()); + for i in items { + v.extend_from_slice(i); + } + v +} + +fn set_key(t: u8) -> [u8; 16] { + ul(&[0x06, 0x0E, 0x2B, 0x34, 0x02, 0x53, 0x01, 0x01, 0x0D, 0x01, 0x01, 0x01, 0x01, 0x01, t, 0x00]) +} + +const PICTURE_DEF: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x02, 0x01, 0, 0, 0]; +const SOUND_DEF: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x02, 0x02, 0, 0, 0]; +const TC_DEF: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x01, 0x03, 0x02, 0x01, 0x01, 0, 0, 0]; +const OP1A: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x01, 0x09, 0x00]; +const PIC_KEY: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x01, 0x02, 0x01, 0x01, 0x0D, 0x01, 0x03, 0x01, 0x15, 0x01, 0x05, 0x00]; +const SND_KEY: [u8; 16] = [0x06, 0x0E, 0x2B, 0x34, 0x01, 0x02, 0x01, 0x01, 0x0D, 0x01, 0x03, 0x01, 0x16, 0x01, 0x03, 0x00]; + +fn partition(kind: u8, this: u64, body_sid: u32, index_sid: u32, hbc: u64) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(&1u16.to_be_bytes()); + v.extend_from_slice(&3u16.to_be_bytes()); + v.extend_from_slice(&1u32.to_be_bytes()); + v.extend_from_slice(&this.to_be_bytes()); + v.extend_from_slice(&0u64.to_be_bytes()); + v.extend_from_slice(&0u64.to_be_bytes()); + v.extend_from_slice(&hbc.to_be_bytes()); + v.extend_from_slice(&0u64.to_be_bytes()); + v.extend_from_slice(&index_sid.to_be_bytes()); + v.extend_from_slice(&0u64.to_be_bytes()); + v.extend_from_slice(&body_sid.to_be_bytes()); + v.extend_from_slice(&OP1A); + v.extend_from_slice(&batch(&[])); + let mut out = Vec::new(); + klv(&mut out, ul(&[0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, kind, 0x04, 0x00]), &v); + out +} + +fn track(uid: u8, id: u32, number: u32, seq: u8) -> Vec { + let mut s = LocalSet::new(uid); + s.p(0x4801, &id.to_be_bytes()) + .p(0x4804, &number.to_be_bytes()) + .p(0x4B01, &[0, 0, 0, 25, 0, 0, 0, 1]) + .p(0x4B02, &0i64.to_be_bytes()) + .p(0x4803, &uuid(seq)); + s.0 +} + +fn sequence(uid: u8, def: [u8; 16], dur: i64, comp: u8) -> Vec { + let mut s = LocalSet::new(uid); + s.p(0x0201, &def).p(0x0202, &dur.to_be_bytes()).p(0x1001, &batch(&[&uuid(comp)])); + s.0 +} + +fn source_clip(uid: u8, def: [u8; 16], dur: i64, pkg: [u8; 32], track: u32) -> Vec { + let mut s = LocalSet::new(uid); + s.p(0x0201, &def).p(0x0202, &dur.to_be_bytes()).p(0x1201, &0i64.to_be_bytes()).p(0x1101, &pkg).p(0x1102, &track.to_be_bytes()); + s.0 +} + +/// An OP1a file: `n` frame-wrapped content packages, each a picture element of `pic(i)` and a +/// 16-bit stereo sound element of 1920 frames (sample k of the file = k, -k on the channels); +/// a footer index with temporal offsets `to` and flags `flags`. +fn build(n: usize, pic: impl Fn(usize) -> Vec, to: &[i8], flags: &[u8]) -> Vec { + let mut md = Vec::new(); + // primer: no dynamic tags + klv(&mut md, ul(&[0x06, 0x0E, 0x2B, 0x34, 0x02, 0x05, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x05, 0x01, 0x00]), &batch(&[])); + let mut pre = LocalSet::new(1); + pre.p(0x3B03, &uuid(2)).p(0x3B09, &OP1A); + klv(&mut md, set_key(0x2F), &pre.0); + let mut cs = LocalSet::new(2); + cs.p(0x1901, &batch(&[&uuid(3), &uuid(4)])); + klv(&mut md, set_key(0x18), &cs.0); + // material package: timecode, picture, sound + let mut mp = LocalSet::new(3); + mp.p(0x4401, &umid(1)).p(0x4402, &[0, b'M', 0, b'P']).p(0x4403, &batch(&[&uuid(10), &uuid(11), &uuid(12)])); + klv(&mut md, set_key(0x36), &mp.0); + let d = n as i64; + klv(&mut md, set_key(0x3B), &track(10, 1, 0, 20)); + klv(&mut md, set_key(0x0F), &sequence(20, TC_DEF, d, 30)); + let mut tc = LocalSet::new(30); + tc.p(0x0201, &TC_DEF).p(0x0202, &d.to_be_bytes()).p(0x1501, &90_000i64.to_be_bytes()).p(0x1502, &25u16.to_be_bytes()).p(0x1503, &[0]); + klv(&mut md, set_key(0x14), &tc.0); + klv(&mut md, set_key(0x3B), &track(11, 2, 0, 21)); + klv(&mut md, set_key(0x0F), &sequence(21, PICTURE_DEF, d, 31)); + klv(&mut md, set_key(0x11), &source_clip(31, PICTURE_DEF, d, umid(2), 2)); + klv(&mut md, set_key(0x3B), &track(12, 3, 0, 22)); + klv(&mut md, set_key(0x0F), &sequence(22, SOUND_DEF, d, 32)); + klv(&mut md, set_key(0x11), &source_clip(32, SOUND_DEF, d, umid(2), 3)); + // file package + let mut fp = LocalSet::new(4); + fp.p(0x4401, &umid(2)).p(0x4403, &batch(&[&uuid(13), &uuid(14)])).p(0x4701, &uuid(40)); + klv(&mut md, set_key(0x37), &fp.0); + klv(&mut md, set_key(0x3B), &track(13, 2, 0x1501_0500, 23)); + klv(&mut md, set_key(0x0F), &sequence(23, PICTURE_DEF, d, 33)); + klv(&mut md, set_key(0x11), &source_clip(33, PICTURE_DEF, d, [0; 32], 0)); + klv(&mut md, set_key(0x3B), &track(14, 3, 0x1601_0300, 24)); + klv(&mut md, set_key(0x0F), &sequence(24, SOUND_DEF, d, 34)); + klv(&mut md, set_key(0x11), &source_clip(34, SOUND_DEF, d, [0; 32], 0)); + let mut mdesc = LocalSet::new(40); + mdesc.p(0x3001, &[0, 0, 0, 25, 0, 0, 0, 1]).p(0x3F01, &batch(&[&uuid(41), &uuid(42)])); + klv(&mut md, set_key(0x44), &mdesc.0); + let mut cdci = LocalSet::new(41); + cdci.p(0x3006, &2u32.to_be_bytes()) + .p(0x3203, &64u32.to_be_bytes()) + .p(0x3202, &32u32.to_be_bytes()) + .p(0x320E, &[0, 0, 0, 2, 0, 0, 0, 1]) + .p(0x3301, &8u32.to_be_bytes()) + .p(0x3201, &ul(&[0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0A, 0x04, 0x01, 0x02, 0x02, 0x03, 0x06, 0x03, 0x00])); + klv(&mut md, set_key(0x28), &cdci.0); + let mut wave = LocalSet::new(42); + wave.p(0x3006, &3u32.to_be_bytes()) + .p(0x3D03, &[0, 0, 0xBB, 0x80, 0, 0, 0, 1]) + .p(0x3D07, &2u32.to_be_bytes()) + .p(0x3D01, &16u32.to_be_bytes()) + .p(0x3D0A, &4u16.to_be_bytes()); + klv(&mut md, set_key(0x48), &wave.0); + let mut ecd = LocalSet::new(5); + ecd.p(0x2701, &umid(2)).p(0x3F07, &1u32.to_be_bytes()).p(0x3F06, &2u32.to_be_bytes()); + klv(&mut md, set_key(0x23), &ecd.0); + + let mut f = partition(2, 0, 0, 0, md.len() as u64); + f.extend_from_slice(&md); + let body_at = f.len() as u64; + f.extend_from_slice(&partition(3, body_at, 1, 0, 0)); + let mut offsets = Vec::new(); + let mut cp_base = None; + for i in 0..n { + let at = f.len() as u64; + let base = *cp_base.get_or_insert(at); + offsets.push(at - base); + klv(&mut f, PIC_KEY, &pic(i)); + let mut snd = Vec::new(); + for k in 0..1920 { + let s = ((i * 1920 + k) % 30000) as i16; + snd.extend_from_slice(&s.to_le_bytes()); + snd.extend_from_slice(&(-s).to_le_bytes()); + } + klv(&mut f, SND_KEY, &snd); + } + let footer_at = f.len() as u64; + f.extend_from_slice(&partition(4, footer_at, 0, 2, 0)); + let mut ix = LocalSet::new(6); + let mut entries = Vec::new(); + for i in 0..n { + let mut e = vec![to.get(i).copied().unwrap_or(0) as u8, 0, flags.get(i).copied().unwrap_or(0)]; + e.extend_from_slice(&offsets[i].to_be_bytes()); + entries.push(e); + } + let refs: Vec<&[u8]> = entries.iter().map(Vec::as_slice).collect(); + ix.p(0x3F0B, &[0, 0, 0, 25, 0, 0, 0, 1]) + .p(0x3F0C, &0i64.to_be_bytes()) + .p(0x3F0D, &(n as i64).to_be_bytes()) + .p(0x3F06, &2u32.to_be_bytes()) + .p(0x3F07, &1u32.to_be_bytes()) + .p(0x3F0A, &batch(&refs)); + klv(&mut f, ul(&[0x06, 0x0E, 0x2B, 0x34, 0x02, 0x53, 0x01, 0x01, 0x0D, 0x01, 0x02, 0x01, 0x01, 0x10, 0x01, 0x00]), &ix.0); + f +} + +/// Stored order I0 P3 B1 B2 P6 B4 B5: display d is stored at d + TO[d]. +fn ipbb() -> (Vec, Vec) { + (vec![0, 1, 1, -2, 1, 1, -2], vec![0xC0, 0x22, 0x33, 0x33, 0x22, 0x33, 0x33]) +} + +fn prores_like(i: usize) -> Vec { + let mut v = vec![0, 0, 0, 40]; + v.extend_from_slice(b"icpf"); + v.extend(std::iter::repeat_n(i as u8, 32)); + v +} + +#[test] +fn opens_synthetic_op1a() { + let (to, flags) = ipbb(); + let f = build(7, |i| vec![i as u8; 100 + i], &to, &flags); + let m = open(&f).unwrap(); + assert_eq!(m.operational_pattern, OperationalPattern::Generalized { item: 1, package: 1 }); + assert_eq!(m.operational_pattern.name(), "OP1a"); + assert_eq!(m.material_package_name.as_deref(), Some("MP")); + let tc = m.timecode.unwrap(); + assert_eq!((tc.start, tc.rounded_base, tc.drop_frame), (90_000, 25, false)); + assert_eq!(tc.format(), "01:00:00:00"); + let v = m.track_of_kind(TrackKind::Picture).unwrap(); + let t = &m.tracks[v]; + assert_eq!(t.track_number, 0x1501_0500); + assert_eq!(t.edit_rate, Rational::new(25, 1)); + assert_eq!(t.duration, Some(7)); + assert_eq!(t.samples.len(), 7); + let p = t.picture.as_ref().unwrap(); + assert_eq!((p.frame_width(), p.frame_height()), (64, 32)); + assert_eq!(t.codec, Codec::ProRes { profile: Some(3) }, "coding label wins (the bytes do not sniff as anything)"); + // presentation order from the temporal offsets + let pts: Vec = t.samples.iter().map(|s| s.pts).collect(); + assert_eq!(pts, vec![0, 3, 1, 2, 6, 4, 5]); + assert_eq!(t.display_order, vec![0, 2, 3, 1, 5, 6, 4]); + assert!(t.temporal_offsets); + assert_eq!(t.sample_at(3), Some(1)); + // the index marks only the first picture random-access (ProRes would be intra; this label says ProRes so all are key) + assert!(t.samples.iter().all(|s| s.key)); + // sample bytes + for i in 0..7 { + assert_eq!(m.read_sample(&f, v, i).unwrap(), vec![i as u8; 100 + i]); + } + // sound: 7 × 1920 frames, sample k = k on the left, -k on the right + let a = m.track_of_kind(TrackKind::Sound).unwrap(); + let s = &m.tracks[a]; + assert_eq!(s.codec, Codec::Pcm); + assert_eq!(s.stored_sample_frames(), 7 * 1920); + assert_eq!(s.samples_per_edit_unit(), (48000, 25)); + let pcm = m.read_pcm(&f, a, 1900, 50).unwrap(); + for k in 0..50 { + assert_eq!(pcm[0][k], (1900 + k) as f32 / 32768.0); + assert_eq!(pcm[1][k], -((1900 + k) as f32) / 32768.0); + } + // past the end: zeros + let tail = m.read_pcm(&f, a, 7 * 1920 - 2, 4).unwrap(); + assert_eq!(tail[0][2..], [0.0, 0.0]); +} + +#[test] +fn key_frames_from_index_flags_for_long_gop() { + let (to, flags) = ipbb(); + // AVC-looking essence (start code + AUD) and no coding label: sniffed as AVC + let f = build(7, |i| vec![0, 0, 0, 1, 9, 0x10, i as u8], &to, &flags); + // replace the ProRes coding label so the codec comes from the bytes + let mut f2 = f.clone(); + let lbl = [0x04, 0x01, 0x02, 0x02, 0x03, 0x06, 0x03, 0x00]; + let at = f2.windows(8).position(|w| w == lbl).unwrap(); + f2[at..at + 8].copy_from_slice(&[0x04, 0x01, 0x02, 0x02, 0x01, 0x31, 0x40, 0x01]); + let m = open(&f2).unwrap(); + let t = &m.tracks[m.track_of_kind(TrackKind::Picture).unwrap()]; + assert_eq!(t.codec, Codec::Avc { intra: false }); + let keys: Vec = t.samples.iter().map(|s| s.key).collect(); + assert_eq!(keys, vec![true, false, false, false, false, false, false]); + assert_eq!(t.sync_before(5), 0); + assert!(t.indexed); + let b: Vec = t.samples.iter().map(|s| s.b_picture).collect(); + assert_eq!(b, vec![false, false, true, true, false, true, true]); +} + +#[test] +fn missing_temporal_offsets_with_b_pictures_ask_for_reordering() { + let (_, flags) = ipbb(); + let m = open(&build(7, prores_like, &[], &flags)).unwrap(); + let t = &m.tracks[0]; + assert!(!t.temporal_offsets); + // ProRes is intra-only: the B flags are ignored for key frames, reordering still reported + assert!(t.samples.iter().all(|s| s.key)); +} + +#[test] +fn not_mxf_and_empty() { + assert!(matches!(open(&b"RIFF....WAVE".to_vec()), Err(Error::NotMxf(_)))); + assert!(open(&Vec::new()).is_err()); + assert!(!sniff(b"\x1aE\xdf\xa3")); +} + +#[test] +fn truncation_never_panics_and_keeps_whole_frames() { + let (to, flags) = ipbb(); + let f = build(7, prores_like, &to, &flags); + let mut opened = 0; + for cut in (0..f.len()).step_by(37) { + 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() { + // every listed sample is fully inside the file + assert!(m.read_sample(&part, ti, i).is_ok(), "cut {cut} track {ti} sample {i}"); + } + if t.sound.is_some() { + let _ = m.read_pcm(&part, ti, 0, 5000); + } + } + } + } + assert!(opened > 10, "truncated files with complete metadata still open ({opened})"); +} + +#[test] +fn mutation_never_panics() { + let (to, flags) = ipbb(); + let f = build(5, prores_like, &to, &flags); + let mut seed = 0x1234_5678u64; + for _ in 0..600 { + let mut g = f.clone(); + for _ in 0..4 { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let at = (seed as usize) % g.len(); + g[at] = (seed >> 32) as u8; + } + if let Ok(m) = open(&g) { + for (ti, t) in m.tracks.iter().enumerate() { + for i in 0..t.samples.len().min(8) { + let _ = m.read_sample(&g, ti, i); + } + let _ = m.read_pcm(&g, ti, 0, 100); + } + } + } +} + +#[test] +fn drop_frame_timecode_format() { + let tc = Timecode { start: 107_892, rounded_base: 30, drop_frame: true }; + assert_eq!(tc.format(), "01:00:00;00"); + let tc = Timecode { start: 1800, rounded_base: 30, drop_frame: true }; + assert_eq!(tc.format(), "00:01:00;02"); + let tc = Timecode { start: 90_000 + 25 * 61 + 3, rounded_base: 25, drop_frame: false }; + assert_eq!(tc.format(), "01:01:01:03"); +} + +#[test] +fn hostile_pcm_requests_fail_before_allocation() { + let (to, flags) = ipbb(); + let bytes = build(5, prores_like, &to, &flags); + let mut file = open(&bytes).unwrap(); + let track = file.track_of_kind(TrackKind::Sound).unwrap(); + file.tracks[track].sound.as_mut().unwrap().channels = u32::MAX; + assert!(file.read_pcm(&bytes, track, 0, 100).is_err()); + file.tracks[track].sound.as_mut().unwrap().channels = 2; + file.tracks[track].sound.as_mut().unwrap().block_align = u32::MAX; + assert!(file.read_pcm(&bytes, track, 0, 100).is_err()); + assert!(file.read_pcm(&bytes, track, 0, usize::MAX).is_err()); + assert!(crate::mxf::decode_pcm(&[], SoundFormat::Pcm, usize::MAX, 16, 4).is_err()); +} + +#[test] +fn a_corrupt_index_position_cannot_allocate_a_giant_sparse_table() { + let (to, flags) = ipbb(); + let mut bytes = build(5, prores_like, &to, &flags); + let prefix = [0x3f, 0x0c, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0]; + let at = bytes.windows(prefix.len()).position(|w| w == prefix).unwrap(); + bytes[at + 4..at + 12].copy_from_slice(&49_999_999i64.to_be_bytes()); + let file = open(&bytes).unwrap(); + assert_eq!(file.tracks[file.track_of_kind(TrackKind::Picture).unwrap()].samples.len(), 5); +} diff --git a/crates/media/src/mxf/write.rs b/crates/media/src/mxf/write.rs new file mode 100644 index 00000000..54d896e9 --- /dev/null +++ b/crates/media/src/mxf/write.rs @@ -0,0 +1,1350 @@ +//! Clean-room MXF writer: OP1a (ST 378, frame-wrapped generic container) and OP-Atom (ST 390, +//! Avid-style: one clip-wrapped essence per file). +//! +//! The writer streams to any `Write + Seek` sink: [`MxfWriter::new`] writes the header partition +//! (open, incomplete) with the header metadata, then a body partition; essence follows as it is +//! pushed. [`MxfWriter::finish`] writes the footer partition (closed, complete header metadata with +//! the final durations, index table segments), the random index pack, and rewrites the header and +//! body partition packs and the header metadata in place (same size) so the file's header is closed +//! and complete too. +//! +//! Layout written: +//! +//! ```text +//! header partition pack · primer · preface · identification · content storage · essence container +//! data · material package (+ tracks) · file package (+ tracks) · descriptor(s) · fill +//! body partition pack (BodySID 1) +//! OP1a: one content package per edit unit: picture element, then one element per sound track +//! OP-Atom: one clip-wrapped essence element +//! footer partition pack (IndexSID 2) · header metadata · index table segments +//! random index pack +//! ``` +//! +//! Essence mappings: VC-3 / DNxHD / DNxHR (ST 2019-4), Apple ProRes (RDD 44), AVC byte stream +//! (ST 381-3; Annex B access units with in-band SPS / PPS) with long-GOP index entries (temporal +//! offsets, key-frame offsets, prediction flags), and Broadcast Wave PCM (ST 382, 8–32 bit). + +mod labels; +#[cfg(test)] +mod tests; + +use std::collections::VecDeque; +use std::io::{self, Seek, SeekFrom, Write}; + +use crate::mxf::Rational; +use labels::{ + CE_709, CE_2020, CP_709, CP_2020, DEF_PICTURE, DEF_SOUND, DEF_TIMECODE, EC_MULTIPLE, INDEX_KEY, KLV_FILL, OP_ATOM, PRIMER, PRIMER_KEY, RIP_KEY, + TC_709, TC_HLG, TC_PQ, element_key, essence_container, op1a, partition_key, set_key, +}; + +/// A SMPTE ST 330 basic UMID (32 bytes): package identifiers. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct Umid(pub [u8; 32]); + +impl std::fmt::Debug for Umid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for b in self.0 { + write!(f, "{b:02x}")?; + } + Ok(()) + } +} + +impl Umid { + /// A basic UMID (universal label `06 0A 2B 34 01 01 01 05 01 01 0D 20`, length 0x13, instance + /// 0) whose 16-byte material number is a hash of `seed` (callers pass something unique: path, + /// time, track). The same seed always gives the same UMID. + pub fn from_seed(seed: &[u8]) -> Umid { + let mut u = [0u8; 32]; + u[..16].copy_from_slice(&[0x06, 0x0A, 0x2B, 0x34, 0x01, 0x01, 0x01, 0x05, 0x01, 0x01, 0x0D, 0x20, 0x13, 0x00, 0x00, 0x00]); + u[16..].copy_from_slice(&hash128(seed)); + Umid(u) + } + /// The material number (bytes 16..32). + pub fn material_number(&self) -> [u8; 16] { + self.0[16..].try_into().unwrap_or([0; 16]) + } + pub fn is_zero(&self) -> bool { + self.0 == [0; 32] + } +} + +fn hash128(seed: &[u8]) -> [u8; 16] { + let mut a = 0xcbf2_9ce4_8422_2325u64; + let mut b = 0x84222325_cbf29ce4u64 ^ seed.len() as u64; + for &x in seed { + a = (a ^ x as u64).wrapping_mul(0x0000_0100_0000_01B3); + b = (b ^ x as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15).rotate_left(23); + } + let mix = |mut z: u64| { + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + }; + let mut out = [0u8; 16]; + out[..8].copy_from_slice(&mix(a ^ b.rotate_left(17)).to_be_bytes()); + out[8..].copy_from_slice(&mix(b ^ a.rotate_left(41)).to_be_bytes()); + out +} + +/// Operational pattern of the written file. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Pattern { + /// OP1a: one material package playing one file package; all essence interleaved per edit + /// unit (frame wrapping). + Op1a, + /// OP-Atom: exactly one essence track (a picture or one sound track), clip-wrapped. + OpAtom, +} + +/// Picture essence coding. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PictureCoding { + /// SMPTE VC-3 (DNxHD / DNxHR) with its compression id (e.g. 1272 = DNxHR HQ). + Vc3 { cid: u32 }, + /// Apple ProRes; `profile` as in the RDD 44 coding label: 1 Proxy, 2 LT, 3 422, 4 HQ, 5 4444, + /// 6 4444 XQ. + ProRes { profile: u8 }, + /// H.264 / AVC Annex B byte stream; `profile_idc` from the SPS (66, 77, 100, …). + Avc { profile_idc: u8, intra: bool }, +} + +impl PictureCoding { + fn intra_only(&self) -> bool { + match self { + PictureCoding::Avc { intra, .. } => *intra, + _ => true, + } + } + /// Picture essence coding label. + fn coding_label(&self) -> [u8; 16] { + match *self { + PictureCoding::Vc3 { cid } => { + let code = if (1235..=1234 + 0x7F).contains(&cid) { (cid - 1234) as u8 } else { 0 }; + [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0A, 0x04, 0x01, 0x02, 0x02, 0x71, code, 0x00, 0x00] + } + PictureCoding::ProRes { profile } => { + [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0D, 0x04, 0x01, 0x02, 0x02, 0x03, 0x06, profile.clamp(1, 6), 0x00] + } + PictureCoding::Avc { profile_idc, intra } => { + let (b13, b14) = if intra { + (0x32, 0x20) + } else { + ( + 0x31, + match profile_idc { + 66 => 0x10, + 77 => 0x20, + 88 => 0x30, + 110 => 0x50, + 122 => 0x60, + 244 => 0x70, + _ => 0x40, + }, + ) + }; + [0x06, 0x0E, 0x2B, 0x34, 0x04, 0x01, 0x01, 0x0A, 0x04, 0x01, 0x02, 0x02, 0x01, b13, b14, 0x01] + } + } + } + /// (essence container label, element type) for frame or clip wrapping. + fn container(&self, clip: bool) -> ([u8; 16], u8) { + let w = if clip { 0x02 } else { 0x01 }; + match self { + PictureCoding::Vc3 { .. } => (essence_container(0x0A, 0x11, w, 0x00), if clip { 0x0D } else { 0x0C }), + PictureCoding::ProRes { .. } => (essence_container(0x0D, 0x1C, w, 0x00), if clip { 0x18 } else { 0x17 }), + PictureCoding::Avc { .. } => (essence_container(0x0A, 0x10, 0x60, w), if clip { 0x16 } else { 0x15 }), + } + } +} + +/// Colour description of the picture (transfer, coding equations, primaries labels). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ColorSpace { + #[default] + Rec709, + /// BT.2020 primaries and matrix, SMPTE ST 2084 transfer. + Rec2020Pq, + /// BT.2020 primaries and matrix, ARIB STD-B67 (HLG) transfer. + Rec2020Hlg, +} + +/// The picture track. +#[derive(Clone, Debug, PartialEq)] +pub struct PictureDesc { + pub coding: PictureCoding, + pub width: u32, + pub height: u32, + /// Display aspect ratio of the frame (e.g. 16/9). + pub aspect: Rational, + /// Bits per component. + pub depth: u32, + /// Chroma subsampling: (2, 1) 4:2:2, (2, 2) 4:2:0, (1, 1) 4:4:4. + pub subsampling: (u32, u32), + pub color: ColorSpace, +} + +impl PictureDesc { + /// Square pixels; 8-bit 4:2:0 for AVC, 10-bit 4:2:2 for ProRes, 8-bit 4:2:2 for VC-3 + /// (10-bit for DNxHR HQX / 444 and the 10-bit DNxHD ids). + pub fn new(coding: PictureCoding, width: u32, height: u32) -> Self { + let g = gcd(width.max(1), height.max(1)); + let (depth, subsampling) = match coding { + PictureCoding::Avc { .. } => (8, (2, 2)), + PictureCoding::ProRes { profile } => (if profile >= 5 { 12 } else { 10 }, if profile >= 5 { (1, 1) } else { (2, 1) }), + PictureCoding::Vc3 { cid } => ( + if matches!(cid, 1235 | 1241 | 1250 | 1256 | 1270 | 1271) { 10 } else { 8 }, + if matches!(cid, 1256 | 1270) { (1, 1) } else { (2, 1) }, + ), + }; + PictureDesc { + coding, + width, + height, + aspect: Rational::new((width.max(1) / g) as i32, (height.max(1) / g) as i32), + depth, + subsampling, + color: ColorSpace::Rec709, + } + } +} + +fn gcd(a: u32, b: u32) -> u32 { + if b == 0 { a } else { gcd(b, a % b) } +} + +/// A PCM sound track (Broadcast Wave mapping, little-endian interleaved samples). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SoundDesc { + pub sample_rate: u32, + pub channels: u32, + /// 8, 16, 24 or 32. + pub bits: u32, +} + +impl SoundDesc { + pub fn block_align(&self) -> u32 { + self.channels.max(1) * self.bits.div_ceil(8) + } +} + +/// Start timecode of the packages. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StartTimecode { + /// Frame count of the first frame (at the timecode rate). + pub frames: i64, + /// Timecode frame rate (the track's edit rate; 30000/1001 for 29.97). + pub rate: Rational, + pub drop_frame: bool, +} + +impl StartTimecode { + pub fn rounded_base(&self) -> u16 { + if !self.rate.is_valid() { + return 25; + } + ((self.rate.num as i64 + self.rate.den as i64 - 1) / self.rate.den as i64) as u16 + } +} + +/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range +/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so +/// month, day and time of day come from chrono and only the year is carried, which keeps every +/// `i64` exact instead of collapsing out-of-range years to the epoch. +fn utc_parts(secs: i64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + +/// An MXF timestamp (UTC). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Timestamp { + pub year: u16, + pub month: u8, + pub day: u8, + pub hour: u8, + pub minute: u8, + pub second: u8, + pub millisecond: u16, +} + +impl Timestamp { + /// From seconds since the Unix epoch (proleptic Gregorian, UTC). Years far outside chrono's own + /// range are preserved (the old hand-rolled conversion kept them) with month, day and time + /// intact; only the `year` is clamped to the MXF `u16` field, exactly as the old code did. + pub fn from_unix(secs: i64) -> Timestamp { + use chrono::{Datelike, Timelike}; + let (y, t) = utc_parts(secs); + Timestamp { + year: y.clamp(0, 65535) as u16, + month: t.month() as u8, + day: t.day() as u8, + hour: t.hour() as u8, + minute: t.minute() as u8, + second: t.second() as u8, + millisecond: 0, + } + } + fn bytes(&self) -> [u8; 8] { + let y = self.year.to_be_bytes(); + [y[0], y[1], self.month, self.day, self.hour, self.minute, self.second, (self.millisecond / 4) as u8] + } +} + +/// Package identifiers and names. The material package is what an AAF composition (or an NLE) +/// references; the file package describes the stored essence. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PackageIds { + pub material: Umid, + pub file: Umid, + pub material_name: String, + pub file_name: String, +} + +impl PackageIds { + /// UMIDs derived from `seed` (material and file differ). + pub fn from_seed(seed: &str, name: &str) -> Self { + PackageIds { + material: Umid::from_seed(format!("material:{seed}").as_bytes()), + file: Umid::from_seed(format!("file:{seed}").as_bytes()), + material_name: name.to_string(), + file_name: name.to_string(), + } + } +} + +/// Everything the writer needs up front. +#[derive(Clone, Debug, PartialEq)] +pub struct WriterConfig { + pub pattern: Pattern, + /// Edit rate of the essence tracks (the frame rate; OP-Atom sound files may use the sample + /// rate, one edit unit per sample). + pub edit_rate: Rational, + pub picture: Option, + pub sound: Vec, + pub timecode: Option, + pub ids: PackageIds, + /// Track id of the first essence track (the timecode track is 1). OP-Atom files that share a + /// material package give their tracks distinct ids (Avid style: V1 = 2, A1 = 3, …). + pub first_track_id: u32, + pub company: String, + pub product: String, + pub version: String, + pub modified: Timestamp, +} + +impl WriterConfig { + /// A configuration with no essence tracks yet. `company` and `product` are `craft-media`: + /// an app sets its own names. + pub fn new(pattern: Pattern, edit_rate: Rational, ids: PackageIds) -> Self { + WriterConfig { + pattern, + edit_rate, + picture: None, + sound: Vec::new(), + timecode: None, + ids, + first_track_id: 2, + company: "craft-media".into(), + product: "craft-media".into(), + version: env!("CARGO_PKG_VERSION").into(), + modified: Timestamp::default(), + } + } +} + +/// How a picture was coded. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CodedKind { + #[default] + Intra, + /// Forward predicted (P). + Predicted, + /// Bidirectionally predicted (B). + Bidirectional, +} + +/// Index information for one picture, in stored (decode) order. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FrameInfo { + /// A decoder can start here (IDR / intra picture with its parameter sets). + pub key: bool, + pub kind: CodedKind, + /// Presentation position (edit units from the start); `None` = the stored position. + pub display: Option, +} + +impl FrameInfo { + /// An independently decodable picture presented in stored order. + pub fn intra() -> Self { + FrameInfo { key: true, kind: CodedKind::Intra, display: None } + } +} + +/// One index entry under construction. +#[derive(Clone, Debug)] +struct Entry { + /// Stream offset of the edit unit in the essence container. + offset: u64, + /// Offsets of slices 1.. from the edit unit start (OP1a sound elements). + slices: Vec, + info: FrameInfo, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TrackKind { + Picture, + Sound(usize), +} + +#[derive(Clone, Debug)] +struct TrackW { + id: u32, + kind: TrackKind, + element_key: [u8; 16], + container: [u8; 16], +} + +/// Header region: header metadata plus reserved fill, rewritten in place by `finish`. +const HEADER_RESERVE: usize = 4096; +const INDEX_SID: u32 = 2; +const BODY_SID: u32 = 1; +/// Entries per index table segment (the entry array's 2-byte local length limits a segment). +const MAX_SEGMENT_BYTES: usize = 60_000; + +/// Streaming MXF writer. See the [module docs](self). +pub struct MxfWriter { + w: W, + base: u64, + /// Bytes written so far (relative to `base`). + pos: u64, + cfg: WriterConfig, + tracks: Vec, + header_region: usize, + body_partition: u64, + /// File offset (relative) of the first byte of the essence container. + essence_start: u64, + entries: Vec, + // OP1a + pending: VecDeque<(Vec, FrameInfo)>, + sound_buf: Vec>, + /// Read cursor into each `sound_buf` track: bytes before it are already written. + sound_rd: Vec, + // OP-Atom + clip_len: u64, + clip_kl: u64, + samples: u64, +} + +fn ber4(n: usize) -> [u8; 4] { + let b = (n as u32).to_be_bytes(); + [0x83, b[1], b[2], b[3]] +} + +/// Key and BER length of a KLV triplet whose value is `len` bytes long. +fn klv_header(out: &mut Vec, key: &[u8; 16], len: usize) { + out.extend_from_slice(key); + if len < 1 << 24 { + out.extend_from_slice(&ber4(len)); + } else { + out.push(0x88); + out.extend_from_slice(&(len as u64).to_be_bytes()); + } +} + +fn klv(out: &mut Vec, key: &[u8; 16], v: &[u8]) { + klv_header(out, key, v.len()); + out.extend_from_slice(v); +} + +fn fill(out: &mut Vec, total: usize) { + if total < 20 { + // too small for a fill item: zero-length fill items are not possible; caller avoids this + out.extend(std::iter::repeat_n(0u8, total)); + return; + } + out.extend_from_slice(&KLV_FILL); + out.extend_from_slice(&ber4(total - 20)); + out.extend(std::iter::repeat_n(0u8, total - 20)); +} + +fn batch(items: &[&[u8]], item_size: usize) -> Vec { + let mut v = (items.len() as u32).to_be_bytes().to_vec(); + v.extend_from_slice(&(item_size as u32).to_be_bytes()); + for i in items { + v.extend_from_slice(i); + } + v +} + +fn utf16(s: &str) -> Vec { + s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect() +} + +fn rational(r: Rational) -> [u8; 8] { + let mut b = [0u8; 8]; + b[..4].copy_from_slice(&r.num.to_be_bytes()); + b[4..].copy_from_slice(&r.den.to_be_bytes()); + b +} + +/// A header metadata local set under construction. +struct Set { + key: [u8; 16], + v: Vec, +} + +impl Set { + fn new(kind: u8, uid: [u8; 16]) -> Set { + let mut s = Set { key: set_key(kind), v: Vec::new() }; + s.p(0x3C0A, &uid); + s + } + fn p(&mut self, tag: u16, v: &[u8]) -> &mut Set { + self.v.extend_from_slice(&tag.to_be_bytes()); + self.v.extend_from_slice(&(v.len().min(0xFFFF) as u16).to_be_bytes()); + self.v.extend_from_slice(&v[..v.len().min(0xFFFF)]); + self + } + fn u8(&mut self, tag: u16, x: u8) -> &mut Set { + self.p(tag, &[x]) + } + fn u16(&mut self, tag: u16, x: u16) -> &mut Set { + self.p(tag, &x.to_be_bytes()) + } + fn u32(&mut self, tag: u16, x: u32) -> &mut Set { + self.p(tag, &x.to_be_bytes()) + } + fn i64(&mut self, tag: u16, x: i64) -> &mut Set { + self.p(tag, &x.to_be_bytes()) + } + fn rat(&mut self, tag: u16, r: Rational) -> &mut Set { + self.p(tag, &rational(r)) + } + fn refs(&mut self, tag: u16, uids: &[[u8; 16]]) -> &mut Set { + let items: Vec<&[u8]> = uids.iter().map(|u| &u[..]).collect(); + self.p(tag, &batch(&items, 16)) + } + fn write(&self, out: &mut Vec) { + klv(out, &self.key, &self.v); + } +} + +/// Final (or provisional, -1 = unknown) durations for the metadata. +#[derive(Clone, Copy, Debug)] +struct Durations { + /// Edit units of the essence tracks. + essence: i64, +} + +impl MxfWriter { + /// Start a file: writes the header partition and the body partition pack. + pub fn new(mut w: W, cfg: WriterConfig) -> io::Result { + if !cfg.edit_rate.is_valid() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "MXF edit rate must be positive")); + } + // OP1a pads every sound track to a whole edit unit on flush. Bound that unit before a + // low but technically positive edit rate can turn padding into a multi-gigabyte Vec. + const MAX_PCM_EDIT_UNIT_BYTES: u128 = 64 << 20; + for sound in &cfg.sound { + let samples = (u128::from(sound.sample_rate) * cfg.edit_rate.den as u128).div_ceil(cfg.edit_rate.num as u128); + let bytes = samples.saturating_mul(u128::from(sound.block_align())); + if bytes > MAX_PCM_EDIT_UNIT_BYTES { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "MXF PCM edit unit exceeds the allocation budget")); + } + } + let n_essence = cfg.picture.is_some() as usize + cfg.sound.len(); + if n_essence == 0 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "MXF file without essence tracks")); + } + if cfg.pattern == Pattern::OpAtom && n_essence != 1 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "OP-Atom files hold exactly one essence track")); + } + if cfg.sound.iter().any(|s| s.sample_rate == 0 || s.channels == 0 || !(1..=32).contains(&s.bits)) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid PCM sound description")); + } + let clip = cfg.pattern == Pattern::OpAtom; + let mut tracks = Vec::new(); + let mut id = cfg.first_track_id.max(2); + if let Some(p) = &cfg.picture { + let (container, et) = p.coding.container(clip); + tracks.push(TrackW { id, kind: TrackKind::Picture, element_key: element_key(0x15, 1, et, 1), container }); + id += 1; + } + let ns = cfg.sound.len() as u8; + for k in 0..cfg.sound.len() { + let container = essence_container(0x01, 0x06, if clip { 0x02 } else { 0x01 }, 0x00); + tracks.push(TrackW { + id, + kind: TrackKind::Sound(k), + element_key: element_key(0x16, ns, if clip { 0x02 } else { 0x01 }, k as u8 + 1), + container, + }); + id += 1; + } + let base = w.stream_position()?; + let nsound = cfg.sound.len(); + let mut me = MxfWriter { + w, + base, + pos: 0, + cfg, + tracks, + header_region: 0, + body_partition: 0, + essence_start: 0, + entries: Vec::new(), + pending: VecDeque::new(), + sound_buf: vec![Vec::new(); nsound], + sound_rd: vec![0; nsound], + clip_len: 0, + clip_kl: 0, + samples: 0, + }; + let meta = me.metadata(Durations { essence: -1 }); + me.header_region = meta.len() + HEADER_RESERVE; + let pack = me.partition_pack(PartitionFields { + kind: 2, + status: 1, + this: 0, + prev: 0, + footer: 0, + hbc: me.header_region as u64, + ibc: 0, + index_sid: 0, + body_sid: 0, + }); + let mut out = pack; + out.extend_from_slice(&meta); + fill(&mut out, HEADER_RESERVE); + me.put(&out)?; + me.body_partition = me.pos; + let body = me.partition_pack(PartitionFields { + kind: 3, + status: 4, + this: me.pos, + prev: 0, + footer: 0, + hbc: 0, + ibc: 0, + index_sid: 0, + body_sid: BODY_SID, + }); + me.put(&body)?; + me.essence_start = me.pos; + if clip { + // clip-wrapped element: key + 9-byte BER length, patched by finish() + me.clip_kl = me.pos; + let mut kl = me.tracks[0].element_key.to_vec(); + kl.push(0x88); + kl.extend_from_slice(&[0; 8]); + me.put(&kl)?; + } + Ok(me) + } + + /// The configuration (package UMIDs and names included). + pub fn config(&self) -> &WriterConfig { + &self.cfg + } + + /// Edit units written so far (content packages, or OP-Atom pictures). + pub fn edit_units(&self) -> u64 { + self.entries.len() as u64 + } + + fn put(&mut self, b: &[u8]) -> io::Result<()> { + self.w.write_all(b)?; + self.pos += b.len() as u64; + Ok(()) + } + + fn uid(&self, n: u32) -> [u8; 16] { + let mut u = self.cfg.ids.file.material_number(); + u[6] = (u[6] & 0x0F) | 0x40; + u[8] = (u[8] & 0x3F) | 0x80; + u[12..].copy_from_slice(&n.to_be_bytes()); + u + } + + fn op_label(&self) -> [u8; 16] { + match self.cfg.pattern { + Pattern::Op1a => op1a(self.tracks.len() > 1), + Pattern::OpAtom => OP_ATOM, + } + } + + fn containers(&self) -> Vec<[u8; 16]> { + let mut v: Vec<[u8; 16]> = Vec::new(); + for t in &self.tracks { + if !v.contains(&t.container) { + v.push(t.container); + } + } + if self.tracks.len() > 1 { + v.push(EC_MULTIPLE); + } + v + } + + fn partition_pack(&self, f: PartitionFields) -> Vec { + let PartitionFields { kind, status, this, prev, footer, hbc, ibc, index_sid, body_sid } = f; + let mut v = Vec::with_capacity(160); + v.extend_from_slice(&1u16.to_be_bytes()); + v.extend_from_slice(&3u16.to_be_bytes()); + v.extend_from_slice(&1u32.to_be_bytes()); // KAG size + v.extend_from_slice(&this.to_be_bytes()); + v.extend_from_slice(&prev.to_be_bytes()); + v.extend_from_slice(&footer.to_be_bytes()); + v.extend_from_slice(&hbc.to_be_bytes()); + v.extend_from_slice(&ibc.to_be_bytes()); + v.extend_from_slice(&index_sid.to_be_bytes()); + v.extend_from_slice(&0u64.to_be_bytes()); // body offset + v.extend_from_slice(&body_sid.to_be_bytes()); + v.extend_from_slice(&self.op_label()); + let cs = self.containers(); + let items: Vec<&[u8]> = cs.iter().map(|c| &c[..]).collect(); + v.extend_from_slice(&batch(&items, 16)); + let mut out = Vec::new(); + klv(&mut out, &partition_key(kind, status), &v); + out + } + + /// Sound sample frames of edit unit `i` of OP1a track `k`. + fn unit_samples(&self, k: usize, i: u64) -> u64 { + let sr = self.cfg.sound[k].sample_rate as u128; + let r = self.cfg.edit_rate; + let at = |n: u64| (n as u128 * sr * r.den as u128 / r.num as u128) as u64; + at(i + 1) - at(i) + } + + /// Duration of an OP-Atom sound track in edit units. + fn sound_units(&self) -> i64 { + let sr = self.cfg.sound.first().map_or(1, |s| s.sample_rate) as u128; + let r = self.cfg.edit_rate; + let num = self.samples as u128 * r.num as u128; + let den = sr * r.den as u128; + num.div_ceil(den.max(1)) as i64 + } + + fn essence_units(&self) -> i64 { + match (self.cfg.pattern, self.cfg.picture.is_some()) { + (Pattern::OpAtom, false) => self.sound_units(), + _ => self.entries.len() as i64, + } + } + + /// Push one picture (stored order). OP1a: content packages are written as soon as the sound + /// of their edit unit is there too. + pub fn push_picture(&mut self, data: &[u8], info: FrameInfo) -> io::Result<()> { + if self.cfg.picture.is_none() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "this MXF file has no picture track")); + } + match self.cfg.pattern { + Pattern::Op1a => { + self.pending.push_back((data.to_vec(), info)); + self.emit(false) + } + Pattern::OpAtom => { + self.entries.push(Entry { offset: self.clip_len, slices: Vec::new(), info }); + self.put(data)?; + self.clip_len += data.len() as u64; + Ok(()) + } + } + } + + /// Push interleaved little-endian PCM of sound track `track` (whole sample frames). + pub fn push_sound(&mut self, track: usize, pcm: &[u8]) -> io::Result<()> { + let Some(sd) = self.cfg.sound.get(track).copied() else { + return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("no sound track {track}"))); + }; + let ba = sd.block_align() as usize; + if !pcm.len().is_multiple_of(ba) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "PCM data is not a whole number of sample frames")); + } + match self.cfg.pattern { + Pattern::Op1a => { + self.sound_buf[track].extend_from_slice(pcm); + self.emit(false) + } + Pattern::OpAtom => { + self.put(pcm)?; + self.clip_len += pcm.len() as u64; + self.samples += (pcm.len() / ba) as u64; + Ok(()) + } + } + } + + /// Write every complete OP1a content package (`flush`: pad the sound of the pending pictures + /// with silence, or, without a picture track, write the remaining sound). + fn emit(&mut self, flush: bool) -> io::Result<()> { + let has_pic = self.cfg.picture.is_some(); + loop { + let i = self.entries.len() as u64; + if has_pic && self.pending.is_empty() { + return Ok(()); + } + let need: Vec = + (0..self.cfg.sound.len()).map(|k| self.unit_samples(k, i) as usize * self.cfg.sound[k].block_align() as usize).collect(); + let enough = need.iter().zip(self.sound_buf.iter().zip(&self.sound_rd)).all(|(n, (b, rd))| b.len() - rd >= *n); + if !enough && !flush { + return Ok(()); + } + if !has_pic && self.sound_buf.iter().zip(&self.sound_rd).all(|(b, rd)| b.len() == *rd) { + return Ok(()); + } + let pic = if has_pic { self.pending.pop_front() } else { None }; + // The picture is written straight from its buffer; `head` is its key and length, + // `cp` the sound KLVs behind it. + let mut head = Vec::new(); + let mut cp = Vec::new(); + let mut slices = Vec::new(); + let pic_len = pic.as_ref().map_or(0, |(data, _)| data.len()); + if pic.is_some() { + klv_header(&mut head, &self.tracks[0].element_key, pic_len); + } + let sound_start = head.len() + pic_len; + let first_sound = has_pic as usize; + for (k, n) in need.iter().enumerate() { + let buf = &self.sound_buf[k]; + let rd = self.sound_rd[k]; + let take = (*n).min(buf.len() - rd); + if k > 0 || has_pic { + slices.push((sound_start + cp.len()) as u32); + } + klv_header(&mut cp, &self.tracks[first_sound + k].element_key, *n); + cp.extend_from_slice(&buf[rd..rd + take]); + cp.resize(cp.len() + (*n - take), 0); + self.sound_rd[k] = rd + take; + // Drop the consumed prefix once it is at least half of the buffer: amortised + // linear, where draining every edit unit from the front is quadratic. + let (buf, rd) = (&mut self.sound_buf[k], &mut self.sound_rd[k]); + if *rd == buf.len() { + buf.clear(); + *rd = 0; + } else if *rd >= 4096 && *rd * 2 >= buf.len() { + buf.drain(..*rd); + *rd = 0; + } + } + let info = pic.as_ref().map(|p| p.1).unwrap_or_else(FrameInfo::intra); + self.entries.push(Entry { offset: self.pos - self.essence_start, slices, info }); + self.put(&head)?; + if let Some((data, _)) = &pic { + self.put(data)?; + } + self.put(&cp)?; + } + } + + /// Header metadata (primer pack and sets). + fn metadata(&self, d: Durations) -> Vec { + let cfg = &self.cfg; + let mut out = Vec::new(); + let primer: Vec> = PRIMER + .iter() + .map(|(t, ul)| { + let mut e = t.to_be_bytes().to_vec(); + e.extend_from_slice(ul); + e + }) + .collect(); + let items: Vec<&[u8]> = primer.iter().map(Vec::as_slice).collect(); + klv(&mut out, &PRIMER_KEY, &batch(&items, 18)); + let ts = cfg.modified.bytes(); + let containers = self.containers(); + // Preface + let mut s = Set::new(0x2F, self.uid(1)); + s.p(0x3B02, &ts).u16(0x3B05, 0x0103).u32(0x3B07, 1).refs(0x3B06, &[self.uid(2)]).p(0x3B03, &self.uid(3)).p(0x3B09, &self.op_label()); + let ec: Vec<&[u8]> = containers.iter().map(|c| &c[..]).collect(); + s.p(0x3B0A, &batch(&ec, 16)).p(0x3B0B, &batch(&[], 16)); + s.write(&mut out); + // Identification + let mut s = Set::new(0x30, self.uid(2)); + s.p(0x3C09, &self.uid(100)) + .p(0x3C01, &utf16(&cfg.company)) + .p(0x3C02, &utf16(&cfg.product)) + .p(0x3C04, &utf16(&cfg.version)) + .p(0x3C05, &hash128(b"craft-media MXF writer")) + .p(0x3C06, &ts); + s.write(&mut out); + // Content storage, essence container data + let mut s = Set::new(0x18, self.uid(3)); + s.refs(0x1901, &[self.uid(1000), self.uid(2000)]).refs(0x1902, &[self.uid(4)]); + s.write(&mut out); + let mut s = Set::new(0x23, self.uid(4)); + s.p(0x2701, &cfg.ids.file.0).u32(0x3F06, INDEX_SID).u32(0x3F07, BODY_SID); + s.write(&mut out); + // Packages + for (pkg, base) in [(0u8, 1000u32), (1, 2000)] { + let material = pkg == 0; + let n = 1 + self.tracks.len() as u32; + let track_uids: Vec<[u8; 16]> = (0..n).map(|k| self.uid(base + 1 + 3 * k)).collect(); + let mut s = Set::new(if material { 0x36 } else { 0x37 }, self.uid(base)); + let (umid, name) = if material { (cfg.ids.material, &cfg.ids.material_name) } else { (cfg.ids.file, &cfg.ids.file_name) }; + s.p(0x4401, &umid.0); + if !name.is_empty() { + s.p(0x4402, &utf16(name)); + } + s.p(0x4405, &ts).p(0x4404, &ts).refs(0x4403, &track_uids); + if !material { + s.p(0x4701, &self.uid(3000)); + } + s.write(&mut out); + // timecode track (id 1) + let tc = cfg.timecode.unwrap_or(StartTimecode { frames: 0, rate: cfg.edit_rate, drop_frame: false }); + let tc_rate = if cfg.picture.is_some() || !tc.rate.is_valid() { cfg.edit_rate } else { tc.rate }; + let tc_dur = if d.essence < 0 { + -1 + } else { + let er = cfg.edit_rate; + { + let (a, b) = (d.essence as i128 * tc_rate.num as i128 * er.den as i128, (tc_rate.den as i128 * er.num as i128).max(1)); + ((a + b - 1) / b) as i64 + } + }; + self.track_sets( + &mut out, + TrackSpec { + uid0: base + 1, + id: 1, + number: 0, + rate: tc_rate, + def: DEF_TIMECODE, + duration: tc_dur, + name: if material { Some("TC1") } else { None }, + }, + |s| { + s.u16(0x1502, tc.rounded_base()).i64(0x1501, tc.frames).u8(0x1503, tc.drop_frame as u8); + }, + ); + let mut vi = 0; + let mut ai = 0; + for (k, t) in self.tracks.iter().enumerate() { + let (def, label) = match t.kind { + TrackKind::Picture => { + vi += 1; + (DEF_PICTURE, format!("V{vi}")) + } + TrackKind::Sound(_) => { + ai += 1; + (DEF_SOUND, format!("A{ai}")) + } + }; + let number = if material { 0 } else { u32::from_be_bytes(t.element_key[12..16].try_into().unwrap_or([0; 4])) }; + let file = cfg.ids.file; + let spec = TrackSpec { + uid0: base + 4 + 3 * k as u32, + id: t.id, + number, + rate: cfg.edit_rate, + def, + duration: d.essence, + name: material.then_some(label.as_str()), + }; + self.track_sets(&mut out, spec, |s| { + let (src, src_track) = if material { (file, t.id) } else { (Umid([0; 32]), 0) }; + s.i64(0x1201, 0).p(0x1101, &src.0).u32(0x1102, src_track); + }); + } + } + // Descriptors + let multiple = self.tracks.len() > 1; + let mut desc_uids = Vec::new(); + for (k, t) in self.tracks.iter().enumerate() { + let uid = if multiple { self.uid(3001 + k as u32) } else { self.uid(3000) }; + desc_uids.push(uid); + match t.kind { + TrackKind::Picture => { + // Picture tracks exist only with a picture configuration. + let Some(p) = cfg.picture.as_ref() else { continue }; + let mut s = Set::new(0x28, uid); + if multiple { + s.u32(0x3006, t.id); + } + s.rat(0x3001, cfg.edit_rate); + if d.essence >= 0 { + s.i64(0x3002, d.essence); + } + let (tcl, ce, cp) = match p.color { + ColorSpace::Rec709 => (TC_709, CE_709, CP_709), + ColorSpace::Rec2020Pq => (TC_PQ, CE_2020, CP_2020), + ColorSpace::Rec2020Hlg => (TC_HLG, CE_2020, CP_2020), + }; + let depth = p.depth.clamp(8, 16); + let black = 16u32 << (depth - 8); + let white = 235u32 << (depth - 8); + let range = (225u32 << (depth - 8)) + 1; + s.p(0x3004, &t.container) + .u8(0x320C, 0) + .u32(0x3203, p.width) + .u32(0x3202, p.height) + .u32(0x3205, p.width) + .u32(0x3204, p.height) + .u32(0x3209, p.width) + .u32(0x3208, p.height) + .rat(0x320E, p.aspect) + .p(0x320D, &batch(&[&0u32.to_be_bytes(), &0u32.to_be_bytes()], 4)) + .p(0x3201, &p.coding.coding_label()) + .p(0x3210, &tcl) + .p(0x321A, &ce) + .p(0x3219, &cp) + .u32(0x3301, depth) + .u32(0x3302, p.subsampling.0) + .u32(0x3308, p.subsampling.1) + .u32(0x3304, black) + .u32(0x3305, white) + .u32(0x3306, range); + s.write(&mut out); + } + TrackKind::Sound(i) => { + let sd = cfg.sound[i]; + let mut s = Set::new(0x48, uid); + if multiple { + s.u32(0x3006, t.id); + } + s.rat(0x3001, cfg.edit_rate); + if d.essence >= 0 { + s.i64(0x3002, d.essence); + } + s.p(0x3004, &t.container) + .rat(0x3D03, Rational::new(sd.sample_rate as i32, 1)) + .u8(0x3D02, 1) + .u32(0x3D07, sd.channels) + .u32(0x3D01, sd.bits) + .u16(0x3D0A, sd.block_align() as u16) + .u32(0x3D09, sd.block_align() * sd.sample_rate); + s.write(&mut out); + } + } + } + if multiple { + let mut s = Set::new(0x44, self.uid(3000)); + s.rat(0x3001, cfg.edit_rate); + if d.essence >= 0 { + s.i64(0x3002, d.essence); + } + s.p(0x3004, &EC_MULTIPLE).refs(0x3F01, &desc_uids); + s.write(&mut out); + } + out + } + + /// A track, its sequence and its one component (`component` adds the component-specific + /// properties: source clip or timecode). + fn track_sets(&self, out: &mut Vec, spec: TrackSpec<'_>, component: impl FnOnce(&mut Set)) { + let TrackSpec { uid0, id, number, rate, def, duration, name } = spec; + let (tu, su, cu) = (self.uid(uid0), self.uid(uid0 + 1), self.uid(uid0 + 2)); + let mut t = Set::new(0x3B, tu); + t.u32(0x4801, id).u32(0x4804, number); + if let Some(n) = name { + t.p(0x4802, &utf16(n)); + } + t.rat(0x4B01, rate).i64(0x4B02, 0).p(0x4803, &su); + t.write(out); + let mut s = Set::new(0x0F, su); + s.p(0x0201, &def).i64(0x0202, duration).refs(0x1001, &[cu]); + s.write(out); + let mut c = Set::new(if def == DEF_TIMECODE { 0x14 } else { 0x11 }, cu); + c.p(0x0201, &def).i64(0x0202, duration); + component(&mut c); + c.write(out); + } + + /// Index table segments for the essence written. + fn index(&self) -> Vec { + let mut out = Vec::new(); + let rate = self.cfg.edit_rate; + let mut seg_uid = 4000; + let mut segment = |out: &mut Vec, f: &mut dyn FnMut(&mut Set)| { + let mut s = Set { key: INDEX_KEY, v: Vec::new() }; + s.p(0x3C0A, &self.uid(seg_uid)); + seg_uid += 1; + s.rat(0x3F0B, rate); + f(&mut s); + s.u32(0x3F06, INDEX_SID).u32(0x3F07, BODY_SID); + s.write(out); + }; + // OP-Atom sound at one sample per edit unit: constant bytes per edit unit + if self.cfg.pattern == Pattern::OpAtom && self.cfg.picture.is_none() { + let sd = self.cfg.sound[0]; + let per_unit = + (sd.sample_rate as i64 * rate.den as i64) % rate.num as i64 == 0 && (sd.sample_rate as i64 * rate.den as i64) / rate.num as i64 == 1; + if per_unit { + let units = self.essence_units(); + segment(&mut out, &mut |s| { + s.i64(0x3F0C, 0).i64(0x3F0D, units).u32(0x3F05, sd.block_align()).u8(0x3F08, 0).u8(0x3F0E, 0); + }); + return out; + } + // other edit rates: one entry per edit unit from the sample counts + let units = self.essence_units().max(0) as u64; + let ba = sd.block_align() as u64; + let entries: Vec = (0..units) + .map(|i| { + let s = (i as u128 * sd.sample_rate as u128 * rate.den as u128 / rate.num as u128) as u64; + Entry { offset: s * ba, slices: Vec::new(), info: FrameInfo::intra() } + }) + .collect(); + self.entry_segments(&mut out, &entries, &mut segment); + return out; + } + self.entry_segments(&mut out, &self.entries, &mut segment); + out + } + + fn entry_segments(&self, out: &mut Vec, entries: &[Entry], segment: &mut dyn FnMut(&mut Vec, &mut dyn FnMut(&mut Set))) { + let n = entries.len(); + let nslices = entries.iter().map(|e| e.slices.len()).max().unwrap_or(0); + // temporal offsets: display d is stored at d + TO[d] + let mut stored_of = vec![usize::MAX; n]; + let mut valid = true; + for (s, e) in entries.iter().enumerate() { + let d = e.info.display.unwrap_or(s as i64); + if d < 0 || d as usize >= n || stored_of[d as usize] != usize::MAX { + valid = false; + break; + } + stored_of[d as usize] = s; + } + let intra = self.cfg.picture.as_ref().is_none_or(|p| p.coding.intra_only()); + let avc = matches!(self.cfg.picture.as_ref().map(|p| p.coding), Some(PictureCoding::Avc { .. })); + let mut rows: Vec> = Vec::with_capacity(n); + let mut last_key = 0usize; + for (s, e) in entries.iter().enumerate() { + let to = if valid { (stored_of[s] as i64 - s as i64).clamp(-128, 127) as i8 } else { 0 }; + if e.info.key || intra { + last_key = s; + } + let kfo = (last_key as i64 - s as i64).max(-128) as i8; + let flags: u8 = if e.info.key || intra { + if avc { 0xC0 } else { 0x80 } + } else { + match e.info.kind { + CodedKind::Intra => 0x00, + CodedKind::Predicted => 0x22, + CodedKind::Bidirectional => 0x33, + } + }; + let mut r = vec![to as u8, kfo as u8, flags]; + r.extend_from_slice(&e.offset.to_be_bytes()); + for k in 0..nslices { + r.extend_from_slice(&e.slices.get(k).copied().unwrap_or(0).to_be_bytes()); + } + rows.push(r); + } + // delta entries: element k of the content package is in slice k (picture in slice 0) + let n_el = if self.cfg.pattern == Pattern::Op1a { self.tracks.len() } else { 1 }; + let deltas: Vec<[u8; 6]> = (0..n_el).map(|k| [0, k.min(nslices) as u8, 0, 0, 0, 0]).collect(); + let delta_items: Vec<&[u8]> = deltas.iter().map(|d| &d[..]).collect(); + let delta = batch(&delta_items, 6); + let entry_size = 11 + 4 * nslices; + let per_seg = (MAX_SEGMENT_BYTES / entry_size).max(1); + let mut start = 0usize; + loop { + let end = (start + per_seg).min(n); + let items: Vec<&[u8]> = rows[start..end].iter().map(Vec::as_slice).collect(); + let arr = batch(&items, entry_size); + segment(out, &mut |s: &mut Set| { + s.i64(0x3F0C, start as i64) + .i64(0x3F0D, (end - start) as i64) + .u32(0x3F05, 0) + .u8(0x3F08, nslices as u8) + .u8(0x3F0E, 0) + .p(0x3F09, &delta) + .p(0x3F0A, &arr); + }); + start = end; + if start >= n { + break; + } + } + } + + /// Finish the file: footer partition with the final header metadata and the index, random + /// index pack, and the closed header. Returns the sink (positioned at the end). + pub fn finish(mut self) -> io::Result { + if self.cfg.pattern == Pattern::Op1a { + self.emit(true)?; + } else { + // patch the clip element length + let end = self.pos; + self.w.seek(SeekFrom::Start(self.base + self.clip_kl + 16))?; + let mut l = vec![0x88]; + l.extend_from_slice(&self.clip_len.to_be_bytes()); + self.w.write_all(&l)?; + self.w.seek(SeekFrom::Start(self.base + end))?; + } + let d = Durations { essence: self.essence_units() }; + let meta = self.metadata(d); + let index = self.index(); + let footer = self.pos; + let pack = self.partition_pack(PartitionFields { + kind: 4, + status: 4, + this: footer, + prev: self.body_partition, + footer, + hbc: meta.len() as u64, + ibc: index.len() as u64, + index_sid: INDEX_SID, + body_sid: 0, + }); + let mut out = pack; + out.extend_from_slice(&meta); + out.extend_from_slice(&index); + // random index pack: (BodySID, offset) per partition, then the pack's length + let mut rip = Vec::new(); + for (sid, off) in [(0u32, 0u64), (BODY_SID, self.body_partition), (0, footer)] { + rip.extend_from_slice(&sid.to_be_bytes()); + rip.extend_from_slice(&off.to_be_bytes()); + } + rip.extend_from_slice(&((16 + 4 + rip.len() + 4) as u32).to_be_bytes()); + klv(&mut out, &RIP_KEY, &rip); + self.put(&out)?; + let end = self.pos; + // closed, complete header partition (same size as the provisional one) + if meta.len() + 20 <= self.header_region { + let mut h = self.partition_pack(PartitionFields { + kind: 2, + status: 4, + this: 0, + prev: 0, + footer, + hbc: self.header_region as u64, + ibc: 0, + index_sid: 0, + body_sid: 0, + }); + h.extend_from_slice(&meta); + fill(&mut h, self.header_region - meta.len()); + self.w.seek(SeekFrom::Start(self.base))?; + self.w.write_all(&h)?; + } else { + // cannot happen (metadata sizes do not depend on the durations); keep the header open + let h = self.partition_pack(PartitionFields { + kind: 2, + status: 1, + this: 0, + prev: 0, + footer, + hbc: self.header_region as u64, + ibc: 0, + index_sid: 0, + body_sid: 0, + }); + self.w.seek(SeekFrom::Start(self.base))?; + self.w.write_all(&h)?; + } + let body = self.partition_pack(PartitionFields { + kind: 3, + status: 4, + this: self.body_partition, + prev: 0, + footer, + hbc: 0, + ibc: 0, + index_sid: 0, + body_sid: BODY_SID, + }); + self.w.seek(SeekFrom::Start(self.base + self.body_partition))?; + self.w.write_all(&body)?; + self.w.seek(SeekFrom::Start(self.base + end))?; + self.w.flush()?; + Ok(self.w) + } +} + +/// Interleave planar f32 samples as little-endian signed PCM of `bits` (16, 24 or 32; 8-bit is +/// unsigned as in WAVE). +pub fn encode_pcm(planar: &[Vec], bits: u32) -> Vec { + let n = planar.iter().map(Vec::len).min().unwrap_or(0); + let bps = bits.div_ceil(8).clamp(1, 4) as usize; + let mut out = Vec::with_capacity(n * planar.len() * bps); + for i in 0..n { + for c in planar { + // f32 products, as the WAV / QuickTime PCM writers quantise + let s = c[i].clamp(-1.0, 1.0); + match bps { + 1 => out.push(((s * 127.0).round() as i32 + 128) as u8), + 2 => out.extend_from_slice(&((s * 32767.0).round() as i16).to_le_bytes()), + 3 => out.extend_from_slice(&((s * 8_388_607.0).round() as i32).to_le_bytes()[..3]), + _ => out.extend_from_slice(&((s as f64 * 2_147_483_647.0).round() as i32).to_le_bytes()), + } + } + } + out +} + +/// Options of [`write_opatom_pcm`]. +#[derive(Clone, Debug, PartialEq)] +pub struct OpAtomPcm { + pub sample_rate: u32, + /// 16 or 24 (any 8–32). + pub bits: u32, + pub channels: u32, + /// Edit rate of the track (`None` = the sample rate: one edit unit per sample frame). + pub edit_rate: Option, + pub ids: PackageIds, + pub timecode: Option, +} + +/// A complete OP-Atom PCM file in memory. `samples` are interleaved integer sample values in the +/// range of `bits` (e.g. -32768..=32767 for 16 bits). +pub fn write_opatom_pcm(opts: &OpAtomPcm, samples: &[i32]) -> io::Result> { + let sd = SoundDesc { sample_rate: opts.sample_rate, channels: opts.channels.max(1), bits: opts.bits }; + let rate = opts.edit_rate.unwrap_or(Rational::new(opts.sample_rate as i32, 1)); + let mut cfg = WriterConfig::new(Pattern::OpAtom, rate, opts.ids.clone()); + cfg.sound = vec![sd]; + cfg.timecode = opts.timecode; + let mut w = MxfWriter::new(io::Cursor::new(Vec::new()), cfg)?; + let bps = sd.bits.div_ceil(8).clamp(1, 4) as usize; + let frames = samples.len() / sd.channels as usize; + let mut pcm = Vec::with_capacity(frames * sd.channels as usize * bps); + for &s in &samples[..frames * sd.channels as usize] { + if bps == 1 { + pcm.push((s + 128) as u8); + } else { + pcm.extend_from_slice(&s.to_le_bytes()[..bps]); + } + } + w.push_sound(0, &pcm)?; + Ok(w.finish()?.into_inner()) +} + +/// Fields of a partition pack that vary between the header, body and footer partitions. +struct PartitionFields { + kind: u8, + status: u8, + this: u64, + prev: u64, + footer: u64, + hbc: u64, + ibc: u64, + index_sid: u32, + body_sid: u32, +} + +/// Identity and timing of one track, its sequence and its component. +struct TrackSpec<'a> { + uid0: u32, + id: u32, + number: u32, + rate: Rational, + def: [u8; 16], + duration: i64, + name: Option<&'a str>, +} diff --git a/crates/media/src/mxf/write/labels.rs b/crates/media/src/mxf/write/labels.rs new file mode 100644 index 00000000..eb8fbc04 --- /dev/null +++ b/crates/media/src/mxf/write/labels.rs @@ -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 +} diff --git a/crates/media/src/mxf/write/tests.rs b/crates/media/src/mxf/write/tests.rs new file mode 100644 index 00000000..acc8bd08 --- /dev/null +++ b/crates/media/src/mxf/write/tests.rs @@ -0,0 +1,451 @@ +//! Writer tests: files read back with our own demuxer; truncation and mutation never panic. + +use std::io::Cursor; + +use super::*; +use crate::mxf::{Codec, OperationalPattern, PartitionKind, TrackKind, Wrapping, open}; + +fn prores_like(i: usize) -> Vec { + 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 { + 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 { + 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 { + 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 +} + +#[test] +fn op1a_rejects_an_unbounded_pcm_edit_unit_before_padding() { + let mut cfg = WriterConfig::new(Pattern::Op1a, Rational::new(1, 36_000), PackageIds::from_seed("low-rate", "Low Rate")); + cfg.picture = Some(PictureDesc::new(PictureCoding::ProRes { profile: 3 }, 64, 36)); + cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: 2, bits: 16 }]; + let err = MxfWriter::new(Cursor::new(Vec::new()), cfg).err().unwrap(); + assert!(err.to_string().contains("PCM edit unit")); +} + +/// 10 ProRes-like pictures at 25 fps with stereo 16-bit and mono 24-bit sound pushed in uneven chunks. +fn build_op1a() -> Vec { + 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() +} + +/// The OP1a file's partitions, names, timecode and operational pattern. +fn assert_op1a_metadata(m: &crate::mxf::MxfFile) { + 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(), m.company_name.as_deref()), (Some("craft-media"), Some("craft-media"))); + 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)); +} + +/// The picture track's parameters and every frame's bytes. +fn assert_op1a_picture(m: &crate::mxf::MxfFile, f: &[u8]) { + 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)); + } +} + +/// Both sound tracks' layout and samples. +fn assert_op1a_sound(m: &crate::mxf::MxfFile, f: &[u8]) { + let sounds: Vec = (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}"); + } +} + +/// The index has one entry per content package with two sound slices. +fn assert_op1a_index(m: &crate::mxf::MxfFile) { + // 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 op1a_reads_back() { + let f = build_op1a(); + let m = open(&f).unwrap(); + assert_op1a_metadata(&m); + assert_eq!(m.tracks.len(), 3); + assert_op1a_picture(&m, &f); + assert_op1a_sound(&m, &f); + assert_op1a_index(&m); +} + +#[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 = 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 = t.samples.iter().map(|s| s.pts).collect(); + assert_eq!(pts, vec![0, 3, 1, 2, 6, 4, 5, 7]); + let keys: Vec = t.samples.iter().map(|s| s.key).collect(); + assert_eq!(keys, vec![true, false, false, false, false, false, false, true]); + let b: Vec = 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 = (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::(), 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)); + // Years far outside chrono's own range are preserved with month/day/time intact and only the + // year clamped to the MXF `u16`, exactly as the old hand-rolled conversion did (not collapsed + // to the epoch — reviewer FredApps). `10_000_000_000_000` s → 318857-05-20 17:46:40. + let t = Timestamp::from_unix(10_000_000_000_000); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (65535, 5, 20, 17, 46, 40)); + // Year 10000 (just past chrono's range) still fits the u16 field. + let t = Timestamp::from_unix(253_402_300_800); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (10000, 1, 1, 0, 0, 0)); + // A negative year clamps to 0 but keeps its month/day/time. + let t = Timestamp::from_unix(-10_000_000_000_000); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (0, 8, 13, 6, 13, 20)); + // The `i64` extremes the old code accepted. + let t = Timestamp::from_unix(i64::MAX); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (65535, 12, 4, 15, 30, 7)); + let t = Timestamp::from_unix(i64::MIN); + assert_eq!((t.year, t.month, t.day, t.hour, t.minute, t.second), (0, 1, 27, 8, 29, 52)); +} + +#[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::>(), + ) + .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); + } + } + } + } +} + +/// #210 review: a whole-clip read of long stereo PCM (more samples than the old fixed +/// 16 777 216-sample request cap, about 175 s at 48 kHz) must work; only reads far past the stored +/// essence are refused. +#[test] +fn long_pcm_reads_backed_by_the_file_are_not_capped() { + let frames = 200 * 48_000; + let samples: Vec = (0..frames * 2).map(|k| (k % 60_000) as i32 - 30_000).collect(); + let opts = OpAtomPcm { sample_rate: 48_000, bits: 16, channels: 2, edit_rate: None, ids: PackageIds::from_seed("long", "Long"), timecode: None }; + let f = write_opatom_pcm(&opts, &samples).unwrap(); + let m = open(&f).unwrap(); + assert_eq!(m.tracks[0].stored_sample_frames(), frames as u64); + // a little past the end is zero padding, as before + let pcm = m.read_pcm(&f, 0, 0, frames + 10).unwrap(); + assert_eq!((pcm.len(), pcm[0].len()), (2, frames + 10)); + for k in [0, 1, frames / 2, frames - 1] { + assert_eq!(pcm[0][k], samples[2 * k] as f32 / 32768.0, "frame {k}"); + assert_eq!(pcm[1][k], samples[2 * k + 1] as f32 / 32768.0, "frame {k}"); + } + assert_eq!(pcm[0][frames..], [0.0; 10]); + // a request that is mostly not backed by data is refused before allocating + assert!(m.read_pcm(&f, 0, 0, frames + (1 << 24)).is_err()); +} + +/// One `push_sound` of a long clip must not cost more than the clip itself: draining the buffered +/// PCM from the front for every edit unit made this quadratic (about 16 s for 10 minutes). +#[test] +fn long_sound_only_push_stays_linear() { + let mut cfg = WriterConfig::new(Pattern::Op1a, Rational::new(25, 1), PackageIds::from_seed("long", "Long Sound")); + cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: 1, bits: 16 }]; + let frames = 600 * 48_000; + let pcm = (0..frames).flat_map(|k| ((k % 30_000) as i16).to_le_bytes()).collect::>(); + let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap(); + let t = std::time::Instant::now(); + w.push_sound(0, &pcm).unwrap(); + let pushed = t.elapsed(); + assert_eq!(w.edit_units(), 600 * 25); + let f = w.finish().unwrap().into_inner(); + assert!(pushed < std::time::Duration::from_secs(3), "push_sound took {pushed:?}"); + let m = open(&f).unwrap(); + let a = m.track_of_kind(TrackKind::Sound).unwrap(); + let got = m.read_pcm(&f, a, 0, frames).unwrap(); + for k in [0, 1, 1919, 1920, frames / 2, frames - 1] { + assert_eq!(got[0][k], (k % 30_000) as f32 / 32768.0, "frame {k}"); + } +} + +/// Opening a file with big pictures must not read most of the file: after a big value the walk +/// refills its window with a few KiB, not 64 KiB. +#[test] +fn open_does_not_read_through_big_pictures() { + use std::cell::Cell; + + struct Counting<'a> { + data: &'a [u8], + bytes: Cell, + } + impl crate::mxf::ByteSource for Counting<'_> { + fn len(&self) -> u64 { + self.data.len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + self.bytes.set(self.bytes.get() + buf.len() as u64); + self.data.read_at(offset, buf) + } + } + + let mut cfg = op1a_config(Rational::new(25, 1), PictureCoding::ProRes { profile: 3 }); + cfg.sound.truncate(1); + let mut w = MxfWriter::new(Cursor::new(Vec::new()), cfg).unwrap(); + let frames = 40; + w.push_sound(0, &pcm16(0, frames * 1920)).unwrap(); + for i in 0..frames { + let mut pic = prores_like(i); + pic.resize(200_000 + i, i as u8); + w.push_picture(&pic, FrameInfo::intra()).unwrap(); + } + let f = w.finish().unwrap().into_inner(); + let src = Counting { data: &f, bytes: Cell::new(0) }; + let m = open(&src).unwrap(); + let v = m.track_of_kind(TrackKind::Picture).unwrap(); + assert_eq!(m.tracks[v].samples.len(), frames); + assert!(m.tracks[v].samples.iter().enumerate().all(|(i, s)| s.size as usize == 200_000 + i)); + let read = src.bytes.get(); + assert!(read * 5 < f.len() as u64, "open read {read} of {} bytes", f.len()); +} diff --git a/crates/media/tests/oracle_mxf.rs b/crates/media/tests/oracle_mxf.rs new file mode 100644 index 00000000..3269c897 --- /dev/null +++ b/crates/media/tests/oracle_mxf.rs @@ -0,0 +1,416 @@ +//! Oracle tests for MXF (skipped without ffmpeg/ffprobe). +//! +//! Reading: ffmpeg writes OP1a, OP-Atom and D-10 files; the demuxer's tracks, sample tables, key +//! flags, timecode and PCM are compared with ffprobe's packets and ffmpeg's decoded sound. +//! +//! Writing: the essence of those files is written again with `MxfWriter` (OP1a and OP-Atom); +//! ffprobe must describe the new file as it does the source, and ffmpeg must decode the same +//! pictures and sound from it. + +mod common; + +use craft_media::CodecId; +use craft_media::mxf::{ + Codec, CodedKind, FrameInfo, MxfFile, MxfWriter, OpAtomPcm, OperationalPattern, PackageIds, Pattern, PictureCoding, PictureDesc, Rational, + SoundDesc, SoundFormat, StartTimecode, TrackKind, Wrapping, WriterConfig, open, write_opatom_pcm, +}; +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const V25: &[&str] = &["-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25"]; +const TONE: &[&str] = &["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000"]; +const NOISE: &[&str] = &["-f", "lavfi", "-i", "anoisesrc=color=pink:sample_rate=48000:amplitude=0.5:seed=7"]; + +const ALL: &[&str] = &["h264_bf2.mxf", "h264_2997df.mxf", "dnxhr_lb.mxf", "prores.mxf", "mpeg2.mxf", "atom_dnxhr.mxf", "atom_pcm.mxf", "d10.mxf"]; + +fn spec(name: &str) -> Vec<&'static str> { + let cat = |p: &[&[&'static str]]| p.concat(); + let x264: &[&str] = &["-c:v", "libx264", "-pix_fmt", "yuv420p"]; + match name { + // long-GOP H.264 with B pictures (ffmpeg writes no temporal offsets) + "h264_bf2.mxf" => { + cat(&[V25, NOISE, &["-t", "2"], x264, &["-bf", "2", "-g", "12", "-c:a", "pcm_s16le", "-ac", "2", "-timecode", "01:00:00:00"]]) + } + "h264_2997df.mxf" => cat(&[ + &["-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30000/1001"], + NOISE, + &["-t", "1"], + x264, + &["-bf", "0", "-g", "10", "-c:a", "pcm_s24le", "-ac", "2", "-timecode", "01:00:00;00"], + ]), + "dnxhr_lb.mxf" => { + cat(&[V25, TONE, &["-t", "1", "-c:v", "dnxhd", "-profile:v", "dnxhr_lb", "-pix_fmt", "yuv422p", "-c:a", "pcm_s24le", "-ac", "2"]]) + } + "prores.mxf" => cat(&[V25, TONE, &["-t", "1", "-c:v", "prores_ks", "-profile:v", "3", "-pix_fmt", "yuv422p10le", "-c:a", "pcm_s16le"]]), + "mpeg2.mxf" => cat(&[V25, NOISE, &["-t", "1", "-c:v", "mpeg2video", "-bf", "2", "-c:a", "pcm_s16le"]]), + "atom_dnxhr.mxf" => cat(&[V25, &["-t", "1", "-c:v", "dnxhd", "-profile:v", "dnxhr_lb", "-pix_fmt", "yuv422p", "-f", "mxf_opatom"]]), + "atom_pcm.mxf" => cat(&[NOISE, &["-t", "1", "-c:a", "pcm_s24le", "-ac", "1", "-f", "mxf_opatom"]]), + // D-10 (IMX): intra MPEG-2 at a constant rate, sound as 8-channel AES3 elements + "d10.mxf" => cat(&[ + &["-f", "lavfi", "-i", "testsrc2=size=720x608:rate=25"], + NOISE, + &["-t", "0.4", "-c:v", "mpeg2video", "-pix_fmt", "yuv422p", "-minrate", "30M", "-maxrate", "30M", "-b:v", "30M"], + &["-bufsize", "1200000", "-rc_init_occupancy", "1200000", "-intra_vlc", "1", "-non_linear_quant", "1", "-g", "1"], + &["-flags", "+ildct+low_delay", "-dc", "10", "-ps", "1", "-qmin", "1", "-qmax", "3", "-c:a", "pcm_s16le", "-ac", "4", "-f", "mxf_d10"], + ]), + other => panic!("unknown fixture {other}"), + } +} + +fn fixture(name: &str) -> Option { + let ffmpeg = common::ffmpeg_or_skip("mxf oracle")?; + let out = common::fixtures_dir("mxf").join(name); + common::fixtures::generate(&out, |tmp: &Path| { + let mut c = Command::new(&ffmpeg); + c.args(["-v", "error", "-y", "-nostdin"]).args(spec(name)); + // OP1a is chosen by the `.mxf` extension; the others name their muxer + c.arg(tmp).status().is_ok_and(|s| s.success()) + }) +} + +/// ffprobe's packets of stream `index`: (pts, size, key). +fn packets(all: &[Value], index: i64) -> Vec<(Option, u64, bool)> { + all.iter() + .filter(|p| common::int(&p["stream_index"]) == Some(index)) + .map(|p| (common::int(&p["pts"]), common::int(&p["size"]).unwrap_or(0) as u64, p["flags"].as_str().is_some_and(|f| f.starts_with('K')))) + .collect() +} + +/// ffmpeg's decode of the first sound stream as planar f32 (`channels` channels). +fn ffmpeg_pcm(path: &Path, channels: usize) -> Option>> { + let ff = common::ffmpeg()?; + let out = Command::new(ff) + .args(["-v", "error", "-nostdin", "-i"]) + .arg(path) + .args(["-map", "0:a:0", "-f", "f32le", "-acodec", "pcm_f32le", "-"]) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "{}: {}", path.display(), String::from_utf8_lossy(&out.stderr)); + let samples: Vec = out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect(); + Some((0..channels).map(|c| samples.iter().skip(c).step_by(channels).copied().collect()).collect()) +} + +/// MD5 of ffmpeg's decode of one stream (`v:0` or `a:0`), and whatever it complained about. +fn decode_md5(path: &Path, stream: &str) -> Option { + let ff = common::ffmpeg()?; + let out = Command::new(ff) + .args(["-v", "error", "-nostdin", "-i"]) + .arg(path) + .args(["-map", &format!("0:{stream}"), "-f", "md5", "-"]) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "{}: {}", path.display(), String::from_utf8_lossy(&out.stderr)); + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +fn expected_codec(name: &str) -> Codec { + match name { + "h264" => Codec::Avc { intra: false }, + "dnxhd" => Codec::Vc3, + "prores" => Codec::ProRes { profile: None }, + "mpeg2video" => Codec::Mpeg2, + other => panic!("ffprobe codec {other} has no mapping in this test"), + } +} + +struct Opened { + path: PathBuf, + bytes: Vec, + mxf: MxfFile, +} + +/// Open a fixture and compare every track with ffprobe. +fn check(name: &str) -> Option { + let path = fixture(name)?; + let bytes = std::fs::read(&path).unwrap(); + let mxf = open(&bytes).unwrap(); + // ffmpeg's D-10 files have no index table: the key frames come from a scan, with a warning + let expected: &[&str] = + if name == "d10.mxf" { &["no index table with random-access flags: key frames found by scanning the essence"] } else { &[] }; + assert_eq!(mxf.warnings, expected, "{name}"); + let streams = common::streams(&path)?; + let all = common::packets(&path)?; + let av: Vec<&Value> = streams.iter().filter(|s| s["codec_type"] == "video" || s["codec_type"] == "audio").collect(); + // a D-10 file's four channels are one sound track here and one stream in ffprobe + assert_eq!(mxf.tracks.len(), av.len(), "{name}: track count"); + for (t, s) in mxf.tracks.iter().zip(&av) { + let index = common::int(&s["index"]).unwrap(); + let pk = packets(&all, index); + let params = t.codec_params(); + if s["codec_type"] == "video" { + assert_eq!(t.kind, TrackKind::Picture, "{name}"); + let want = expected_codec(s["codec_name"].as_str().unwrap()); + // the ProRes profile comes from the coding label + assert_eq!(std::mem::discriminant(&t.codec), std::mem::discriminant(&want), "{name}: {:?}", t.codec); + let (w, h) = (common::int(&s["width"]).unwrap() as u32, common::int(&s["height"]).unwrap() as u32); + if name == "d10.mxf" { + // D-10 stores 608 lines (two fields of 304, with 32 lines above the picture) and + // displays 576: ffprobe reports the stored size, `codec_params` the displayed one + let pic = t.picture.as_ref().unwrap(); + assert_eq!((pic.stored_width, pic.stored_height * 2), (w, h), "{name}"); + assert_eq!((params.width, params.height), (Some(720), Some(576)), "{name}"); + } else { + assert_eq!((params.width, params.height), (Some(w), Some(h)), "{name}"); + } + assert_eq!(t.samples.len(), pk.len(), "{name}: picture count"); + for (i, (sample, p)) in t.samples.iter().zip(&pk).enumerate() { + assert_eq!(u64::from(sample.size), p.1, "{name}: picture {i} size"); + if t.codec == Codec::Mpeg2 { + // ffprobe marks every I picture; `key` is the index's random-access bit, which + // the I picture of an open GOP does not have (see `op1a_files_match_ffprobe`) + assert!(!sample.key || p.2, "{name}: picture {i} is a key only here"); + } else { + assert_eq!(sample.key, p.2, "{name}: picture {i} key flag"); + } + } + assert!(t.samples.first().is_some_and(|s| s.key), "{name}: the first picture is a key"); + // presentation order, where the index has temporal offsets: ffprobe's PTS order + if t.temporal_offsets { + let first = pk.iter().filter_map(|p| p.0).min().unwrap(); + let theirs: Vec = pk.iter().map(|p| p.0.unwrap() - first).collect(); + let ours: Vec = t.samples.iter().map(|s| s.pts).collect(); + assert_eq!(ours, theirs, "{name}: presentation positions"); + } + let mut order = t.display_order.clone(); + order.sort_unstable(); + assert_eq!(order, (0..t.samples.len()).collect::>(), "{name}: the display order is a permutation"); + } else { + assert_eq!(t.kind, TrackKind::Sound, "{name}"); + let info = t.sound.as_ref().unwrap(); + let channels = common::int(&s["channels"]).unwrap() as usize; + assert_eq!(info.channels as usize, channels, "{name}: channels"); + assert_eq!((params.codec, params.sample_rate), (CodecId::Pcm, Some(48_000)), "{name}"); + // every stored sample, exactly as ffmpeg decodes it + let theirs = ffmpeg_pcm(&path, channels)?; + let frames = theirs[0].len(); + assert_eq!(t.stored_sample_frames(), frames as u64, "{name}: sample frames"); + let track = mxf.tracks.iter().position(|x| std::ptr::eq(x, t)).unwrap(); + let ours = mxf.read_pcm(&bytes, track, 0, frames).unwrap(); + assert!(ours == theirs, "{name}: PCM differs from ffmpeg's decode"); + // and a read from the middle + let part = mxf.read_pcm(&bytes, track, 1000, 5000).unwrap(); + assert!(part.iter().zip(&theirs).all(|(a, b)| a[..] == b[1000..6000]), "{name}: PCM from sample 1000"); + } + } + Some(Opened { path, bytes, mxf }) +} + +#[test] +fn op1a_files_match_ffprobe() { + for name in ["h264_bf2.mxf", "h264_2997df.mxf", "dnxhr_lb.mxf", "prores.mxf", "mpeg2.mxf"] { + let Some(o) = check(name) else { return }; + let m = &o.mxf; + assert_eq!(m.operational_pattern, OperationalPattern::Generalized { item: 1, package: 1 }, "{name}"); + assert!(m.tracks.iter().all(|t| t.wrapping == Wrapping::Frame), "{name}"); + let v = &m.tracks[m.track_of_kind(TrackKind::Picture).unwrap()]; + match name { + "h264_bf2.mxf" => { + assert_eq!(m.timecode.map(|t| t.format()).as_deref(), Some("01:00:00:00")); + // B pictures in the index, no temporal offsets: the order is the caller's to find + assert!(v.needs_reorder && !v.temporal_offsets && v.samples.iter().any(|s| s.b_picture)); + assert!(v.samples.iter().filter(|s| s.key).count() > 1); + } + "h264_2997df.mxf" => { + assert_eq!(m.timecode.map(|t| t.format()).as_deref(), Some("01:00:00;00")); + assert_eq!(v.edit_rate, Rational::new(30_000, 1001)); + assert!(!v.needs_reorder); + } + // ffmpeg's profile 3 is 422 HQ, 4 in the RDD 44 coding label + "prores.mxf" => assert_eq!(v.codec, Codec::ProRes { profile: Some(4) }), + "dnxhr_lb.mxf" => assert!(v.samples.iter().all(|s| s.key) && v.codec.intra_only()), + _ => { + // long-GOP MPEG-2 with open GOPs: the first I picture is a random-access point, + // the later ones are I pictures (index flags 00xx) without the random-access bit + let flags: Vec = m.index_segments.iter().flat_map(|s| s.entries.iter().map(|e| e.flags)).collect(); + assert_eq!(flags.len(), v.samples.len()); + assert!(flags[0] & 0x80 != 0 && v.samples[0].key); + let open_gop: Vec = (1..flags.len()).filter(|&i| flags[i] & 0x30 == 0 && flags[i] & 0x80 == 0).collect(); + assert!(!open_gop.is_empty(), "flags {flags:02x?}"); + assert!(open_gop.iter().all(|&i| !v.samples[i].key)); + } + } + } +} + +#[test] +fn op_atom_and_d10_files_match_ffprobe() { + let Some(o) = check("atom_dnxhr.mxf") else { return }; + assert_eq!(o.mxf.operational_pattern, OperationalPattern::Atom); + assert_eq!((o.mxf.tracks[0].wrapping, o.mxf.tracks[0].codec), (Wrapping::Clip, Codec::Vc3)); + let Some(o) = check("atom_pcm.mxf") else { return }; + assert_eq!(o.mxf.operational_pattern, OperationalPattern::Atom); + assert_eq!(o.mxf.tracks[0].sound_format, SoundFormat::Pcm); + assert!(o.mxf.tracks[0].codec_params().pcm.is_some_and(|p| p.bits == 24 && !p.big_endian)); + let Some(o) = check("d10.mxf") else { return }; + let a = &o.mxf.tracks[o.mxf.track_of_kind(TrackKind::Sound).unwrap()]; + assert_eq!((a.sound_format, a.codec), (SoundFormat::Aes3Element, Codec::Aes3Element)); + assert_eq!(a.codec_params().pcm, None, "AES3 element data is not plain PCM"); + assert_eq!(o.mxf.tracks[o.mxf.track_of_kind(TrackKind::Picture).unwrap()].codec, Codec::Mpeg2); +} + +/// `profile_idc` of the first SPS in an Annex B access unit. +fn avc_profile(au: &[u8]) -> Option { + au.windows(5).find(|w| w[..3] == [0, 0, 1] && w[3] & 0x1F == 7).map(|w| w[4]) +} + +/// The writer's picture coding for a track read from a file. +fn coding_of(codec: Codec, first: &[u8]) -> PictureCoding { + match codec { + // the VC-3 compression id is at byte 40 of the frame header + Codec::Vc3 => PictureCoding::Vc3 { cid: u32::from_be_bytes(first[40..44].try_into().unwrap()) }, + Codec::ProRes { profile } => PictureCoding::ProRes { profile: profile.unwrap() }, + Codec::Avc { intra } => PictureCoding::Avc { profile_idc: avc_profile(first).unwrap(), intra }, + other => panic!("the writer has no mapping for {other:?}"), + } +} + +/// Write the essence of an OP1a file again as OP1a: every picture and the raw sound bytes. +fn rewrite_op1a(o: &Opened, name: &str) -> PathBuf { + let m = &o.mxf; + let vi = m.track_of_kind(TrackKind::Picture).unwrap(); + let ai = m.track_of_kind(TrackKind::Sound).unwrap(); + let (v, a) = (&m.tracks[vi], &m.tracks[ai]); + let first = m.read_sample(&o.bytes, vi, 0).unwrap(); + let pic = v.picture.as_ref().unwrap(); + let mut cfg = WriterConfig::new(Pattern::Op1a, v.edit_rate, PackageIds::from_seed(name, "Rewritten")); + cfg.picture = Some(PictureDesc::new(coding_of(v.codec, &first), pic.frame_width(), pic.frame_height())); + let s = a.sound.as_ref().unwrap(); + cfg.sound = vec![SoundDesc { sample_rate: 48_000, channels: s.channels, bits: s.bits }]; + let tc = m.timecode.unwrap(); + cfg.timecode = Some(StartTimecode { frames: tc.start, rate: v.edit_rate, drop_frame: tc.drop_frame }); + let out = common::fixtures_dir("mxf").join(format!("rewritten_{name}")); + let mut w = MxfWriter::new(std::io::BufWriter::new(std::fs::File::create(&out).unwrap()), cfg).unwrap(); + for (i, sample) in v.samples.iter().enumerate() { + let data = m.read_sample(&o.bytes, vi, i).unwrap(); + // no B pictures in these files: stored order is presentation order + let info = if sample.key { FrameInfo::intra() } else { FrameInfo { key: false, kind: CodedKind::Predicted, display: None } }; + w.push_picture(&data, info).unwrap(); + } + for c in &a.chunks { + w.push_sound(0, &o.bytes[c.offset as usize..(c.offset + c.size) as usize]).unwrap(); + } + assert_eq!(w.edit_units(), v.samples.len() as u64); + w.finish().unwrap(); + out +} + +/// Files written by `MxfWriter` from ffmpeg-encoded essence: ffprobe describes them as it does +/// the source, ffmpeg decodes the same pictures and sound, and our own reader reads them back. +#[test] +fn written_op1a_files_are_read_by_ffmpeg_like_their_source() { + for name in ["dnxhr_lb.mxf", "prores.mxf", "h264_2997df.mxf"] { + let Some(o) = check(name) else { return }; + let out = rewrite_op1a(&o, name); + let (Some(src), Some(new)) = (common::streams(&o.path), common::streams(&out)) else { return }; + for (a, b) in src.iter().zip(&new) { + for key in ["codec_type", "codec_name", "width", "height", "pix_fmt", "sample_rate", "channels", "r_frame_rate", "bits_per_sample"] { + assert_eq!(a[key], b[key], "{name}: stream {key}"); + } + } + assert_eq!(src.len(), new.len(), "{name}: stream count"); + let (Some(pa), Some(pb)) = (common::packets(&o.path), common::packets(&out)) else { return }; + for index in 0..2 { + let (a, b) = (packets(&pa, index), packets(&pb, index)); + assert_eq!(a.len(), b.len(), "{name}: stream {index} packet count"); + if index == 0 { + assert!(a.iter().zip(&b).all(|(x, y)| (x.1, x.2) == (y.1, y.2)), "{name}: picture sizes and key flags"); + } else { + // at 29.97 an edit unit holds 1601 or 1602 samples, in an order each writer picks + assert_eq!(a.iter().map(|p| p.1).sum::(), b.iter().map(|p| p.1).sum::(), "{name}: sound bytes"); + } + } + let (Some(fa), Some(fb)) = (common::probe_json(&o.path, &["-show_format"]), common::probe_json(&out, &["-show_format"])) else { return }; + assert_eq!(fb["format"]["format_name"], "mxf", "{name}"); + assert_eq!(fa["format"]["tags"]["timecode"], fb["format"]["tags"]["timecode"], "{name}: timecode"); + assert_eq!(fb["format"]["tags"]["product_name"], "craft-media", "{name}"); + for stream in ["v:0", "a:0"] { + assert_eq!(decode_md5(&o.path, stream), decode_md5(&out, stream), "{name}: ffmpeg's decode of {stream}"); + } + // and our reader gives back what went in + let bytes = std::fs::read(&out).unwrap(); + let m = open(&bytes).unwrap(); + assert!(m.warnings.is_empty(), "{name}: {:?}", m.warnings); + assert_eq!(m.operational_pattern, OperationalPattern::Generalized { item: 1, package: 1 }); + let (vi, src_vi) = (m.track_of_kind(TrackKind::Picture).unwrap(), o.mxf.track_of_kind(TrackKind::Picture).unwrap()); + for i in 0..m.tracks[vi].samples.len() { + assert_eq!(m.read_sample(&bytes, vi, i).unwrap(), o.mxf.read_sample(&o.bytes, src_vi, i).unwrap(), "{name}: picture {i}"); + } + } +} + +#[test] +fn written_op_atom_files_are_read_by_ffmpeg() { + // picture: the DNxHR frames of ffmpeg's OP-Atom file, clip-wrapped again + let Some(o) = check("atom_dnxhr.mxf") else { return }; + let v = &o.mxf.tracks[0]; + let first = o.mxf.read_sample(&o.bytes, 0, 0).unwrap(); + let mut cfg = WriterConfig::new(Pattern::OpAtom, v.edit_rate, PackageIds::from_seed("atom", "Atom")); + cfg.picture = Some(PictureDesc::new(coding_of(v.codec, &first), 320, 240)); + let mut w = MxfWriter::new(std::io::Cursor::new(Vec::new()), cfg).unwrap(); + for i in 0..v.samples.len() { + w.push_picture(&o.mxf.read_sample(&o.bytes, 0, i).unwrap(), FrameInfo::intra()).unwrap(); + } + let out = common::fixtures_dir("mxf").join("rewritten_atom_dnxhr.mxf"); + std::fs::write(&out, w.finish().unwrap().into_inner()).unwrap(); + let Some(streams) = common::streams(&out) else { return }; + assert_eq!((streams.len(), &streams[0]["codec_name"]), (1, &Value::from("dnxhd"))); + let Some(all) = common::packets(&out) else { return }; + assert_eq!(all.len(), v.samples.len()); + assert_eq!(decode_md5(&o.path, "v:0"), decode_md5(&out, "v:0")); + + // sound: 24-bit samples, read by ffmpeg bit for bit + let samples: Vec = (0..48_000).map(|k| ((k * 7919) % 16_777_216) - 8_388_608).collect(); + for (label, edit_rate) in [("samples", None), ("frames", Some(Rational::new(25, 1)))] { + let opts = OpAtomPcm { sample_rate: 48_000, bits: 24, channels: 1, edit_rate, ids: PackageIds::from_seed(label, "A1"), timecode: None }; + let out = common::fixtures_dir("mxf").join(format!("written_atom_pcm_{label}.mxf")); + std::fs::write(&out, write_opatom_pcm(&opts, &samples).unwrap()).unwrap(); + let Some(pcm) = ffmpeg_pcm(&out, 1) else { return }; + let want: Vec = samples.iter().map(|s| *s as f32 / 8_388_608.0).collect(); + assert!(pcm[0] == want, "{label}: ffmpeg decodes other samples"); + let Some(streams) = common::streams(&out) else { return }; + assert_eq!((&streams[0]["codec_name"], &streams[0]["sample_rate"]), (&Value::from("pcm_s24le"), &Value::from("48000"))); + } +} + +/// Every fixture, truncated and damaged: opening and reading never panics. +#[test] +fn damaged_ffmpeg_files_never_panic() { + for name in ALL { + let Some(path) = fixture(name) else { return }; + let bytes = std::fs::read(&path).unwrap(); + let mut x = 0x2545_F491u32; + let mut next = move || { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x as usize + }; + // damage where the structure is: the header and the footer, not the essence in between + let edges = 16_384.min(bytes.len() / 2); + for round in 0..60 { + let mut d = bytes.clone(); + if round % 4 == 0 { + d.truncate(next() % d.len()); + } + for _ in 0..1 + round % 12 { + let at = if next() % 2 == 0 { next() % edges } else { d.len().saturating_sub(1 + next() % edges) }; + if let Some(b) = d.get_mut(at) { + *b = [0, 0xFF, 0x80, next() as u8][next() % 4]; + } + } + let Ok(m) = open(&d) else { continue }; + for (ti, t) in m.tracks.iter().enumerate() { + let _ = (t.codec_params(), t.sync_before(t.samples.len()), m.timecode.map(|t| t.format())); + let n = t.samples.len(); + for i in (0..n).step_by((n / 8).max(1)).chain(n.checked_sub(1)) { + let _ = m.read_sample(&d, ti, i); + } + let _ = m.read_pcm(&d, ti, 0, 4800); + let _ = m.read_pcm(&d, ti, t.stored_sample_frames().saturating_sub(100), 4800); + } + } + } +}