diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 4e9a64d..7fbc476 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,7 +13,7 @@ body: attributes: label: mp4forge Version description: Which version are you using? - placeholder: "0.7.0" + placeholder: "0.8.0" validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 102e538..9aa4131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ +# 0.8.0 (May 26, 2026) + +- Added the feature-gated mux release surface, including `MuxRequest`, path-first + `MuxTrackSpec` parsing with selector suffixes, flat and fragmented output layout selection, + segment or fragment duration modes, sync `mux_to_path`, `mux_into_path`, and + `mux_fragmented_to_paths` helpers, Tokio async companions behind `mux` plus `async`, and the + sync-only `mux` CLI route +- Added real MP4 mux writing for both flat and fragmented layouts on one shared mux event graph, + with deterministic payload planning, chunk interleaving, duration-based chunking, sync-aligned + fragment or segment boundaries, split init/media fragmented outputs, destination update or + create-new behavior, preserved flat destination metadata where supported, and explicit + validation for unsupported duration-mode or fragmented multi-video requests +- Added broad mux import coverage for MP4 track import with retained sample-entry bytes, selected + AVI, MPEG-PS, MPEG-TS, DASH, NHML, NHNT, SAF, and VobSub-style inputs, plus demux-backed + elementary or containerized audio, video, text, subtitle, image, raw-video, and PCM families + across the public path-first request shape +- Added `mp4forge::mux::inspect`, `mp4forge::mux::sample_reader`, and + `mp4forge::mux::rewrite` helpers for direct-ingest reporting, packet-focused exports, + one-sample-at-a-time seekable or progressive reading, and elementary sample rewrite/export + flows for AVC, HEVC, VVC, AV1, AAC ADTS, and MHAS data +- Expanded typed box and sample-entry coverage needed by the mux, probe, divide, and rewrite + paths, including additional DTS, Dolby, IAMF, FLAC, MPEG-H, PCM, uncompressed-visual, + codec-configuration, timing, and metadata structures +- Broadened `divide` and probe behavior for more video and audio sample-entry families while + keeping the existing validation-first workflow for unsupported fragmented output shapes +- Reworked file-backed decrypt rewrite execution to use streaming root rewrite plans for the + supported sync and async surfaces, refreshed affected top-level `sidx` data after clear-output + rewrites, and kept progress-enabled decrypt surfaces byte-aligned with their non-progress + companions +- Added mux-focused examples, expanded retained fixture coverage, moved test output helpers onto + auto-cleaned temporary paths, and added fuzz targets for mux demuxing, sample rewrites, async + API parity, decrypt byte-surface parity, extra box roundtrips, bit I/O boundaries, and `sidx` + plan application +- Removed the deprecated `dump` command `-mdat` and `-free` shorthand options; use + `-full mdat` or `-full free,skip` explicitly instead + # 0.7.0 (April 28, 2026) - Added the feature-gated decryption release surface across sync library helpers, Tokio async diff --git a/Cargo.toml b/Cargo.toml index bf83d5f..50a56b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "mp4forge" -version = "0.7.0" +version = "0.8.0" edition = "2024" rust-version = "1.88" authors = ["bakgio"] license = "MIT OR Apache-2.0" -description = "Rust library and CLI for inspecting, probing, extracting, and rewriting MP4 box structures" +description = "Rust library and CLI for inspecting, probing, extracting, muxing, and rewriting MP4 structures" repository = "https://github.com/bakgio/mp4forge" readme = "README.md" keywords = ["mp4", "isobmff", "parser", "video", "cli"] @@ -20,10 +20,12 @@ rustdoc-args = ["--cfg", "docsrs"] default = [] async = ["dep:tokio"] decrypt = ["dep:aes"] +mux = [] serde = ["dep:serde"] [dependencies] aes = { version = "0.8", optional = true } +miniz_oxide = "0.8" serde = { version = "1", features = ["derive"], optional = true } terminal_size = "0.4" tokio = { version = "1.52.1", features = ["fs", "io-util", "rt", "rt-multi-thread", "macros"], optional = true } @@ -31,3 +33,4 @@ tokio = { version = "1.52.1", features = ["fs", "io-util", "rt", "rt-multi-threa [dev-dependencies] aes = "0.8" serde_json = "1" +tempfile = "3" diff --git a/README.md b/README.md index 6144282..ee6689b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

mp4forge

- Rust library and CLI for inspecting, extracting, probing, and rewriting MP4 box structures. + Rust library and CLI for inspecting, extracting, probing, muxing, and rewriting MP4 structures.

Crates.io @@ -21,20 +21,22 @@ - Thin typed path-based helpers and byte-slice convenience wrappers for common extraction, rewrite, and probe flows - Fragmented top-level `sidx` analysis, planning, and rewrite APIs for supported layouts - Feature-gated decryption APIs and a sync-only `decrypt` CLI for the supported protected MP4 families -- Built-in CLI for `decrypt`, `dump`, `extract`, `probe`, `psshdump`, `edit`, and `divide` +- Built-in CLI for `decrypt`, `divide`, `dump`, `edit`, `extract`, `mux`, `probe`, and `psshdump` - Shared-fixture coverage for regular MP4, fragmented MP4, encrypted init segments, QuickTime-style metadata cases, and derived real codec fixtures for additional codec-family coverage ## Installation ```toml [dependencies] -mp4forge = "0.7.0" +mp4forge = "0.8.0" # With optional features: -# mp4forge = { version = "0.7.0", features = ["async"] } -# mp4forge = { version = "0.7.0", features = ["decrypt"] } -# mp4forge = { version = "0.7.0", features = ["decrypt", "async"] } -# mp4forge = { version = "0.7.0", features = ["serde"] } +# mp4forge = { version = "0.8.0", features = ["async"] } +# mp4forge = { version = "0.8.0", features = ["decrypt"] } +# mp4forge = { version = "0.8.0", features = ["decrypt", "async"] } +# mp4forge = { version = "0.8.0", features = ["mux"] } +# mp4forge = { version = "0.8.0", features = ["mux", "async"] } +# mp4forge = { version = "0.8.0", features = ["serde"] } ``` Install the CLI from crates.io: @@ -68,6 +70,14 @@ feature flags: IPMP ACBC and ACGK OD-track movies, and the retained IAEC protected-movie path. When combined with `async`, it also enables the additive file-backed Tokio async decrypt companions, while the CLI remains on the synchronous path. +- `mux`: enables the additive mux task surface and the retained low-level helpers underneath it. + The library path covers the narrow public `MuxRequest` model with repeated track specs plus + optional `segment_duration` or `fragment_duration`, real `ftyp`/`moov`/`mdat` writing for sync + callers, additive async real-container writing when combined with `async`, internal chunk and + duration coordination on one mux event graph, the retained low-level seekable and progressive + payload assembly helpers, and one-sample-at-a-time seekable or progressive readers. It also + enables the sync-only `mux` CLI route for one output MP4 built from repeated + path-first `--track` inputs. - `serde`: derives `Serialize` and `Deserialize` for the reusable public report structs under `mp4forge::cli::probe` and `mp4forge::cli::dump`, along with their nested public codec-detail, media-characteristics, `FieldValue`, and `FourCc` data. This is intended for library-side report @@ -85,6 +95,7 @@ COMMAND: dump display the MP4 box tree edit rewrite selected boxes extract extract raw boxes by type or path + mux merge one video track plus audio, text, and subtitle tracks into one MP4 psshdump summarize pssh boxes probe summarize an MP4 file ``` @@ -94,9 +105,37 @@ sync-only, accepts repeated `--key ID:KEY`, optional `--fragments-info FILE`, an `--show-progress`, and reuses the same library decryption surface that backs the feature-gated sync and async APIs. -`divide` currently targets fragmented inputs with up to one AVC video track and one MP4A audio -track, including encrypted wrappers that preserve those original sample-entry formats. Pass -`-validate` when you want the same probe-driven layout checks without creating any output files. +`mux` is available when the crate is built with `--features mux`. The CLI route stays sync-only +and accepts repeated `--track` inputs, one required positional output path, and at most one of +`--segment_duration` or `--fragment_duration`. The current public `--track` grammar is path-first: +`PATH` imports one raw source or every supported track from one MP4 source, while +`PATH#video`, `PATH#audio`, `PATH#audio:N`, `PATH#text`, `PATH#text:N`, and `PATH#track:ID` +select one specific track from a containerized source. The landed path-only auto-detection +currently covers MP4, supported AVI audio streams plus H.263/JPEG/PNG/MPEG-4 Part 2/H.264/AVC1 video streams, supported +MPEG-PS MPEG audio streams plus LPCM audio plus MPEG-4 Part 2/H.264/H.265/VVC video streams, supported MPEG-TS +MPEG audio streams plus AAC LATM/MHAS plus AC-3/E-AC-3/AC-4/DTS/TrueHD audio plus MPEG-2/AV1/AVS3/MPEG-4 Part 2/H.264/H.265/VVC video streams, AAC +ADTS, MP3, AC-3, E-AC-3, AC-4, AMR, AMR-WB, QCP voice audio, DTS core audio, AAC LATM, Dolby +TrueHD, leading-sync MHAS MPEG-H, IAMF, H.263 elementary video, MPEG-2 elementary video, MPEG-4 Part 2 elementary video, +H.264 Annex B, H.265 Annex B, VVC Annex B, raw AV1 OBU, raw AV1 Annex B, IVF-backed AV1, IVF-backed VP8, IVF-backed VP9, +JPEG still images, PNG still images, WAVE/AIFF/AIFC PCM, native FLAC, Ogg-backed FLAC, +Ogg-backed Opus, Ogg-backed Vorbis, Ogg-backed Speex, Ogg-backed Theora, and CAF-backed ALAC. +Broader DTS-family +sample-entry variants remain supported through MP4 track import, and the broader demux-backed +path-only families continue to move over behind the same public shape. +MP4-track merges continue to cover the broader registered sample-entry families because they +preserve encoded sample-entry bytes from the source file, and mixed video/audio/text/subtitle jobs +retain imported handler names and languages on the real MP4 path. The matching sync and async +library entry points use the same `MuxRequest` surface, while the retained lower-level mux helpers +remain available separately when you need staged planning or payload-copy behavior without the +task-level request layer. The public `mp4forge::mux::sample_reader` helpers can also expose stable +text or subtitle track identity when you construct them with companion `MuxTrackConfig` values. + +`divide` currently targets fragmented inputs with up to one video track from AVC, HEVC, Dolby +Vision on HEVC, AV1, VP8, or VP9 and one audio track from MP4A-based audio, Opus, AC-3, +E-AC-3, AC-4, ALAC, DTS-family entries, FLAC, IAMF, MPEG-H, or PCM, including encrypted wrappers +that preserve those original sample-entry formats. Subtitle and text tracks remain unsupported in +the current divide output model. Pass `-validate` when you want the same probe-driven layout +checks without creating any output files. `dump` defaults to the existing human-readable tree view. Pass `-format json` or `-format yaml` for deterministic structured tree export with stable `payload_fields` for supported boxes; `-full` and @@ -124,8 +163,9 @@ per-chunk, bitrate, and IDR aggregation, or use `mp4forge::probe::ProbeOptions` when you need the same control programmatically. > See the [`examples/`](./examples) directory for the crate's low-level and high-level API usage -> patterns, including the feature-gated decrypt example and the Tokio-based async library example -> behind the optional `async` feature. +> patterns, including the feature-gated decrypt example, the feature-gated real-mux and +> mux/sample-reader examples, and the Tokio-based async library example behind the optional +> `async` feature. ## License diff --git a/examples/inspect_mux_boundaries.rs b/examples/inspect_mux_boundaries.rs new file mode 100644 index 0000000..8f36ae5 --- /dev/null +++ b/examples/inspect_mux_boundaries.rs @@ -0,0 +1,29 @@ +#[cfg(feature = "mux")] +fn main() { + use mp4forge::mux::{MuxInterleavePolicy, MuxStagedMediaItem, plan_staged_media_items}; + + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 1024, 4096, 2048).with_sync_sample(true), + MuxStagedMediaItem::new(1, 2, 512, 512, 2048, 1024), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + for item in plan.planned_items() { + println!( + "track {} decode [{}..{}) output [{}..{})", + item.staged().track_id(), + item.staged().decode_time(), + item.decode_end_time(), + item.output_offset(), + item.output_end_offset() + ); + } +} + +#[cfg(not(feature = "mux"))] +fn main() { + eprintln!("enable the `mux` feature to run this example"); +} diff --git a/examples/mux_fragment_duration.rs b/examples/mux_fragment_duration.rs new file mode 100644 index 0000000..0411471 --- /dev/null +++ b/examples/mux_fragment_duration.rs @@ -0,0 +1,38 @@ +#[cfg(feature = "mux")] +#[path = "support/mux_example_support.rs"] +mod mux_example_support; + +#[cfg(feature = "mux")] +use std::error::Error; + +#[cfg(feature = "mux")] +use mp4forge::mux::{ + MuxDurationMode, MuxMp4TrackSelector, MuxOutputLayout, MuxRequest, MuxTrackSpec, mux_to_path, +}; + +#[cfg(feature = "mux")] +fn main() -> Result<(), Box> { + let audio_input = mux_example_support::build_audio_input_file( + "example-fragment-audio", + mux_example_support::fourcc("dash"), + "mp4a", + &[b"one", b"two", b"three"], + ); + let output_path = mux_example_support::write_temp_file("example-fragment-output", "mp4", &[]); + + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 0.05 }); + + mux_to_path(&request, &output_path)?; + println!("wrote {}", output_path.display()); + Ok(()) +} + +#[cfg(not(feature = "mux"))] +fn main() { + eprintln!("Enable the `mux` feature to run this example."); +} diff --git a/examples/mux_raw_tracks.rs b/examples/mux_raw_tracks.rs new file mode 100644 index 0000000..c70d38a --- /dev/null +++ b/examples/mux_raw_tracks.rs @@ -0,0 +1,38 @@ +#[cfg(feature = "mux")] +#[path = "support/mux_example_support.rs"] +mod mux_example_support; + +#[cfg(feature = "mux")] +use std::error::Error; + +#[cfg(feature = "mux")] +use mp4forge::mux::{MuxRequest, MuxTrackSpec, mux_to_path}; + +#[cfg(feature = "mux")] +fn main() -> Result<(), Box> { + let audio_input = mux_example_support::write_test_flac_file("example-raw-audio", b"flac-frame"); + let first_video_frame = mux_example_support::build_test_av1_sequence_header_obu(640, 360); + let second_video_frame = mux_example_support::build_test_av1_sequence_header_obu(640, 360); + let video_input = mux_example_support::write_test_av1_ivf_file( + "example-raw-video", + 640, + 360, + &[0, 1], + &[&first_video_frame, &second_video_frame], + ); + let output_path = mux_example_support::write_temp_file("example-raw-output", "mp4", &[]); + + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&audio_input), + MuxTrackSpec::path(&video_input), + ]); + + mux_to_path(&request, &output_path)?; + println!("wrote {}", output_path.display()); + Ok(()) +} + +#[cfg(not(feature = "mux"))] +fn main() { + eprintln!("Enable the `mux` feature to run this example."); +} diff --git a/examples/mux_segment_duration.rs b/examples/mux_segment_duration.rs new file mode 100644 index 0000000..c3449cf --- /dev/null +++ b/examples/mux_segment_duration.rs @@ -0,0 +1,38 @@ +#[cfg(feature = "mux")] +#[path = "support/mux_example_support.rs"] +mod mux_example_support; + +#[cfg(feature = "mux")] +use std::error::Error; + +#[cfg(feature = "mux")] +use mp4forge::mux::{ + MuxDurationMode, MuxMp4TrackSelector, MuxOutputLayout, MuxRequest, MuxTrackSpec, mux_to_path, +}; + +#[cfg(feature = "mux")] +fn main() -> Result<(), Box> { + let audio_input = mux_example_support::build_audio_input_file( + "example-segment-audio", + mux_example_support::fourcc("dash"), + "mp4a", + &[b"one", b"two", b"three"], + ); + let output_path = mux_example_support::write_temp_file("example-segment-output", "mp4", &[]); + + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Segment { seconds: 0.05 }); + + mux_to_path(&request, &output_path)?; + println!("wrote {}", output_path.display()); + Ok(()) +} + +#[cfg(not(feature = "mux"))] +fn main() { + eprintln!("Enable the `mux` feature to run this example."); +} diff --git a/examples/mux_subtitle_tracks.rs b/examples/mux_subtitle_tracks.rs new file mode 100644 index 0000000..ac8e6a7 --- /dev/null +++ b/examples/mux_subtitle_tracks.rs @@ -0,0 +1,48 @@ +#[cfg(feature = "mux")] +#[path = "support/mux_example_support.rs"] +mod mux_example_support; + +#[cfg(feature = "mux")] +use std::error::Error; + +#[cfg(feature = "mux")] +use mp4forge::mux::{MuxMp4TrackSelector, MuxRequest, MuxTrackSpec, mux_to_path}; + +#[cfg(feature = "mux")] +fn main() -> Result<(), Box> { + let video_input = mux_example_support::build_video_input_file( + "example-subtitle-video", + mux_example_support::fourcc("isom"), + "avc1", + &[b"video"], + ); + let audio_input = mux_example_support::build_audio_input_file_with_timing( + "example-subtitle-audio", + mux_example_support::fourcc("dash"), + "mp4a", + 1_000, + 1_000, + &[b"aud"], + ); + let text_input = mux_example_support::build_text_input_file( + "example-subtitle-text", + mux_example_support::fourcc("mp42"), + ); + let output_path = mux_example_support::write_temp_file("example-subtitle-output", "mp4", &[]); + + let request = MuxRequest::new(vec![ + MuxTrackSpec::mp4(&video_input, MuxMp4TrackSelector::Video), + MuxTrackSpec::mp4(&audio_input, MuxMp4TrackSelector::Audio { occurrence: 1 }), + MuxTrackSpec::mp4(&text_input, MuxMp4TrackSelector::Text { occurrence: 1 }), + MuxTrackSpec::mp4(&text_input, MuxMp4TrackSelector::Text { occurrence: 2 }), + ]); + + mux_to_path(&request, &output_path)?; + println!("wrote {}", output_path.display()); + Ok(()) +} + +#[cfg(not(feature = "mux"))] +fn main() { + eprintln!("Enable the `mux` feature to run this example."); +} diff --git a/examples/mux_tracks.rs b/examples/mux_tracks.rs new file mode 100644 index 0000000..cd8e32c --- /dev/null +++ b/examples/mux_tracks.rs @@ -0,0 +1,42 @@ +#[cfg(feature = "mux")] +#[path = "support/mux_example_support.rs"] +mod mux_example_support; + +#[cfg(feature = "mux")] +use std::error::Error; + +#[cfg(feature = "mux")] +use mp4forge::mux::{MuxMp4TrackSelector, MuxRequest, MuxTrackSpec, mux_to_path}; + +#[cfg(feature = "mux")] +fn main() -> Result<(), Box> { + let audio_input = mux_example_support::build_audio_input_file_with_timing( + "example-mux-audio", + mux_example_support::fourcc("dash"), + "mp4a", + 1_000, + 1_000, + &[b"aud"], + ); + let video_input = mux_example_support::build_video_input_file( + "example-mux-video", + mux_example_support::fourcc("isom"), + "avc1", + &[b"video"], + ); + let output_path = mux_example_support::write_temp_file("example-mux-output", "mp4", &[]); + + let request = MuxRequest::new(vec![ + MuxTrackSpec::mp4(&audio_input, MuxMp4TrackSelector::Audio { occurrence: 1 }), + MuxTrackSpec::mp4(&video_input, MuxMp4TrackSelector::Video), + ]); + + mux_to_path(&request, &output_path)?; + println!("wrote {}", output_path.display()); + Ok(()) +} + +#[cfg(not(feature = "mux"))] +fn main() { + eprintln!("Enable the `mux` feature to run this example."); +} diff --git a/examples/mux_tracks_async.rs b/examples/mux_tracks_async.rs new file mode 100644 index 0000000..46c3d82 --- /dev/null +++ b/examples/mux_tracks_async.rs @@ -0,0 +1,56 @@ +#[cfg(all(feature = "mux", feature = "async"))] +#[path = "support/mux_example_support.rs"] +mod mux_example_support; + +#[cfg(all(feature = "mux", feature = "async"))] +use std::{error::Error, io}; + +#[cfg(all(feature = "mux", feature = "async"))] +use mp4forge::mux::{MuxMp4TrackSelector, MuxRequest, MuxTrackSpec, mux_to_path_async}; + +#[cfg(all(feature = "mux", feature = "async"))] +fn main() -> Result<(), Box> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_stack_size(8 * 1024 * 1024) + .enable_all() + .build()?; + runtime.block_on(async { + tokio::spawn(run()) + .await + .map_err(|error| io::Error::other(format!("async mux example task failed: {error}")))? + }) +} + +#[cfg(all(feature = "mux", feature = "async"))] +async fn run() -> Result<(), Box> { + let audio_input = mux_example_support::build_audio_input_file_with_timing( + "example-async-mux-audio", + mux_example_support::fourcc("dash"), + "mp4a", + 1_000, + 1_000, + &[b"aud"], + ); + let video_input = mux_example_support::build_video_input_file( + "example-async-mux-video", + mux_example_support::fourcc("isom"), + "avc1", + &[b"video"], + ); + let output_path = mux_example_support::write_temp_file("example-async-mux-output", "mp4", &[]); + + let request = MuxRequest::new(vec![ + MuxTrackSpec::mp4(&audio_input, MuxMp4TrackSelector::Audio { occurrence: 1 }), + MuxTrackSpec::mp4(&video_input, MuxMp4TrackSelector::Video), + ]); + + mux_to_path_async(&request, &output_path).await?; + println!("wrote {}", output_path.display()); + Ok(()) +} + +#[cfg(not(all(feature = "mux", feature = "async")))] +fn main() { + eprintln!("Enable the `mux` and `async` features to run this example."); +} diff --git a/examples/plan_mux_payload.rs b/examples/plan_mux_payload.rs new file mode 100644 index 0000000..12eabcf --- /dev/null +++ b/examples/plan_mux_payload.rs @@ -0,0 +1,21 @@ +#[cfg(feature = "mux")] +fn main() { + use mp4forge::mux::{MuxInterleavePolicy, MuxStagedMediaItem, plan_staged_media_items}; + + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 1024, 4096, 2048).with_sync_sample(true), + MuxStagedMediaItem::new(1, 2, 512, 512, 2048, 1024), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + println!("planned {} items", plan.planned_items().len()); + println!("payload bytes: {}", plan.total_payload_size()); +} + +#[cfg(not(feature = "mux"))] +fn main() { + eprintln!("enable the `mux` feature to run this example"); +} diff --git a/examples/read_planned_samples.rs b/examples/read_planned_samples.rs new file mode 100644 index 0000000..78c1b6c --- /dev/null +++ b/examples/read_planned_samples.rs @@ -0,0 +1,37 @@ +#[cfg(feature = "mux")] +fn main() { + use std::io::Cursor; + + use mp4forge::mux::sample_reader::PlannedSampleReader; + use mp4forge::mux::{MuxInterleavePolicy, MuxStagedMediaItem, plan_staged_media_items}; + + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 1024, 4, 5).with_sync_sample(true), + MuxStagedMediaItem::new(1, 2, 512, 512, 4, 4), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut sources = [ + Cursor::new(b"HEADvideoTAIL".to_vec()), + Cursor::new(b"PREMaudPOST".to_vec()), + ]; + let mut reader = PlannedSampleReader::new(&mut sources, &plan); + let mut sample_bytes = Vec::new(); + + while let Some(metadata) = reader.next_sample_into(&mut sample_bytes).unwrap() { + println!( + "track {} at output {} -> {} bytes", + metadata.track_id(), + metadata.output_offset(), + sample_bytes.len() + ); + } +} + +#[cfg(not(feature = "mux"))] +fn main() { + eprintln!("enable the `mux` feature to run this example"); +} diff --git a/examples/read_planned_track_metadata.rs b/examples/read_planned_track_metadata.rs new file mode 100644 index 0000000..957944f --- /dev/null +++ b/examples/read_planned_track_metadata.rs @@ -0,0 +1,39 @@ +#[cfg(feature = "mux")] +fn main() { + use std::io::Cursor; + + use mp4forge::mux::sample_reader::PlannedSampleReader; + use mp4forge::mux::{ + MuxInterleavePolicy, MuxStagedMediaItem, MuxTrackConfig, plan_staged_media_items, + }; + + let plan = plan_staged_media_items( + vec![MuxStagedMediaItem::new(0, 7, 0, 1_000, 4, 4).with_sync_sample(true)], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + let track_configs = [MuxTrackConfig::new_text(7, 1_000, 0, 0, Vec::new()) + .with_language(*b"eng") + .with_handler_name("CaptionTrack")]; + let mut sources = [Cursor::new(b"HEADwvttTAIL".to_vec())]; + let mut reader = + PlannedSampleReader::new_with_track_configs(&mut sources, &plan, &track_configs); + let mut sample_bytes = Vec::new(); + + while let Some(metadata) = reader.next_sample_into(&mut sample_bytes).unwrap() { + let track = metadata.track().unwrap(); + println!( + "track {} {:?} {} at output {} -> {} bytes", + metadata.track_id(), + track.kind(), + std::str::from_utf8(&track.language()).unwrap(), + metadata.output_offset(), + sample_bytes.len() + ); + } +} + +#[cfg(not(feature = "mux"))] +fn main() { + eprintln!("enable the `mux` feature to run this example"); +} diff --git a/examples/rewrite_annex_b_samples.rs b/examples/rewrite_annex_b_samples.rs new file mode 100644 index 0000000..e0e281b --- /dev/null +++ b/examples/rewrite_annex_b_samples.rs @@ -0,0 +1,27 @@ +#[cfg(feature = "mux")] +use mp4forge::boxes::iso14496_12::AVCDecoderConfiguration; +#[cfg(feature = "mux")] +use mp4forge::mux::rewrite::rewrite_avc_sample_to_annex_b; + +#[cfg(feature = "mux")] +fn main() { + let avcc = AVCDecoderConfiguration { + length_size_minus_one: 3, + ..Default::default() + }; + let sample = [ + 0x00, 0x00, 0x00, 0x02, 0x65, 0x88, 0x00, 0x00, 0x00, 0x01, 0x06, + ]; + let rewritten = rewrite_avc_sample_to_annex_b(&sample, &avcc).unwrap(); + + println!( + "rewrote one {}-byte AVC sample into {} Annex B bytes", + sample.len(), + rewritten.len() + ); +} + +#[cfg(not(feature = "mux"))] +fn main() { + eprintln!("Enable the `mux` feature to run this example."); +} diff --git a/examples/support/mux_example_support.rs b/examples/support/mux_example_support.rs new file mode 100644 index 0000000..f4f380b --- /dev/null +++ b/examples/support/mux_example_support.rs @@ -0,0 +1,552 @@ +#![allow(dead_code)] + +use std::io::Write; +use std::ops::Deref; +use std::path::{Path, PathBuf as StdPathBuf}; +use std::sync::Arc; + +use mp4forge::BoxInfo; +use mp4forge::FourCc; +use mp4forge::bitio::BitWriter; +use mp4forge::boxes::iso14496_12::{ + AudioSampleEntry, SampleEntry, VisualSampleEntry, XMLSubtitleSampleEntry, +}; +use mp4forge::boxes::iso14496_30::{WVTTSampleEntry, WebVTTConfigurationBox, WebVTTSourceLabelBox}; +use mp4forge::codec::{CodecBox, marshal}; +use mp4forge::mux::{ + MuxFileConfig, MuxInterleavePolicy, MuxStagedMediaItem, MuxTrackConfig, + plan_staged_media_items, write_mp4_mux_to_path, +}; +use tempfile::{Builder, TempPath}; + +#[derive(Clone)] +pub struct ExampleTempPath { + path: StdPathBuf, + _owner: Arc, +} + +impl ExampleTempPath { + fn from_temp_path(path: TempPath) -> Self { + Self { + path: path.to_path_buf(), + _owner: Arc::new(path), + } + } + + pub fn as_path(&self) -> &Path { + self.path.as_path() + } +} + +impl AsRef for ExampleTempPath { + fn as_ref(&self) -> &Path { + self.as_path() + } +} + +impl Deref for ExampleTempPath { + type Target = Path; + + fn deref(&self) -> &Self::Target { + self.as_path() + } +} + +impl From<&ExampleTempPath> for StdPathBuf { + fn from(path: &ExampleTempPath) -> Self { + path.as_path().to_path_buf() + } +} + +type PathBuf = ExampleTempPath; + +#[derive(Clone, Copy)] +struct TestMuxSample<'a> { + pub bytes: &'a [u8], + pub duration: u32, + pub composition_time_offset: i32, + pub is_sync_sample: bool, +} + +pub fn fourcc(value: &str) -> FourCc { + FourCc::try_from(value).expect("valid fourcc") +} + +pub fn write_temp_file(prefix: &str, extension: &str, data: &[u8]) -> PathBuf { + let mut file = Builder::new() + .prefix(&format!("mp4forge-{prefix}-")) + .suffix(&format!(".{extension}")) + .tempfile() + .expect("create temp example file"); + file.write_all(data).expect("write temp example file"); + ExampleTempPath::from_temp_path(file.into_temp_path()) +} + +pub fn write_test_flac_file(prefix: &str, frame_payload: &[u8]) -> PathBuf { + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"fLaC"); + bytes.push(0x80); + bytes.extend_from_slice(&34_u32.to_be_bytes()[1..]); + bytes.extend_from_slice(&build_flac_streaminfo_block(48_000, 2, 16, 1_024)); + bytes.extend_from_slice(&build_test_flac_frame(frame_payload)); + write_temp_file(prefix, "flac", &bytes) +} + +pub fn write_test_av1_ivf_file( + prefix: &str, + width: u16, + height: u16, + frame_timestamps: &[u64], + frame_payloads: &[&[u8]], +) -> PathBuf { + write_test_ivf_file( + prefix, + *b"AV01", + IvfHeaderFields { + width, + height, + timescale: 1_000, + timestamp_scale: 1, + }, + frame_timestamps, + frame_payloads, + ) +} + +pub fn build_test_av1_sequence_header_obu(width: u16, height: u16) -> Vec { + let mut payload_writer = BitWriter::new(Vec::new()); + write_bits_u64(&mut payload_writer, 0, 3); + payload_writer + .write_bit(true) + .expect("write AV1 still-picture flag"); + payload_writer + .write_bit(true) + .expect("write AV1 reduced-header flag"); + write_bits_u64(&mut payload_writer, 0, 5); + write_bits_u64(&mut payload_writer, 9, 4); + write_bits_u64(&mut payload_writer, 8, 4); + write_bits_u64(&mut payload_writer, u64::from(width.saturating_sub(1)), 10); + write_bits_u64(&mut payload_writer, u64::from(height.saturating_sub(1)), 9); + for _ in 0..11 { + payload_writer + .write_bit(false) + .expect("write AV1 sequence flag"); + } + write_bits_u64(&mut payload_writer, 0, 2); + payload_writer + .write_bit(false) + .expect("write AV1 color-description flag"); + payload_writer + .write_bit(false) + .expect("write AV1 timing-info flag"); + align_bit_writer(&mut payload_writer); + let payload = payload_writer + .into_inner() + .expect("finish AV1 sequence header"); + + let mut obu = Vec::with_capacity(2 + payload.len()); + obu.push(0x0A); + obu.push(u8::try_from(payload.len()).expect("AV1 sequence-header payload fits")); + obu.extend_from_slice(&payload); + obu +} + +fn write_single_track_mp4_input( + prefix: &str, + file_config: &MuxFileConfig, + track_config: MuxTrackConfig, + samples: &[TestMuxSample<'_>], +) -> PathBuf { + let source_bytes = samples + .iter() + .flat_map(|sample| sample.bytes) + .copied() + .collect::>(); + let source_path = write_temp_file(&format!("{prefix}-source"), "bin", &source_bytes); + let output_path = write_temp_file(prefix, "mp4", &[]); + + let mut source_offset = 0_u64; + let mut decode_time = 0_u64; + let staged_items = samples + .iter() + .map(|sample| { + let item = MuxStagedMediaItem::new( + 0, + track_config.track_id(), + decode_time, + sample.duration, + source_offset, + u32::try_from(sample.bytes.len()).expect("sample size fits in u32"), + ) + .with_composition_time_offset(sample.composition_time_offset) + .with_sync_sample(sample.is_sync_sample); + source_offset += u64::try_from(sample.bytes.len()).expect("sample size fits in u64"); + decode_time += u64::from(sample.duration); + item + }) + .collect::>(); + let plan = plan_staged_media_items(staged_items, MuxInterleavePolicy::DecodeTime) + .expect("plan staged media items"); + + write_mp4_mux_to_path( + &[&source_path], + &output_path, + file_config, + &[track_config], + &plan, + ) + .expect("write one-track input mp4"); + output_path +} + +pub fn build_audio_input_file( + prefix: &str, + major_brand: FourCc, + sample_entry_type: &str, + payloads: &[&[u8]], +) -> PathBuf { + build_audio_input_file_with_timing( + prefix, + major_brand, + sample_entry_type, + 48_000, + 1_024, + payloads, + ) +} + +pub fn build_audio_input_file_with_timing( + prefix: &str, + major_brand: FourCc, + sample_entry_type: &str, + track_timescale: u32, + sample_duration: u32, + payloads: &[&[u8]], +) -> PathBuf { + let samples = payloads + .iter() + .copied() + .map(|bytes| TestMuxSample { + bytes, + duration: sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }) + .collect::>(); + write_single_track_mp4_input( + prefix, + &MuxFileConfig::new(track_timescale) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")), + MuxTrackConfig::new_audio( + 1, + track_timescale, + audio_sample_entry_box_with_type(sample_entry_type), + ) + .with_language(*b"eng") + .with_handler_name("ExampleAudioHandler"), + &samples, + ) +} + +#[derive(Clone, Copy)] +struct IvfHeaderFields { + width: u16, + height: u16, + timescale: u32, + timestamp_scale: u32, +} + +fn write_test_ivf_file( + prefix: &str, + codec_fourcc: [u8; 4], + header: IvfHeaderFields, + frame_timestamps: &[u64], + frame_payloads: &[&[u8]], +) -> PathBuf { + assert_eq!(frame_timestamps.len(), frame_payloads.len()); + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"DKIF"); + bytes.extend_from_slice(&0_u16.to_le_bytes()); + bytes.extend_from_slice(&32_u16.to_le_bytes()); + bytes.extend_from_slice(&codec_fourcc); + bytes.extend_from_slice(&header.width.to_le_bytes()); + bytes.extend_from_slice(&header.height.to_le_bytes()); + bytes.extend_from_slice(&header.timescale.to_le_bytes()); + bytes.extend_from_slice(&header.timestamp_scale.to_le_bytes()); + bytes.extend_from_slice( + &u32::try_from(frame_payloads.len()) + .expect("frame count fits") + .to_le_bytes(), + ); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + for (timestamp, payload) in frame_timestamps.iter().zip(frame_payloads.iter()) { + bytes.extend_from_slice( + &u32::try_from(payload.len()) + .expect("frame size fits") + .to_le_bytes(), + ); + bytes.extend_from_slice(×tamp.to_le_bytes()); + bytes.extend_from_slice(payload); + } + write_temp_file(prefix, "ivf", &bytes) +} + +fn build_flac_streaminfo_block( + sample_rate: u32, + channel_count: u8, + bits_per_sample: u8, + total_samples: u64, +) -> [u8; 34] { + let mut block = [0_u8; 34]; + block[0..2].copy_from_slice(&0x0400_u16.to_be_bytes()); + block[2..4].copy_from_slice(&0x0400_u16.to_be_bytes()); + block[10] = u8::try_from((sample_rate >> 12) & 0xFF).expect("rate nibble fits"); + block[11] = u8::try_from((sample_rate >> 4) & 0xFF).expect("rate byte fits"); + block[12] = (u8::try_from(sample_rate & 0x0F).expect("rate low nibble fits") << 4) + | (((channel_count - 1) & 0x07) << 1) + | (((bits_per_sample - 1) >> 4) & 0x01); + block[13] = (((bits_per_sample - 1) & 0x0F) << 4) + | u8::try_from((total_samples >> 32) & 0x0F).expect("sample-count nibble fits"); + block[14] = u8::try_from((total_samples >> 24) & 0xFF).expect("sample-count byte fits"); + block[15] = u8::try_from((total_samples >> 16) & 0xFF).expect("sample-count byte fits"); + block[16] = u8::try_from((total_samples >> 8) & 0xFF).expect("sample-count byte fits"); + block[17] = u8::try_from(total_samples & 0xFF).expect("sample-count byte fits"); + block +} + +fn build_test_flac_frame(seed_payload: &[u8]) -> Vec { + let mut writer = BitWriter::new(Vec::new()); + write_bits_u64(&mut writer, 0x7FFC, 15); + writer.write_bit(false).expect("write FLAC reserved bit"); + write_bits_u64(&mut writer, 10, 4); + write_bits_u64(&mut writer, 0, 4); + write_bits_u64(&mut writer, 1, 4); + write_bits_u64(&mut writer, 4, 3); + writer.write_bit(false).expect("write FLAC sample-size bit"); + write_bits_u64(&mut writer, 0, 8); + align_bit_writer(&mut writer); + let mut frame = writer.into_inner().expect("finish FLAC header"); + frame.push(flac_crc8(&frame)); + + let left_sample = u16::from(*seed_payload.first().unwrap_or(&0x11)); + let right_sample = u16::from(*seed_payload.get(1).unwrap_or(&0x22)); + let mut subframe_writer = BitWriter::new(Vec::new()); + for sample in [left_sample, right_sample] { + subframe_writer + .write_bit(false) + .expect("write FLAC subframe padding"); + write_bits_u64(&mut subframe_writer, 0, 6); + subframe_writer + .write_bit(false) + .expect("write FLAC wasted-bits flag"); + write_bits_u64(&mut subframe_writer, u64::from(sample), 16); + } + align_bit_writer(&mut subframe_writer); + frame.extend_from_slice(&subframe_writer.into_inner().expect("finish FLAC subframes")); + frame.extend_from_slice(&flac_crc16(&frame).to_be_bytes()); + frame +} + +fn write_bits_u64(writer: &mut BitWriter>, value: u64, width: usize) { + writer + .write_bits(&value.to_be_bytes(), width) + .expect("write example FLAC bits"); +} + +fn align_bit_writer(writer: &mut BitWriter>) { + while !writer.is_aligned() { + writer.write_bit(false).expect("write example FLAC padding"); + } +} + +fn flac_crc8(data: &[u8]) -> u8 { + let mut crc = 0_u8; + for byte in data { + crc ^= *byte; + for _ in 0..8 { + crc = if crc & 0x80 != 0 { + (crc << 1) ^ 0x07 + } else { + crc << 1 + }; + } + } + crc +} + +fn flac_crc16(data: &[u8]) -> u16 { + let mut crc = 0_u16; + for byte in data { + crc ^= u16::from(*byte) << 8; + for _ in 0..8 { + crc = if crc & 0x8000 != 0 { + (crc << 1) ^ 0x8005 + } else { + crc << 1 + }; + } + } + crc +} + +pub fn build_video_input_file( + prefix: &str, + major_brand: FourCc, + sample_entry_type: &str, + payloads: &[&[u8]], +) -> PathBuf { + let samples = payloads + .iter() + .copied() + .map(|bytes| TestMuxSample { + bytes, + duration: 1_000, + composition_time_offset: 0, + is_sync_sample: true, + }) + .collect::>(); + write_single_track_mp4_input( + prefix, + &MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")), + MuxTrackConfig::new_video( + 1, + 1_000, + 640, + 360, + video_sample_entry_box_with_type(sample_entry_type), + ) + .with_language(*b"und") + .with_handler_name("ExampleVideoHandler"), + &samples, + ) +} + +pub fn build_text_input_file(prefix: &str, major_brand: FourCc) -> PathBuf { + let first_source = write_temp_file(&format!("{prefix}-source-text"), "txt", b"wvtt"); + let second_source = write_temp_file(&format!("{prefix}-source-subtitle"), "xml", b"stpp"); + let output_path = write_temp_file(prefix, "mp4", &[]); + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 1_000, 0, 4).with_sync_sample(true), + MuxStagedMediaItem::new(1, 2, 0, 1_000, 0, 4).with_sync_sample(true), + ], + MuxInterleavePolicy::DecodeTime, + ) + .expect("plan text/subtitle items"); + let file_config = MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")); + let track_configs = vec![ + MuxTrackConfig::new_text(1, 1_000, 0, 0, wvtt_sample_entry_box()) + .with_language(*b"eng") + .with_handler_name("EnglishCaptionHandler"), + MuxTrackConfig::new_subtitle(2, 1_000, 0, 0, stpp_sample_entry_box()) + .with_language(*b"fra") + .with_handler_name("FrenchSubtitleHandler"), + ]; + + write_mp4_mux_to_path( + &[&first_source, &second_source], + &output_path, + &file_config, + &track_configs, + &plan, + ) + .expect("write mixed text input mp4"); + output_path +} + +fn audio_sample_entry_box_with_type(box_type: &str) -> Vec { + encode_supported_box( + &AudioSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc(box_type), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000_u32 << 16, + ..AudioSampleEntry::default() + }, + &[], + ) +} + +fn video_sample_entry_box_with_type(box_type: &str) -> Vec { + encode_supported_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc(box_type), + data_reference_index: 1, + }, + width: 640, + height: 360, + horizresolution: 72_u32 << 16, + vertresolution: 72_u32 << 16, + frame_count: 1, + depth: 0x0018, + pre_defined3: -1, + ..VisualSampleEntry::default() + }, + &[], + ) +} + +fn wvtt_sample_entry_box() -> Vec { + let children = [ + encode_supported_box( + &WebVTTConfigurationBox { + config: "WEBVTT".to_string(), + }, + &[], + ), + encode_supported_box( + &WebVTTSourceLabelBox { + source_label: "example".to_string(), + }, + &[], + ), + ] + .concat(); + + encode_supported_box( + &WVTTSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("wvtt"), + data_reference_index: 1, + }, + }, + &children, + ) +} + +fn stpp_sample_entry_box() -> Vec { + encode_supported_box( + &XMLSubtitleSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("stpp"), + data_reference_index: 1, + }, + namespace: "http://www.w3.org/ns/ttml".to_string(), + schema_location: String::new(), + auxiliary_mime_types: String::new(), + }, + &[], + ) +} + +fn encode_supported_box(box_value: &B, children: &[u8]) -> Vec +where + B: CodecBox, +{ + let mut payload = Vec::new(); + marshal(&mut payload, box_value, None).expect("encode supported box payload"); + payload.extend_from_slice(children); + let info = BoxInfo::new(box_value.box_type(), 8 + payload.len() as u64); + let mut bytes = info.encode(); + bytes.extend_from_slice(&payload); + bytes +} diff --git a/examples/write_mp4_mux.rs b/examples/write_mp4_mux.rs new file mode 100644 index 0000000..7f07915 --- /dev/null +++ b/examples/write_mp4_mux.rs @@ -0,0 +1,119 @@ +#[cfg(feature = "mux")] +use std::io::Cursor; + +#[cfg(feature = "mux")] +use mp4forge::boxes::iso14496_12::{AudioSampleEntry, SampleEntry, VisualSampleEntry}; +#[cfg(feature = "mux")] +use mp4forge::codec::{CodecBox, marshal}; +#[cfg(feature = "mux")] +use mp4forge::mux::{ + MuxFileConfig, MuxInterleavePolicy, MuxStagedMediaItem, MuxTrackConfig, + plan_staged_media_items, write_mp4_mux, +}; +#[cfg(feature = "mux")] +use mp4forge::{BoxInfo, FourCc}; + +#[cfg(not(feature = "mux"))] +fn main() {} + +#[cfg(feature = "mux")] +fn main() -> Result<(), Box> { + let mut sources = [ + Cursor::new(b"AAAAhelloBBBBxy".to_vec()), + Cursor::new(b"zzzzSYNCtail".to_vec()), + ]; + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 2, 10, 4, 13, 2), + MuxStagedMediaItem::new(1, 1, 0, 5, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 2, 0, 4, 4, 5) + .with_composition_time_offset(2) + .with_sync_sample(true), + ], + MuxInterleavePolicy::DecodeTime, + )?; + let file_config = MuxFileConfig::new(1_000) + .with_major_brand(fourcc("isom")) + .with_compatible_brand(fourcc("mp42")); + let track_configs = vec![ + MuxTrackConfig::new_audio(1, 1_000, audio_sample_entry_box()?), + MuxTrackConfig::new_video(2, 1_000, 640, 360, video_sample_entry_box()?), + ]; + + let mut output = Cursor::new(Vec::new()); + write_mp4_mux( + &mut sources, + &mut output, + &file_config, + &track_configs, + &plan, + )?; + + println!( + "built one MP4 file with {} bytes and {} planned samples", + output.get_ref().len(), + plan.planned_items().len() + ); + Ok(()) +} + +#[cfg(feature = "mux")] +fn audio_sample_entry_box() -> Result, Box> { + encode_typed_box( + &AudioSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000_u32 << 16, + ..AudioSampleEntry::default() + }, + &[], + ) +} + +#[cfg(feature = "mux")] +fn video_sample_entry_box() -> Result, Box> { + encode_typed_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("avc1"), + data_reference_index: 1, + }, + width: 640, + height: 360, + horizresolution: 72_u32 << 16, + vertresolution: 72_u32 << 16, + frame_count: 1, + depth: 0x0018, + pre_defined3: -1, + ..VisualSampleEntry::default() + }, + &[], + ) +} + +#[cfg(feature = "mux")] +fn encode_typed_box( + box_value: &B, + children: &[u8], +) -> Result, Box> +where + B: CodecBox, +{ + let mut payload = Vec::new(); + marshal(&mut payload, box_value, None)?; + payload.extend_from_slice(children); + + let mut bytes = Cursor::new(Vec::new()); + BoxInfo::new(box_value.box_type(), 8 + u64::try_from(payload.len())?).write(&mut bytes)?; + bytes.get_mut().extend_from_slice(&payload); + Ok(bytes.into_inner()) +} + +#[cfg(feature = "mux")] +fn fourcc(value: &str) -> FourCc { + FourCc::try_from(value).expect("valid fourcc literal") +} diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index aba7301..9af0986 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -9,7 +9,9 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4" -mp4forge = { path = ".." } +mp4forge = { path = "..", features = ["async", "decrypt", "mux"] } +tempfile = "3" +tokio = { version = "1.52.1", features = ["io-util", "rt", "rt-multi-thread"] } [[bin]] name = "read_box_structure" @@ -67,4 +69,53 @@ test = false doc = false bench = false +[[bin]] +name = "mux_demux_inputs" +path = "fuzz_targets/mux_demux_inputs.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "mux_sample_rewrites" +path = "fuzz_targets/mux_sample_rewrites.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "sidx_plan_apply" +path = "fuzz_targets/sidx_plan_apply.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "roundtrip_more_boxes" +path = "fuzz_targets/roundtrip_more_boxes.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "bitio_boundaries" +path = "fuzz_targets/bitio_boundaries.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "async_api_parity" +path = "fuzz_targets/async_api_parity.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decrypt_bytes_surfaces" +path = "fuzz_targets/decrypt_bytes_surfaces.rs" +test = false +doc = false +bench = false + [workspace] diff --git a/fuzz/fuzz_targets/async_api_parity.rs b/fuzz/fuzz_targets/async_api_parity.rs new file mode 100644 index 0000000..ea481df --- /dev/null +++ b/fuzz/fuzz_targets/async_api_parity.rs @@ -0,0 +1,100 @@ +#![no_main] + +mod support; + +use std::io::Cursor; +use std::sync::OnceLock; + +use libfuzzer_sys::fuzz_target; +use mp4forge::FourCc; +use mp4forge::boxes::iso14496_12::{Ftyp, Tfdt}; +use mp4forge::extract::{extract_box_async, extract_boxes_async}; +use mp4forge::probe::{ + ProbeOptions, probe_async, probe_detailed_async, probe_fra_async, probe_with_options_async, +}; +use mp4forge::rewrite::rewrite_box_as_bytes_async; +use mp4forge::sidx::{ + TopLevelSidxPlanOptions, analyze_top_level_sidx_update_async, apply_top_level_sidx_plan_async, + plan_top_level_sidx_update_async, +}; +use mp4forge::walk::BoxPath; +use tokio::runtime::{Builder, Runtime}; + +use support::{FuzzInput, seeded_small_mp4_bytes}; + +const FTYP: FourCc = FourCc::from_bytes(*b"ftyp"); +const MOOF: FourCc = FourCc::from_bytes(*b"moof"); +const MOOV: FourCc = FourCc::from_bytes(*b"moov"); +const TFDT: FourCc = FourCc::from_bytes(*b"tfdt"); +const TRAF: FourCc = FourCc::from_bytes(*b"traf"); + +fuzz_target!(|data: &[u8]| { + let mut input = FuzzInput::new(data); + let bytes = seeded_small_mp4_bytes(&mut input); + let options = TopLevelSidxPlanOptions { + add_if_not_exists: input.take_bool(), + non_zero_ept: input.take_bool(), + }; + + runtime().block_on(async { + let _ = probe_async(&mut Cursor::new(bytes.clone())).await; + let _ = probe_with_options_async( + &mut Cursor::new(bytes.clone()), + ProbeOptions { + expand_samples: input.take_bool(), + expand_chunks: input.take_bool(), + include_segments: input.take_bool(), + }, + ) + .await; + let _ = probe_detailed_async(&mut Cursor::new(bytes.clone())).await; + let _ = probe_fra_async(&mut Cursor::new(bytes.clone())).await; + + let path = take_path(&mut input); + let _ = extract_box_async(&mut Cursor::new(bytes.clone()), None, path.clone()).await; + let _ = extract_boxes_async(&mut Cursor::new(bytes.clone()), None, &[path]).await; + + let _ = rewrite_box_as_bytes_async::(&bytes, BoxPath::from([FTYP]), |ftyp| { + ftyp.minor_version ^= input.take_u32(); + }) + .await; + let _ = rewrite_box_as_bytes_async::( + &bytes, + BoxPath::from([MOOF, TRAF, TFDT]), + |tfdt| { + tfdt.base_media_decode_time_v0 ^= input.take_u32(); + tfdt.base_media_decode_time_v1 ^= input.take_u64(); + }, + ) + .await; + + let _ = analyze_top_level_sidx_update_async(&mut Cursor::new(bytes.clone())).await; + if let Ok(Some(plan)) = + plan_top_level_sidx_update_async(&mut Cursor::new(bytes.clone()), options).await + { + let mut output = Cursor::new(Vec::new()); + let _ = + apply_top_level_sidx_plan_async(&mut Cursor::new(bytes), &mut output, &plan).await; + } + }); +}); + +fn runtime() -> &'static Runtime { + static RUNTIME: OnceLock = OnceLock::new(); + RUNTIME.get_or_init(|| { + Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build fuzz Tokio runtime") + }) +} + +fn take_path(input: &mut FuzzInput<'_>) -> BoxPath { + match input.take_u8() % 5 { + 0 => BoxPath::empty(), + 1 => BoxPath::from([FTYP]), + 2 => BoxPath::from([MOOV]), + 3 => BoxPath::from([MOOF, TRAF]), + _ => BoxPath::from([MOOF, TRAF, TFDT]), + } +} diff --git a/fuzz/fuzz_targets/bitio_boundaries.rs b/fuzz/fuzz_targets/bitio_boundaries.rs new file mode 100644 index 0000000..d34c0f4 --- /dev/null +++ b/fuzz/fuzz_targets/bitio_boundaries.rs @@ -0,0 +1,84 @@ +#![no_main] + +mod support; + +use std::io::{Cursor, Read, Seek, SeekFrom, Write}; + +use libfuzzer_sys::fuzz_target; +use mp4forge::bitio::{BitReader, BitWriter}; + +use support::FuzzInput; + +const MAX_BYTES: usize = 512; +const MAX_WIDTH: usize = 128; + +fuzz_target!(|data: &[u8]| { + let mut input = FuzzInput::new(data); + let bytes = input.take_bytes(MAX_BYTES); + + exercise_reader(&mut input, &bytes); + exercise_writer(&mut input); + exercise_write_then_read(&mut input); +}); + +fn exercise_reader(input: &mut FuzzInput<'_>, bytes: &[u8]) { + let mut reader = BitReader::new(Cursor::new(bytes)); + for _ in 0..input.take_usize(32) { + match input.take_u8() % 5 { + 0 => { + let _ = reader.read_bit(); + } + 1 => { + let _ = reader.read_bits(input.take_usize(MAX_WIDTH)); + } + 2 => { + let mut buf = vec![0_u8; input.take_usize(32)]; + let _ = reader.read(&mut buf); + } + 3 => { + let _ = reader.seek(SeekFrom::Start(input.take_u64() % 1024)); + } + _ => { + let offset = i64::from(input.take_u8()) - 128; + let _ = reader.seek(SeekFrom::Current(offset)); + } + } + } +} + +fn exercise_writer(input: &mut FuzzInput<'_>) { + let mut writer = BitWriter::new(Cursor::new(Vec::new())); + for _ in 0..input.take_usize(32) { + match input.take_u8() % 4 { + 0 => { + let _ = writer.write_bit(input.take_bool()); + } + 1 => { + let bits = input.take_bytes(32); + let width = input.take_usize(bits.len().saturating_mul(8).saturating_add(8)); + let _ = writer.write_bits(&bits, width); + } + 2 => { + let bytes = input.take_bytes(32); + let _ = writer.write(&bytes); + } + _ => { + let _ = writer.flush(); + } + } + } + let _ = writer.into_inner(); +} + +fn exercise_write_then_read(input: &mut FuzzInput<'_>) { + let bits = input.take_bytes(32); + let width = input.take_usize(bits.len().saturating_mul(8)); + let mut writer = BitWriter::new(Vec::new()); + if writer.write_bits(&bits, width).is_ok() + && writer.is_aligned() + && let Ok(encoded) = writer.into_inner() + { + let mut reader = BitReader::new(Cursor::new(encoded)); + let _ = reader.read_bits(width); + } +} diff --git a/fuzz/fuzz_targets/decrypt_bytes_surfaces.rs b/fuzz/fuzz_targets/decrypt_bytes_surfaces.rs new file mode 100644 index 0000000..378d806 --- /dev/null +++ b/fuzz/fuzz_targets/decrypt_bytes_surfaces.rs @@ -0,0 +1,141 @@ +#![no_main] + +mod support; + +use libfuzzer_sys::fuzz_target; +use mp4forge::FourCc; +use mp4forge::boxes::iso23001_7::SencSubsample; +use mp4forge::decrypt::{ + DecryptOptions, DecryptionKey, NativeCommonEncryptionScheme, decrypt_bytes, + decrypt_bytes_with_progress, decrypt_common_encryption_file_bytes, + decrypt_common_encryption_init_bytes, decrypt_common_encryption_media_segment_bytes, + decrypt_common_encryption_sample, decrypt_common_encryption_sample_by_scheme_type_with_keys, + decrypt_common_encryption_sample_with_keys, select_decryption_key, +}; +use mp4forge::encryption::{ResolvedSampleEncryptionSample, ResolvedSampleEncryptionSource}; + +use support::{FuzzInput, seeded_small_mp4_bytes}; + +const MAX_SAMPLE_LEN: usize = 8192; + +fuzz_target!(|data: &[u8]| { + let mut input = FuzzInput::new(data); + let bytes = seeded_small_mp4_bytes(&mut input); + let fragments_info = seeded_small_mp4_bytes(&mut input); + let keys = take_keys(&mut input); + let sample_bytes = input.take_bytes(MAX_SAMPLE_LEN); + let sample = take_resolved_sample(&mut input); + let scheme = take_scheme(&mut input); + + let _ = select_decryption_key(&keys, take_track_id(&mut input), &sample); + let _ = decrypt_common_encryption_sample(scheme, input.take_exact(), &sample, &sample_bytes); + let _ = decrypt_common_encryption_sample_with_keys( + scheme, + take_track_id(&mut input), + &keys, + &sample, + &sample_bytes, + ); + let _ = decrypt_common_encryption_sample_by_scheme_type_with_keys( + take_scheme_type(&mut input), + take_track_id(&mut input), + &keys, + &sample, + &sample_bytes, + ); + + let _ = decrypt_common_encryption_init_bytes(&bytes, &keys); + let _ = decrypt_common_encryption_media_segment_bytes(&fragments_info, &bytes, &keys); + let _ = decrypt_common_encryption_file_bytes(&bytes, &keys); + + let mut options = DecryptOptions::new(); + for key in &keys { + options.add_key(*key); + } + if input.take_bool() { + options.set_fragments_info_bytes(&fragments_info); + } + + let _ = decrypt_bytes(&bytes, &options); + let _ = decrypt_bytes_with_progress(&bytes, &options, |_| {}); +}); + +fn take_keys(input: &mut FuzzInput<'_>) -> Vec { + let mut keys = Vec::new(); + for _ in 0..input.take_usize(4) { + let key = input.take_exact(); + if input.take_bool() { + keys.push(DecryptionKey::track(input.take_u32(), key)); + } else { + keys.push(DecryptionKey::kid(input.take_exact(), key)); + } + } + keys +} + +fn take_resolved_sample(input: &mut FuzzInput<'_>) -> ResolvedSampleEncryptionSample<'static> { + let initialization_vector = leak_bytes(input.take_bytes(16)); + let constant_iv = if input.take_bool() { + Some(leak_bytes(input.take_bytes(16))) + } else { + None + }; + let mut subsamples = Vec::new(); + for _ in 0..input.take_usize(4) { + subsamples.push(SencSubsample { + bytes_of_clear_data: input.take_u16(), + bytes_of_protected_data: input.take_u32(), + }); + } + let subsamples = Box::leak(subsamples.into_boxed_slice()); + + ResolvedSampleEncryptionSample { + sample_index: input.take_u32(), + metadata_source: ResolvedSampleEncryptionSource::TrackEncryptionBox, + is_protected: input.take_bool(), + crypt_byte_block: input.take_u8() & 0x0f, + skip_byte_block: input.take_u8() & 0x0f, + per_sample_iv_size: match input.take_u8() % 4 { + 0 => None, + 1 => Some(0), + 2 => Some(8), + _ => Some(16), + }, + initialization_vector, + constant_iv, + kid: input.take_exact(), + subsamples, + auxiliary_info_size: input.take_u32(), + } +} + +fn leak_bytes(bytes: Vec) -> &'static [u8] { + Box::leak(bytes.into_boxed_slice()) +} + +fn take_scheme(input: &mut FuzzInput<'_>) -> NativeCommonEncryptionScheme { + match input.take_u8() % 4 { + 0 => NativeCommonEncryptionScheme::Cenc, + 1 => NativeCommonEncryptionScheme::Cens, + 2 => NativeCommonEncryptionScheme::Cbc1, + _ => NativeCommonEncryptionScheme::Cbcs, + } +} + +fn take_scheme_type(input: &mut FuzzInput<'_>) -> FourCc { + match input.take_u8() % 5 { + 0 => FourCc::from_bytes(*b"cenc"), + 1 => FourCc::from_bytes(*b"cens"), + 2 => FourCc::from_bytes(*b"cbc1"), + 3 => FourCc::from_bytes(*b"cbcs"), + _ => input.take_fourcc(), + } +} + +fn take_track_id(input: &mut FuzzInput<'_>) -> Option { + if input.take_bool() { + Some(input.take_u32()) + } else { + None + } +} diff --git a/fuzz/fuzz_targets/mux_demux_inputs.rs b/fuzz/fuzz_targets/mux_demux_inputs.rs new file mode 100644 index 0000000..5a43acc --- /dev/null +++ b/fuzz/fuzz_targets/mux_demux_inputs.rs @@ -0,0 +1,93 @@ +#![no_main] + +mod support; + +use std::fs; + +use libfuzzer_sys::fuzz_target; +use mp4forge::mux::inspect::{ + DirectIngestReportFormat, collect_packet_report_warnings, collect_track_report_warnings, + inspect_direct_ingest_packets, inspect_direct_ingest_path, write_packet_report, write_report, +}; +use mp4forge::mux::{MuxRequest, MuxTrackSpec, mux_to_path}; +use tempfile::tempdir; + +use support::FuzzInput; + +const MAX_INPUT_LEN: usize = 128 * 1024; + +const SUFFIXES: [&str; 42] = [ + ".aac", ".adts", ".latm", ".mp3", ".ac3", ".ec3", ".ac4", ".dts", ".thd", ".mhas", ".iamf", + ".amr", ".awb", ".h263", ".m4v", ".m2v", ".h264", ".264", ".h265", ".hevc", ".vvc", ".obu", + ".ivf", ".vp8", ".vp9", ".jpg", ".jpeg", ".png", ".bmp", ".j2k", ".y4m", ".prores", ".wav", + ".aiff", ".flac", ".ogg", ".avi", ".ts", ".mpeg", ".mpg", ".qcp", ".caf", +]; + +fuzz_target!(|data: &[u8]| { + if data.is_empty() { + return; + } + + let mut input = FuzzInput::new(data); + let suffix = SUFFIXES[input.take_usize(SUFFIXES.len() - 1)]; + let payload_start = input.take_usize(data.len().saturating_sub(1)); + let payload_end = data.len().min(payload_start.saturating_add(MAX_INPUT_LEN)); + let payload = &data[payload_start..payload_end]; + if payload.is_empty() { + return; + } + + let Ok(dir) = tempdir() else { + return; + }; + let input_path = dir.path().join(format!("input{suffix}")); + if fs::write(&input_path, payload).is_err() { + return; + } + + match input.take_u8() % 4 { + 0 => { + if let Ok(report) = inspect_direct_ingest_path(&input_path) { + let _ = collect_track_report_warnings(&report); + let mut rendered = Vec::new(); + let _ = write_report(&mut rendered, &report, take_report_format(&mut input)); + } + } + 1 => { + if let Ok(report) = inspect_direct_ingest_packets(&input_path) { + let _ = collect_packet_report_warnings(&report); + let mut rendered = Vec::new(); + let _ = write_packet_report( + &mut rendered, + &report, + take_packet_report_format(&mut input), + ); + } + } + 2 => { + let output_path = dir.path().join("muxed.mp4"); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input_path)]); + let _ = mux_to_path(&request, &output_path); + } + _ => { + let _ = inspect_direct_ingest_path(&input_path); + let _ = inspect_direct_ingest_packets(&input_path); + } + } +}); + +fn take_report_format(input: &mut FuzzInput<'_>) -> DirectIngestReportFormat { + match input.take_u8() % 3 { + 0 => DirectIngestReportFormat::Json, + 1 => DirectIngestReportFormat::Yaml, + _ => DirectIngestReportFormat::Nhml, + } +} + +fn take_packet_report_format(input: &mut FuzzInput<'_>) -> DirectIngestReportFormat { + match input.take_u8() % 3 { + 0 => DirectIngestReportFormat::Json, + 1 => DirectIngestReportFormat::Yaml, + _ => DirectIngestReportFormat::Nhnt, + } +} diff --git a/fuzz/fuzz_targets/mux_sample_rewrites.rs b/fuzz/fuzz_targets/mux_sample_rewrites.rs new file mode 100644 index 0000000..41dac84 --- /dev/null +++ b/fuzz/fuzz_targets/mux_sample_rewrites.rs @@ -0,0 +1,66 @@ +#![no_main] + +mod support; + +use libfuzzer_sys::fuzz_target; +use mp4forge::boxes::iso14496_12::{AVCDecoderConfiguration, HEVCDecoderConfiguration}; +use mp4forge::boxes::iso14496_15::VVCDecoderConfiguration; +use mp4forge::mux::rewrite::{ + rewrite_aac_sample_to_adts, rewrite_av1_sample_to_annex_b, rewrite_avc_sample_to_annex_b, + rewrite_hevc_sample_to_annex_b, rewrite_mhas_samples_to_stream, rewrite_vvc_sample_to_annex_b, +}; + +use support::FuzzInput; + +const MAX_SAMPLE_LEN: usize = 16 * 1024; +const MAX_CONFIG_LEN: usize = 256; +const MAX_MHAS_SAMPLE_LEN: usize = 4096; + +fuzz_target!(|data: &[u8]| { + let mut input = FuzzInput::new(data); + let sample = input.take_bytes(MAX_SAMPLE_LEN); + + match input.take_u8() % 6 { + 0 => { + let avcc = AVCDecoderConfiguration { + length_size_minus_one: input.take_u8() & 0x07, + ..Default::default() + }; + let _ = rewrite_avc_sample_to_annex_b(&sample, &avcc); + } + 1 => { + let hvcc = HEVCDecoderConfiguration { + length_size_minus_one: input.take_u8() & 0x07, + ..Default::default() + }; + let _ = rewrite_hevc_sample_to_annex_b(&sample, &hvcc); + } + 2 => { + let vvcc = VVCDecoderConfiguration { + decoder_configuration_record: input.take_bytes(MAX_CONFIG_LEN), + ..Default::default() + }; + let _ = rewrite_vvc_sample_to_annex_b(&sample, &vvcc); + } + 3 => { + let _ = rewrite_av1_sample_to_annex_b(&sample); + } + 4 => { + let audio_specific_config = input.take_bytes(MAX_CONFIG_LEN); + let _ = rewrite_aac_sample_to_adts(&sample, &audio_specific_config); + } + _ => { + let samples = take_mhas_samples(&mut input, sample); + let refs = samples.iter().map(Vec::as_slice).collect::>(); + let _ = rewrite_mhas_samples_to_stream(&refs); + } + } +}); + +fn take_mhas_samples(input: &mut FuzzInput<'_>, first_sample: Vec) -> Vec> { + let mut samples = vec![first_sample]; + for _ in 0..input.take_usize(3) { + samples.push(input.take_bytes(MAX_MHAS_SAMPLE_LEN)); + } + samples +} diff --git a/fuzz/fuzz_targets/read_box_structure.rs b/fuzz/fuzz_targets/read_box_structure.rs index bd171b8..fbae57f 100644 --- a/fuzz/fuzz_targets/read_box_structure.rs +++ b/fuzz/fuzz_targets/read_box_structure.rs @@ -3,15 +3,33 @@ use std::io::Cursor; use libfuzzer_sys::fuzz_target; -use mp4forge::walk::{WalkControl, walk_structure}; +use mp4forge::walk::{WalkControl, WalkError, walk_structure}; + +const MAX_VISITED_BOXES: usize = 1024; +const MAX_DECODED_PAYLOAD: u64 = 1024; fuzz_target!(|data: &[u8]| { let mut reader = Cursor::new(data); + let input_len = data.len() as u64; + let mut visited = 0usize; + let _ = walk_structure(&mut reader, |handle| { + visited += 1; + if visited > MAX_VISITED_BOXES { + return Err(WalkError::UnexpectedEof); + } + if !handle.is_supported_type() { return Ok(WalkControl::Continue); } + let Ok(payload_size) = handle.info().payload_size() else { + return Ok(WalkControl::Continue); + }; + if handle.info().size() > input_len || payload_size > MAX_DECODED_PAYLOAD { + return Ok(WalkControl::Continue); + } + if handle.read_payload().is_ok() { Ok(WalkControl::Descend) } else { diff --git a/fuzz/fuzz_targets/roundtrip_more_boxes.rs b/fuzz/fuzz_targets/roundtrip_more_boxes.rs new file mode 100644 index 0000000..bc61f3e --- /dev/null +++ b/fuzz/fuzz_targets/roundtrip_more_boxes.rs @@ -0,0 +1,101 @@ +#![no_main] + +mod support; + +use std::io::Cursor; + +use libfuzzer_sys::fuzz_target; +use mp4forge::boxes::etsi_ts_102_366::{Dac3, Dec3}; +use mp4forge::boxes::etsi_ts_103_190::Dac4; +use mp4forge::boxes::iso14496_12::{ + AVCDecoderConfiguration, Btrt, Clap, CoLL, Colr, Frma, HEVCDecoderConfiguration, Hdlr, Mdhd, + Mvhd, Pasp, Saio, Saiz, Sbgp, Schm, Sgpd, Sidx, Tfdt, Tfhd, Tkhd, Trex, Trun, +}; +use mp4forge::boxes::iso14496_14::Esds; +use mp4forge::boxes::iso14496_15::VVCDecoderConfiguration; +use mp4forge::boxes::iso23001_7::Tenc; +use mp4forge::boxes::opus::DOps; +use mp4forge::codec::{CodecBox, marshal, unmarshal}; + +use support::FuzzInput; + +const MAX_PAYLOAD_LEN: usize = 4096; + +fuzz_target!(|data: &[u8]| { + let mut input = FuzzInput::new(data); + let payload = input.take_bytes(MAX_PAYLOAD_LEN); + + match input.take_u8() % 30 { + 0 => decode_then_encode::(&payload), + 1 => decode_then_encode::(&payload), + 2 => decode_then_encode::(&payload), + 3 => decode_then_encode::(&payload), + 4 => decode_then_encode::(&payload), + 5 => decode_then_encode::(&payload), + 6 => decode_then_encode::(&payload), + 7 => decode_then_encode::(&payload), + 8 => decode_then_encode::(&payload), + 9 => decode_then_encode::(&payload), + 10 => decode_then_encode::(&payload), + 11 => decode_then_encode::(&payload), + 12 => decode_then_encode::(&payload), + 13 => decode_then_encode::(&payload), + 14 => decode_then_encode::(&payload), + 15 => decode_then_encode::(&payload), + 16 => decode_then_encode::(&payload), + 17 => decode_then_encode::(&payload), + 18 => decode_then_encode::(&payload), + 19 => decode_then_encode::(&payload), + 20 => decode_then_encode::(&payload), + 21 => decode_then_encode::(&payload), + 22 => decode_then_encode::(&payload), + 23 => decode_then_encode::(&payload), + 24 => decode_then_encode::(&payload), + 25 => decode_then_encode::(&payload), + 26 => decode_then_encode::(&payload), + 27 => decode_then_encode::(&payload), + 28 => decode_then_encode::(&payload), + _ => decode_all_defaults(), + } +}); + +fn decode_then_encode(payload: &[u8]) +where + B: CodecBox + Default, +{ + let mut decoded = B::default(); + if let Ok(read) = unmarshal( + &mut Cursor::new(payload), + payload.len() as u64, + &mut decoded, + None, + ) { + assert!(read <= payload.len() as u64); + let mut encoded = Vec::new(); + let _ = marshal(&mut encoded, &decoded, None); + } +} + +fn decode_all_defaults() { + encode_default::(); + encode_default::(); + encode_default::(); + encode_default::(); + encode_default::(); + encode_default::(); + encode_default::(); + encode_default::(); + encode_default::(); + encode_default::(); + encode_default::(); + encode_default::(); + encode_default::(); +} + +fn encode_default() +where + B: CodecBox + Default, +{ + let mut encoded = Vec::new(); + let _ = marshal(&mut encoded, &B::default(), None); +} diff --git a/fuzz/fuzz_targets/sidx_plan_apply.rs b/fuzz/fuzz_targets/sidx_plan_apply.rs new file mode 100644 index 0000000..463f861 --- /dev/null +++ b/fuzz/fuzz_targets/sidx_plan_apply.rs @@ -0,0 +1,39 @@ +#![no_main] + +mod support; + +use std::io::Cursor; + +use libfuzzer_sys::fuzz_target; +use mp4forge::probe::{ProbeOptions, probe_with_options}; +use mp4forge::sidx::{ + TopLevelSidxPlanOptions, analyze_top_level_sidx_update_bytes, apply_top_level_sidx_plan_bytes, + plan_top_level_sidx_update_bytes, +}; +use mp4forge::walk::{WalkControl, walk_structure}; + +use support::{FuzzInput, seeded_any_mp4_bytes}; + +fuzz_target!(|data: &[u8]| { + let mut input = FuzzInput::new(data); + let bytes = seeded_any_mp4_bytes(&mut input); + let options = TopLevelSidxPlanOptions { + add_if_not_exists: input.take_bool(), + non_zero_ept: input.take_bool(), + }; + + let _ = analyze_top_level_sidx_update_bytes(&bytes); + if let Ok(Some(plan)) = plan_top_level_sidx_update_bytes(&bytes, options) + && let Ok(rewritten) = apply_top_level_sidx_plan_bytes(&bytes, &plan) + { + let _ = analyze_top_level_sidx_update_bytes(&rewritten); + let _ = probe_with_options(&mut Cursor::new(&rewritten), ProbeOptions::lightweight()); + let _ = walk_structure(&mut Cursor::new(&rewritten), |handle| { + Ok(if handle.is_supported_type() { + WalkControl::Descend + } else { + WalkControl::Continue + }) + }); + } +}); diff --git a/src/async_io.rs b/src/async_io.rs index 946b106..22a5cc1 100644 --- a/src/async_io.rs +++ b/src/async_io.rs @@ -2,7 +2,9 @@ //! //! The existing sync APIs remain the default path in `mp4forge`. The first async rollout is //! intentionally limited to seekable library readers and writers such as Tokio file handles or -//! in-memory buffers. The CLI continues to use the sync surface. +//! in-memory buffers. Later queue-backed follow-ons can also use the forward-only async reader +//! and writer aliases in this module when a surface can operate progressively without seeks. The +//! CLI continues to use the sync surface. /// Tokio async read trait used by the library-side async surface. pub use tokio::io::AsyncRead; @@ -11,6 +13,23 @@ pub use tokio::io::AsyncSeek; /// Tokio async write trait used by the library-side async surface. pub use tokio::io::AsyncWrite; +/// Async reader alias for forward-only library inputs. +/// +/// Queue-backed progressive flows can use this bound when they only need incremental reads and do +/// not require random-access seeks. The alias still requires `Send` so callers can move +/// independent I/O jobs onto Tokio worker threads safely. +pub trait AsyncReadForward: AsyncRead + Unpin + Send {} + +impl AsyncReadForward for T where T: AsyncRead + Unpin + Send {} + +/// Async writer alias for forward-only library outputs. +/// +/// This alias covers additive async write surfaces that can emit bytes progressively without +/// later header backfill seeks, while still requiring `Send` for multithreaded Tokio tasks. +pub trait AsyncWriteForward: AsyncWrite + Unpin + Send {} + +impl AsyncWriteForward for T where T: AsyncWrite + Unpin + Send {} + /// Async reader alias for seekable library inputs. /// /// The first async rollout targets inputs that support both asynchronous reads and random-access diff --git a/src/boxes/dolby.rs b/src/boxes/dolby.rs new file mode 100644 index 0000000..d859e51 --- /dev/null +++ b/src/boxes/dolby.rs @@ -0,0 +1,189 @@ +//! Dolby audio sample-entry child box definitions. + +use std::io::{Cursor, Write}; + +#[cfg(feature = "async")] +use crate::async_io::{AsyncReadSeek, AsyncWriteSeek}; +use crate::bitio::{BitReader, BitWriter}; +use crate::boxes::BoxRegistry; +use crate::boxes::iso14496_12::AudioSampleEntry; +#[cfg(feature = "async")] +use crate::codec::CodecFuture; +use crate::codec::{ + CodecBox, CodecError, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, + FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, +}; +use crate::{FourCc, codec_field}; + +fn missing_field(field_name: &'static str) -> FieldValueError { + FieldValueError::MissingField { field_name } +} + +fn unexpected_field(field_name: &'static str, value: FieldValue) -> FieldValueError { + FieldValueError::UnexpectedType { + field_name, + expected: "matching codec field value", + actual: value.kind_name(), + } +} + +fn invalid_value(field_name: &'static str, reason: &'static str) -> FieldValueError { + FieldValueError::InvalidValue { field_name, reason } +} + +fn u16_from_unsigned(field_name: &'static str, value: u64) -> Result { + u16::try_from(value).map_err(|_| invalid_value(field_name, "value does not fit in u16")) +} + +fn u32_from_unsigned(field_name: &'static str, value: u64) -> Result { + u32::try_from(value).map_err(|_| invalid_value(field_name, "value does not fit in u32")) +} + +fn read_bits_u16( + reader: &mut BitReader>, + width: usize, + field_name: &'static str, +) -> Result { + let bits = reader.read_bits(width)?; + let mut value = 0_u16; + for byte in bits { + value = (value << 8) | u16::from(byte); + } + let mask = if width == 16 { + u16::MAX + } else { + (1_u16 << width) - 1 + }; + if value > mask { + return Err( + invalid_value(field_name, "value does not fit in the declared bit width").into(), + ); + } + Ok(value & mask) +} + +/// Dolby TrueHD configuration box carried by `mlpa` sample entries. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Dmlp { + /// Packed TrueHD stream-format flags copied from the decoder configuration record. + pub format_info: u32, + /// Fifteen-bit peak-data-rate field from the decoder configuration record. + pub peak_data_rate: u16, +} + +impl FieldHooks for Dmlp {} + +impl ImmutableBox for Dmlp { + fn box_type(&self) -> FourCc { + FourCc::from_bytes(*b"dmlp") + } +} + +impl MutableBox for Dmlp {} + +impl FieldValueRead for Dmlp { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "FormatInfo" => Ok(FieldValue::Unsigned(u64::from(self.format_info))), + "PeakDataRate" => Ok(FieldValue::Unsigned(u64::from(self.peak_data_rate))), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Dmlp { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("FormatInfo", FieldValue::Unsigned(value)) => { + self.format_info = u32_from_unsigned(field_name, value)?; + Ok(()) + } + ("PeakDataRate", FieldValue::Unsigned(value)) => { + self.peak_data_rate = u16_from_unsigned(field_name, value)?; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Dmlp { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("FormatInfo", 0, with_bit_width(32)), + codec_field!("PeakDataRate", 1, with_bit_width(15)), + ]); + + fn custom_marshal(&self, writer: &mut dyn Write) -> Result, CodecError> { + if self.peak_data_rate > 0x7FFF { + return Err(invalid_value("PeakDataRate", "value does not fit in 15 bits").into()); + } + writer.write_all(&self.format_info.to_be_bytes())?; + let mut bit_writer = BitWriter::new(Vec::new()); + bit_writer.write_bit(false)?; + bit_writer.write_bits(&self.peak_data_rate.to_be_bytes(), 15)?; + let rate_bits = bit_writer.into_inner()?; + writer.write_all(&rate_bits)?; + writer.write_all(&[0, 0, 0, 0])?; + Ok(Some(10)) + } + + fn custom_unmarshal( + &mut self, + reader: &mut dyn ReadSeek, + payload_size: u64, + ) -> Result, CodecError> { + if payload_size != 10 { + return Err(invalid_value("Dmlp", "payload size must be exactly 10 bytes").into()); + } + let mut payload = [0_u8; 10]; + std::io::Read::read_exact(reader, &mut payload)?; + self.format_info = u32::from_be_bytes(payload[..4].try_into().unwrap()); + let mut bit_reader = BitReader::new(Cursor::new(&payload[4..6])); + let _reserved = bit_reader.read_bit()?; + self.peak_data_rate = read_bits_u16(&mut bit_reader, 15, "PeakDataRate")?; + Ok(Some(10)) + } + + #[cfg(feature = "async")] + fn custom_marshal_async<'a>( + &'a self, + writer: &'a mut dyn AsyncWriteSeek, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + let mut bytes = Vec::new(); + let written = self.custom_marshal(&mut bytes)?.unwrap_or(0); + tokio::io::AsyncWriteExt::write_all(writer, &bytes).await?; + Ok(Some(written)) + }) + } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size != 10 { + return Err(invalid_value("Dmlp", "payload size must be exactly 10 bytes").into()); + } + let mut payload = [0_u8; 10]; + tokio::io::AsyncReadExt::read_exact(reader, &mut payload).await?; + self.format_info = u32::from_be_bytes(payload[..4].try_into().unwrap()); + let mut bit_reader = BitReader::new(Cursor::new(&payload[4..6])); + let _reserved = bit_reader.read_bit()?; + self.peak_data_rate = read_bits_u16(&mut bit_reader, 15, "PeakDataRate")?; + Ok(Some(10)) + }) + } +} + +/// Registers the currently implemented Dolby audio boxes in `registry`. +pub fn register_boxes(registry: &mut BoxRegistry) { + registry.register_any::(FourCc::from_bytes(*b"mlpa")); + registry.register::(FourCc::from_bytes(*b"dmlp")); +} diff --git a/src/boxes/dts.rs b/src/boxes/dts.rs new file mode 100644 index 0000000..f06518d --- /dev/null +++ b/src/boxes/dts.rs @@ -0,0 +1,667 @@ +//! DTS sample-entry child box definitions. + +use std::io::{Cursor, Write}; + +#[cfg(feature = "async")] +use crate::async_io::{AsyncReadSeek, AsyncWriteSeek}; +#[cfg(feature = "async")] +use crate::bitio::AsyncBitReader; +use crate::bitio::{BitReader, BitWriter}; +#[cfg(feature = "async")] +use crate::codec::CodecFuture; +use crate::codec::{ + CodecBox, CodecError, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, + FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, +}; +use crate::{FourCc, codec_field}; + +fn missing_field(field_name: &'static str) -> FieldValueError { + FieldValueError::MissingField { field_name } +} + +fn unexpected_field(field_name: &'static str, value: FieldValue) -> FieldValueError { + FieldValueError::UnexpectedType { + field_name, + expected: "matching codec field value", + actual: value.kind_name(), + } +} + +fn invalid_value(field_name: &'static str, reason: &'static str) -> FieldValueError { + FieldValueError::InvalidValue { field_name, reason } +} + +fn u8_from_unsigned(field_name: &'static str, value: u64) -> Result { + u8::try_from(value).map_err(|_| invalid_value(field_name, "value does not fit in u8")) +} + +fn u16_from_unsigned(field_name: &'static str, value: u64) -> Result { + u16::try_from(value).map_err(|_| invalid_value(field_name, "value does not fit in u16")) +} + +fn u32_from_unsigned(field_name: &'static str, value: u64) -> Result { + u32::try_from(value).map_err(|_| invalid_value(field_name, "value does not fit in u32")) +} + +fn read_bits_u8( + reader: &mut BitReader>, + width: usize, + _field_name: &'static str, +) -> Result { + Ok(reader.read_bits(width)?[0]) +} + +#[cfg(feature = "async")] +async fn read_bits_u8_async( + reader: &mut AsyncBitReader, + width: usize, + field_name: &'static str, +) -> Result +where + R: crate::async_io::AsyncRead + Unpin, +{ + let bits = reader.read_bits(width).await?; + bits.first() + .copied() + .ok_or_else(|| invalid_value(field_name, "bit field did not yield any bytes").into()) +} + +/// DTS core-specific configuration box carried by DTS sample entries. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Ddts { + /// Decoder sample rate. + pub sampling_frequency: u32, + /// Declared maximum bitrate. + pub max_bitrate: u32, + /// Declared average bitrate. + pub avg_bitrate: u32, + /// Source sample depth in bits. + pub sample_depth: u8, + /// Two-bit DTS frame-duration code. + pub frame_duration: u8, + /// Five-bit DTS stream-construction code. + pub stream_construction: u8, + /// Whether the core stream carries an LFE channel. + pub core_lfe_present: bool, + /// Six-bit DTS core-layout code. + pub core_layout: u8, + /// Fourteen-bit DTS core-size value. + pub core_size: u16, + /// Whether stereo downmix is signaled. + pub stereo_downmix: bool, + /// Three-bit representation-type code. + pub representation_type: u8, + /// Sixteen-bit channel-layout mask. + pub channel_layout: u16, + /// Whether the stream is flagged as a multi-asset presentation. + pub multi_asset_flag: bool, + /// Whether low-bitrate duration modulation is present. + pub lbr_duration_mod: bool, +} + +impl FieldHooks for Ddts {} + +impl ImmutableBox for Ddts { + fn box_type(&self) -> FourCc { + FourCc::from_bytes(*b"ddts") + } +} + +impl MutableBox for Ddts {} + +impl FieldValueRead for Ddts { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "SamplingFrequency" => Ok(FieldValue::Unsigned(u64::from(self.sampling_frequency))), + "MaxBitrate" => Ok(FieldValue::Unsigned(u64::from(self.max_bitrate))), + "AvgBitrate" => Ok(FieldValue::Unsigned(u64::from(self.avg_bitrate))), + "SampleDepth" => Ok(FieldValue::Unsigned(u64::from(self.sample_depth))), + "FrameDuration" => Ok(FieldValue::Unsigned(u64::from(self.frame_duration))), + "StreamConstruction" => Ok(FieldValue::Unsigned(u64::from(self.stream_construction))), + "CoreLFEPresent" => Ok(FieldValue::Boolean(self.core_lfe_present)), + "CoreLayout" => Ok(FieldValue::Unsigned(u64::from(self.core_layout))), + "CoreSize" => Ok(FieldValue::Unsigned(u64::from(self.core_size))), + "StereoDownmix" => Ok(FieldValue::Boolean(self.stereo_downmix)), + "RepresentationType" => Ok(FieldValue::Unsigned(u64::from(self.representation_type))), + "ChannelLayout" => Ok(FieldValue::Unsigned(u64::from(self.channel_layout))), + "MultiAssetFlag" => Ok(FieldValue::Boolean(self.multi_asset_flag)), + "LbrDurationMod" => Ok(FieldValue::Boolean(self.lbr_duration_mod)), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Ddts { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("SamplingFrequency", FieldValue::Unsigned(value)) => { + self.sampling_frequency = u32_from_unsigned(field_name, value)?; + Ok(()) + } + ("MaxBitrate", FieldValue::Unsigned(value)) => { + self.max_bitrate = u32_from_unsigned(field_name, value)?; + Ok(()) + } + ("AvgBitrate", FieldValue::Unsigned(value)) => { + self.avg_bitrate = u32_from_unsigned(field_name, value)?; + Ok(()) + } + ("SampleDepth", FieldValue::Unsigned(value)) => { + self.sample_depth = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("FrameDuration", FieldValue::Unsigned(value)) => { + self.frame_duration = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("StreamConstruction", FieldValue::Unsigned(value)) => { + self.stream_construction = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("CoreLFEPresent", FieldValue::Boolean(value)) => { + self.core_lfe_present = value; + Ok(()) + } + ("CoreLayout", FieldValue::Unsigned(value)) => { + self.core_layout = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("CoreSize", FieldValue::Unsigned(value)) => { + self.core_size = u16_from_unsigned(field_name, value)?; + Ok(()) + } + ("StereoDownmix", FieldValue::Boolean(value)) => { + self.stereo_downmix = value; + Ok(()) + } + ("RepresentationType", FieldValue::Unsigned(value)) => { + self.representation_type = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("ChannelLayout", FieldValue::Unsigned(value)) => { + self.channel_layout = u16_from_unsigned(field_name, value)?; + Ok(()) + } + ("MultiAssetFlag", FieldValue::Boolean(value)) => { + self.multi_asset_flag = value; + Ok(()) + } + ("LbrDurationMod", FieldValue::Boolean(value)) => { + self.lbr_duration_mod = value; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Ddts { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("SamplingFrequency", 0, with_bit_width(32)), + codec_field!("MaxBitrate", 1, with_bit_width(32)), + codec_field!("AvgBitrate", 2, with_bit_width(32)), + codec_field!("SampleDepth", 3, with_bit_width(8)), + codec_field!("FrameDuration", 4, with_bit_width(2)), + codec_field!("StreamConstruction", 5, with_bit_width(5)), + codec_field!("CoreLFEPresent", 6, with_bit_width(1), as_boolean()), + codec_field!("CoreLayout", 7, with_bit_width(6)), + codec_field!("CoreSize", 8, with_bit_width(14)), + codec_field!("StereoDownmix", 9, with_bit_width(1), as_boolean()), + codec_field!("RepresentationType", 10, with_bit_width(3)), + codec_field!("ChannelLayout", 11, with_bit_width(16)), + codec_field!("MultiAssetFlag", 12, with_bit_width(1), as_boolean()), + codec_field!("LbrDurationMod", 13, with_bit_width(1), as_boolean()), + ]); + + fn custom_marshal(&self, writer: &mut dyn Write) -> Result, CodecError> { + if self.frame_duration > 0x03 { + return Err(invalid_value("FrameDuration", "value does not fit in 2 bits").into()); + } + if self.stream_construction > 0x1f { + return Err(invalid_value("StreamConstruction", "value does not fit in 5 bits").into()); + } + if self.core_layout > 0x3f { + return Err(invalid_value("CoreLayout", "value does not fit in 6 bits").into()); + } + if self.core_size > 0x3fff { + return Err(invalid_value("CoreSize", "value does not fit in 14 bits").into()); + } + if self.representation_type > 0x07 { + return Err(invalid_value("RepresentationType", "value does not fit in 3 bits").into()); + } + + writer.write_all(&self.sampling_frequency.to_be_bytes())?; + writer.write_all(&self.max_bitrate.to_be_bytes())?; + writer.write_all(&self.avg_bitrate.to_be_bytes())?; + writer.write_all(&[self.sample_depth])?; + let mut bit_writer = BitWriter::new(Vec::new()); + bit_writer.write_bits(&[self.frame_duration], 2)?; + bit_writer.write_bits(&[self.stream_construction], 5)?; + bit_writer.write_bit(self.core_lfe_present)?; + bit_writer.write_bits(&[self.core_layout], 6)?; + bit_writer.write_bits(&self.core_size.to_be_bytes(), 14)?; + bit_writer.write_bit(self.stereo_downmix)?; + bit_writer.write_bits(&[self.representation_type], 3)?; + bit_writer.write_bits(&self.channel_layout.to_be_bytes(), 16)?; + bit_writer.write_bit(self.multi_asset_flag)?; + bit_writer.write_bit(self.lbr_duration_mod)?; + bit_writer.write_bits(&[0u8], 6)?; + let bits = bit_writer.into_inner()?; + writer.write_all(&bits)?; + Ok(Some(20)) + } + + fn custom_unmarshal( + &mut self, + reader: &mut dyn ReadSeek, + payload_size: u64, + ) -> Result, CodecError> { + if payload_size != 20 { + return Err(invalid_value("Ddts", "payload size must be exactly 20 bytes").into()); + } + let mut fixed = [0u8; 20]; + std::io::Read::read_exact(reader, &mut fixed)?; + self.sampling_frequency = u32::from_be_bytes(fixed[0..4].try_into().unwrap()); + self.max_bitrate = u32::from_be_bytes(fixed[4..8].try_into().unwrap()); + self.avg_bitrate = u32::from_be_bytes(fixed[8..12].try_into().unwrap()); + self.sample_depth = fixed[12]; + let mut bit_reader = BitReader::new(Cursor::new(&fixed[13..20])); + self.frame_duration = read_bits_u8(&mut bit_reader, 2, "FrameDuration")?; + self.stream_construction = read_bits_u8(&mut bit_reader, 5, "StreamConstruction")?; + self.core_lfe_present = bit_reader.read_bit()?; + self.core_layout = read_bits_u8(&mut bit_reader, 6, "CoreLayout")?; + self.core_size = u16::from_be_bytes({ + let bits = bit_reader.read_bits(14)?; + [bits[0], bits[1]] + }) & 0x3fff; + self.stereo_downmix = bit_reader.read_bit()?; + self.representation_type = read_bits_u8(&mut bit_reader, 3, "RepresentationType")?; + self.channel_layout = u16::from_be_bytes(bit_reader.read_bits(16)?.try_into().unwrap()); + self.multi_asset_flag = bit_reader.read_bit()?; + self.lbr_duration_mod = bit_reader.read_bit()?; + let _ = bit_reader.read_bits(6)?; + Ok(Some(20)) + } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size != 20 { + return Err(invalid_value("Ddts", "payload size must be exactly 20 bytes").into()); + } + let mut fixed = [0u8; 20]; + tokio::io::AsyncReadExt::read_exact(reader, &mut fixed).await?; + self.sampling_frequency = u32::from_be_bytes(fixed[0..4].try_into().unwrap()); + self.max_bitrate = u32::from_be_bytes(fixed[4..8].try_into().unwrap()); + self.avg_bitrate = u32::from_be_bytes(fixed[8..12].try_into().unwrap()); + self.sample_depth = fixed[12]; + let mut bit_reader = BitReader::new(Cursor::new(&fixed[13..20])); + self.frame_duration = read_bits_u8(&mut bit_reader, 2, "FrameDuration")?; + self.stream_construction = read_bits_u8(&mut bit_reader, 5, "StreamConstruction")?; + self.core_lfe_present = bit_reader.read_bit()?; + self.core_layout = read_bits_u8(&mut bit_reader, 6, "CoreLayout")?; + self.core_size = u16::from_be_bytes({ + let bits = bit_reader.read_bits(14)?; + [bits[0], bits[1]] + }) & 0x3fff; + self.stereo_downmix = bit_reader.read_bit()?; + self.representation_type = read_bits_u8(&mut bit_reader, 3, "RepresentationType")?; + self.channel_layout = u16::from_be_bytes(bit_reader.read_bits(16)?.try_into().unwrap()); + self.multi_asset_flag = bit_reader.read_bit()?; + self.lbr_duration_mod = bit_reader.read_bit()?; + let _ = bit_reader.read_bits(6)?; + Ok(Some(20)) + }) + } +} + +/// DTS-UHD-specific configuration box carried by DTS sample entries. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Udts { + /// Six-bit decoder-profile code. + pub decoder_profile_code: u8, + /// Two-bit frame-duration code. + pub frame_duration_code: u8, + /// Three-bit max-payload code. + pub max_payload_code: u8, + /// Five-bit number-of-presentations code. + pub num_presentations_code: u8, + /// Thirty-two-bit channel mask. + pub channel_mask: u32, + /// One-bit base-sampling-frequency code. + pub base_sampling_frequency_code: bool, + /// Two-bit sample-rate modifier. + pub sample_rate_mod: u8, + /// Three-bit representation-type code. + pub representation_type: u8, + /// Three-bit stream index. + pub stream_index: u8, + /// Whether expansion-box bytes are present. + pub expansion_box_present: bool, + /// One-bit per-presentation ID-tag-presence flags. + pub id_tag_present: Vec, + /// Packed presentation-ID tag bytes. + pub presentation_id_tag_data: Vec, + /// Opaque expansion-box bytes. + pub expansion_box_data: Vec, +} + +impl FieldHooks for Udts {} + +impl ImmutableBox for Udts { + fn box_type(&self) -> FourCc { + FourCc::from_bytes(*b"udts") + } +} + +impl MutableBox for Udts {} + +impl FieldValueRead for Udts { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "DecoderProfileCode" => Ok(FieldValue::Unsigned(u64::from(self.decoder_profile_code))), + "FrameDurationCode" => Ok(FieldValue::Unsigned(u64::from(self.frame_duration_code))), + "MaxPayloadCode" => Ok(FieldValue::Unsigned(u64::from(self.max_payload_code))), + "NumPresentationsCode" => { + Ok(FieldValue::Unsigned(u64::from(self.num_presentations_code))) + } + "ChannelMask" => Ok(FieldValue::Unsigned(u64::from(self.channel_mask))), + "BaseSamplingFrequencyCode" => { + Ok(FieldValue::Boolean(self.base_sampling_frequency_code)) + } + "SampleRateMod" => Ok(FieldValue::Unsigned(u64::from(self.sample_rate_mod))), + "RepresentationType" => Ok(FieldValue::Unsigned(u64::from(self.representation_type))), + "StreamIndex" => Ok(FieldValue::Unsigned(u64::from(self.stream_index))), + "ExpansionBoxPresent" => Ok(FieldValue::Boolean(self.expansion_box_present)), + "PresentationIdTagData" => Ok(FieldValue::Bytes(self.presentation_id_tag_data.clone())), + "ExpansionBoxData" => Ok(FieldValue::Bytes(self.expansion_box_data.clone())), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Udts { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("DecoderProfileCode", FieldValue::Unsigned(value)) => { + self.decoder_profile_code = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("FrameDurationCode", FieldValue::Unsigned(value)) => { + self.frame_duration_code = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("MaxPayloadCode", FieldValue::Unsigned(value)) => { + self.max_payload_code = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("NumPresentationsCode", FieldValue::Unsigned(value)) => { + self.num_presentations_code = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("ChannelMask", FieldValue::Unsigned(value)) => { + self.channel_mask = u32_from_unsigned(field_name, value)?; + Ok(()) + } + ("BaseSamplingFrequencyCode", FieldValue::Boolean(value)) => { + self.base_sampling_frequency_code = value; + Ok(()) + } + ("SampleRateMod", FieldValue::Unsigned(value)) => { + self.sample_rate_mod = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("RepresentationType", FieldValue::Unsigned(value)) => { + self.representation_type = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("StreamIndex", FieldValue::Unsigned(value)) => { + self.stream_index = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("ExpansionBoxPresent", FieldValue::Boolean(value)) => { + self.expansion_box_present = value; + Ok(()) + } + ("PresentationIdTagData", FieldValue::Bytes(value)) => { + self.presentation_id_tag_data = value; + Ok(()) + } + ("ExpansionBoxData", FieldValue::Bytes(value)) => { + self.expansion_box_data = value; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Udts { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("DecoderProfileCode", 0, with_bit_width(6)), + codec_field!("FrameDurationCode", 1, with_bit_width(2)), + codec_field!("MaxPayloadCode", 2, with_bit_width(3)), + codec_field!("NumPresentationsCode", 3, with_bit_width(5)), + codec_field!("ChannelMask", 4, with_bit_width(32)), + codec_field!( + "BaseSamplingFrequencyCode", + 5, + with_bit_width(1), + as_boolean() + ), + codec_field!("SampleRateMod", 6, with_bit_width(2)), + codec_field!("RepresentationType", 7, with_bit_width(3)), + codec_field!("StreamIndex", 8, with_bit_width(3)), + codec_field!("ExpansionBoxPresent", 9, with_bit_width(1), as_boolean()), + codec_field!("PresentationIdTagData", 10, with_bit_width(8), as_bytes()), + codec_field!("ExpansionBoxData", 11, with_bit_width(8), as_bytes()), + ]); + + fn custom_marshal(&self, writer: &mut dyn Write) -> Result, CodecError> { + let expected_flags = usize::from(self.num_presentations_code) + 1; + if self.id_tag_present.len() != expected_flags { + return Err(invalid_value( + "NumPresentationsCode", + "id_tag_present length must equal NumPresentationsCode + 1", + ) + .into()); + } + if self.id_tag_present.iter().filter(|flag| **flag).count() * 16 + != self.presentation_id_tag_data.len() + { + return Err(invalid_value( + "PresentationIdTagData", + "payload length must equal 16 bytes for each enabled ID tag", + ) + .into()); + } + let mut bit_writer = BitWriter::new(Vec::new()); + bit_writer.write_bits(&[self.decoder_profile_code], 6)?; + bit_writer.write_bits(&[self.frame_duration_code], 2)?; + bit_writer.write_bits(&[self.max_payload_code], 3)?; + bit_writer.write_bits(&[self.num_presentations_code], 5)?; + bit_writer.write_bits(&self.channel_mask.to_be_bytes(), 32)?; + bit_writer.write_bit(self.base_sampling_frequency_code)?; + bit_writer.write_bits(&[self.sample_rate_mod], 2)?; + bit_writer.write_bits(&[self.representation_type], 3)?; + bit_writer.write_bits(&[self.stream_index], 3)?; + bit_writer.write_bit(self.expansion_box_present)?; + for flag in &self.id_tag_present { + bit_writer.write_bit(*flag)?; + } + bit_writer.flush()?; + let mut bytes = bit_writer.into_inner()?; + bytes.extend_from_slice(&self.presentation_id_tag_data); + bytes.extend_from_slice(&self.expansion_box_data); + writer.write_all(&bytes)?; + Ok(Some(u64::try_from(bytes.len()).unwrap())) + } + + fn custom_unmarshal( + &mut self, + reader: &mut dyn ReadSeek, + payload_size: u64, + ) -> Result, CodecError> { + if payload_size < 8 { + return Err(invalid_value("Udts", "payload size must be at least 8 bytes").into()); + } + let total_len = usize::try_from(payload_size).map_err(|_| { + invalid_value("Udts", "payload size exceeds the supported in-memory range") + })?; + let mut prefix = [0_u8; 2]; + reader.read_exact(&mut prefix)?; + let mut prefix_reader = BitReader::new(Cursor::new(prefix.as_slice())); + let _decoder_profile_code = read_bits_u8(&mut prefix_reader, 6, "DecoderProfileCode")?; + let _frame_duration_code = read_bits_u8(&mut prefix_reader, 2, "FrameDurationCode")?; + let _max_payload_code = read_bits_u8(&mut prefix_reader, 3, "MaxPayloadCode")?; + let num_presentations_code = read_bits_u8(&mut prefix_reader, 5, "NumPresentationsCode")?; + let header_len = (58usize + usize::from(num_presentations_code) + 1).div_ceil(8); + if total_len < header_len { + return Err( + invalid_value("Udts", "payload is truncated before the header completes").into(), + ); + } + let mut header_bytes = vec![0_u8; header_len]; + header_bytes[..2].copy_from_slice(&prefix); + reader.read_exact(&mut header_bytes[2..])?; + let mut bit_reader = BitReader::new(Cursor::new(header_bytes.as_slice())); + self.decoder_profile_code = read_bits_u8(&mut bit_reader, 6, "DecoderProfileCode")?; + self.frame_duration_code = read_bits_u8(&mut bit_reader, 2, "FrameDurationCode")?; + self.max_payload_code = read_bits_u8(&mut bit_reader, 3, "MaxPayloadCode")?; + self.num_presentations_code = read_bits_u8(&mut bit_reader, 5, "NumPresentationsCode")?; + self.channel_mask = u32::from_be_bytes(bit_reader.read_bits(32)?.try_into().unwrap()); + self.base_sampling_frequency_code = bit_reader.read_bit()?; + self.sample_rate_mod = read_bits_u8(&mut bit_reader, 2, "SampleRateMod")?; + self.representation_type = read_bits_u8(&mut bit_reader, 3, "RepresentationType")?; + self.stream_index = read_bits_u8(&mut bit_reader, 3, "StreamIndex")?; + self.expansion_box_present = bit_reader.read_bit()?; + self.id_tag_present.clear(); + for _ in 0..=self.num_presentations_code { + self.id_tag_present.push(bit_reader.read_bit()?); + } + let consumed = header_len; + let presentation_id_len = self.id_tag_present.iter().filter(|flag| **flag).count() * 16; + if total_len.saturating_sub(consumed) < presentation_id_len { + return Err(invalid_value( + "PresentationIdTagData", + "payload is truncated before the declared ID-tag data", + ) + .into()); + } + self.presentation_id_tag_data = vec![0_u8; presentation_id_len]; + reader.read_exact(&mut self.presentation_id_tag_data)?; + let consumed = consumed + presentation_id_len; + if self.expansion_box_present { + let expansion_len = total_len.saturating_sub(consumed); + self.expansion_box_data = vec![0_u8; expansion_len]; + reader.read_exact(&mut self.expansion_box_data)?; + } else { + self.expansion_box_data.clear(); + } + Ok(Some(payload_size)) + } + + #[cfg(feature = "async")] + fn custom_marshal_async<'a>( + &'a self, + writer: &'a mut dyn AsyncWriteSeek, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + let mut bytes = Vec::new(); + let written = self.custom_marshal(&mut bytes)?.unwrap_or(0); + tokio::io::AsyncWriteExt::write_all(writer, &bytes).await?; + Ok(Some(written)) + }) + } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size < 8 { + return Err(invalid_value("Udts", "payload size must be at least 8 bytes").into()); + } + let total_len = usize::try_from(payload_size).map_err(|_| { + invalid_value("Udts", "payload size exceeds the supported in-memory range") + })?; + let mut prefix = [0_u8; 2]; + tokio::io::AsyncReadExt::read_exact(reader, &mut prefix).await?; + let mut prefix_reader = AsyncBitReader::new(Cursor::new(prefix.to_vec())); + let _decoder_profile_code = + read_bits_u8_async(&mut prefix_reader, 6, "DecoderProfileCode").await?; + let _frame_duration_code = + read_bits_u8_async(&mut prefix_reader, 2, "FrameDurationCode").await?; + let _max_payload_code = + read_bits_u8_async(&mut prefix_reader, 3, "MaxPayloadCode").await?; + let num_presentations_code = + read_bits_u8_async(&mut prefix_reader, 5, "NumPresentationsCode").await?; + let header_len = (58usize + usize::from(num_presentations_code) + 1).div_ceil(8); + if total_len < header_len { + return Err(invalid_value( + "Udts", + "payload is truncated before the header completes", + ) + .into()); + } + let mut header_bytes = vec![0_u8; header_len]; + header_bytes[..2].copy_from_slice(&prefix); + tokio::io::AsyncReadExt::read_exact(reader, &mut header_bytes[2..]).await?; + let mut bit_reader = AsyncBitReader::new(Cursor::new(header_bytes)); + self.decoder_profile_code = + read_bits_u8_async(&mut bit_reader, 6, "DecoderProfileCode").await?; + self.frame_duration_code = + read_bits_u8_async(&mut bit_reader, 2, "FrameDurationCode").await?; + self.max_payload_code = + read_bits_u8_async(&mut bit_reader, 3, "MaxPayloadCode").await?; + self.num_presentations_code = + read_bits_u8_async(&mut bit_reader, 5, "NumPresentationsCode").await?; + self.channel_mask = + u32::from_be_bytes(bit_reader.read_bits(32).await?.try_into().unwrap()); + self.base_sampling_frequency_code = bit_reader.read_bit().await?; + self.sample_rate_mod = read_bits_u8_async(&mut bit_reader, 2, "SampleRateMod").await?; + self.representation_type = + read_bits_u8_async(&mut bit_reader, 3, "RepresentationType").await?; + self.stream_index = read_bits_u8_async(&mut bit_reader, 3, "StreamIndex").await?; + self.expansion_box_present = bit_reader.read_bit().await?; + self.id_tag_present.clear(); + for _ in 0..=self.num_presentations_code { + self.id_tag_present.push(bit_reader.read_bit().await?); + } + let consumed = header_len; + let presentation_id_len = self.id_tag_present.iter().filter(|flag| **flag).count() * 16; + if total_len.saturating_sub(consumed) < presentation_id_len { + return Err(invalid_value( + "PresentationIdTagData", + "payload is truncated before the declared ID-tag data", + ) + .into()); + } + self.presentation_id_tag_data = vec![0_u8; presentation_id_len]; + tokio::io::AsyncReadExt::read_exact(reader, &mut self.presentation_id_tag_data).await?; + let consumed = consumed + presentation_id_len; + if self.expansion_box_present { + let expansion_len = total_len.saturating_sub(consumed); + self.expansion_box_data = vec![0_u8; expansion_len]; + tokio::io::AsyncReadExt::read_exact(reader, &mut self.expansion_box_data).await?; + } else { + self.expansion_box_data.clear(); + } + Ok(Some(payload_size)) + }) + } +} diff --git a/src/boxes/etsi_ts_102_366.rs b/src/boxes/etsi_ts_102_366.rs index d347542..4e07143 100644 --- a/src/boxes/etsi_ts_102_366.rs +++ b/src/boxes/etsi_ts_102_366.rs @@ -1,6 +1,6 @@ //! ETSI TS 102 366 AC-3 and E-AC-3 sample-entry and decoder-configuration box definitions. -use std::io::{Cursor, Read}; +use std::io::{Cursor, Read, Seek}; use super::iso14496_12::AudioSampleEntry; use crate::bitio::{BitReader, BitWriter}; @@ -276,9 +276,21 @@ fn parse_ec3_substreams( }); } - let mut reserved = Vec::new(); + let checkpoint = reader + .stream_position() + .map_err(|_| invalid_value(field_name, "substream payload alignment is invalid"))?; + let end = reader + .seek(std::io::SeekFrom::End(0)) + .map_err(|_| invalid_value(field_name, "substream payload is truncated"))?; + reader + .seek(std::io::SeekFrom::Start(checkpoint)) + .map_err(|_| invalid_value(field_name, "substream payload is truncated"))?; + let remaining = end.saturating_sub(checkpoint); + let reserved_len = usize::try_from(remaining) + .map_err(|_| invalid_value(field_name, "substream payload is too large"))?; + let mut reserved = vec![0_u8; reserved_len]; reader - .read_to_end(&mut reserved) + .read_exact(&mut reserved) .map_err(|_| invalid_value(field_name, "substream payload is truncated"))?; Ok((substreams, reserved)) diff --git a/src/boxes/flac.rs b/src/boxes/flac.rs index 3c1cd34..dcbdb8a 100644 --- a/src/boxes/flac.rs +++ b/src/boxes/flac.rs @@ -3,11 +3,18 @@ use std::io::Write; use super::iso14496_12::AudioSampleEntry; +#[cfg(feature = "async")] +use crate::async_io::AsyncReadSeek; use crate::boxes::BoxRegistry; +#[cfg(feature = "async")] +use crate::codec::CodecFuture; use crate::codec::{ CodecBox, CodecError, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, - FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, read_exact_vec_untrusted, + FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, read_exact_array_untrusted, + read_exact_vec_untrusted, }; +#[cfg(feature = "async")] +use crate::codec::{read_exact_array_untrusted_async, read_exact_vec_untrusted_async}; use crate::{FourCc, codec_field}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -170,14 +177,138 @@ fn decode_metadata_blocks( }); } - if blocks - .last() - .is_some_and(|block| !block.last_metadata_block_flag) + if let Some(last_block) = blocks.last_mut() + && !last_block.last_metadata_block_flag { - return Err(invalid_value( - field_name, - "final metadata block flag must be set", - )); + // Some files terminate the serialized metadata chain cleanly without setting the final + // flag on the last block. Treat the payload boundary as authoritative while keeping + // encode-time validation strict. + last_block.last_metadata_block_flag = true; + } + + Ok(blocks) +} + +fn decode_metadata_blocks_from_reader( + field_name: &'static str, + reader: &mut dyn ReadSeek, + payload_size: u64, +) -> Result, FieldValueError> { + let mut remaining = payload_size; + let mut blocks = Vec::new(); + + while remaining != 0 { + if remaining < 4 { + return Err(invalid_value( + field_name, + "metadata block header is truncated", + )); + } + + let header = read_exact_array_untrusted::<4, _>(reader) + .map_err(|_| invalid_value(field_name, "metadata block header is truncated"))?; + remaining -= 4; + + let last_metadata_block_flag = (header[0] & 0x80) != 0; + let block_type = header[0] & 0x7f; + let length = read_u24(&header, 1); + let block_len = usize::try_from(length).map_err(|_| { + invalid_value(field_name, "metadata block length does not fit in usize") + })?; + if remaining < u64::from(length) { + return Err(invalid_value( + field_name, + "metadata block payload is truncated", + )); + } + + let block_data = read_exact_vec_untrusted(reader, block_len) + .map_err(|_| invalid_value(field_name, "metadata block payload is truncated"))?; + remaining -= u64::from(length); + + if last_metadata_block_flag && remaining != 0 { + return Err(invalid_value( + field_name, + "last metadata block flag must only appear on the final block", + )); + } + + blocks.push(FlacMetadataBlock { + last_metadata_block_flag, + block_type, + length, + block_data, + }); + } + + if let Some(last_block) = blocks.last_mut() + && !last_block.last_metadata_block_flag + { + last_block.last_metadata_block_flag = true; + } + + Ok(blocks) +} + +#[cfg(feature = "async")] +async fn decode_metadata_blocks_from_reader_async( + field_name: &'static str, + reader: &mut dyn AsyncReadSeek, + payload_size: u64, +) -> Result, FieldValueError> { + let mut remaining = payload_size; + let mut blocks = Vec::new(); + + while remaining != 0 { + if remaining < 4 { + return Err(invalid_value( + field_name, + "metadata block header is truncated", + )); + } + + let header = read_exact_array_untrusted_async::<4, _>(reader) + .await + .map_err(|_| invalid_value(field_name, "metadata block header is truncated"))?; + remaining -= 4; + + let last_metadata_block_flag = (header[0] & 0x80) != 0; + let block_type = header[0] & 0x7f; + let length = read_u24(&header, 1); + let block_len = usize::try_from(length).map_err(|_| { + invalid_value(field_name, "metadata block length does not fit in usize") + })?; + if remaining < u64::from(length) { + return Err(invalid_value( + field_name, + "metadata block payload is truncated", + )); + } + + let block_data = read_exact_vec_untrusted_async(reader, block_len) + .await + .map_err(|_| invalid_value(field_name, "metadata block payload is truncated"))?; + remaining -= u64::from(length); + + if last_metadata_block_flag && remaining != 0 { + return Err(invalid_value( + field_name, + "last metadata block flag must only appear on the final block", + )); + } + + blocks.push(FlacMetadataBlock { + last_metadata_block_flag, + block_type, + length, + block_data, + }); + } + + if let Some(last_block) = blocks.last_mut() + && !last_block.last_metadata_block_flag + { + last_block.last_metadata_block_flag = true; } Ok(blocks) @@ -307,10 +438,10 @@ impl CodecBox for DfLa { return Err(invalid_value("Payload", "payload is too short").into()); } - let payload = read_exact_vec_untrusted(reader, payload_size as usize)?; - let version = payload[0]; + let header = read_exact_array_untrusted::<4, _>(reader)?; + let version = header[0]; let flags = - (u32::from(payload[1]) << 16) | (u32::from(payload[2]) << 8) | u32::from(payload[3]); + (u32::from(header[1]) << 16) | (u32::from(header[2]) << 8) | u32::from(header[3]); if version != 0 { return Err(invalid_value("Version", "unsupported version").into()); } @@ -319,9 +450,43 @@ impl CodecBox for DfLa { } self.full_box = FullBoxState { version, flags }; - self.metadata_blocks = decode_metadata_blocks("MetadataBlocks", &payload[4..])?; + self.metadata_blocks = + decode_metadata_blocks_from_reader("MetadataBlocks", reader, payload_size - 4)?; Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size < 4 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + let header = read_exact_array_untrusted_async::<4, _>(reader).await?; + let version = header[0]; + let flags = + (u32::from(header[1]) << 16) | (u32::from(header[2]) << 8) | u32::from(header[3]); + if version != 0 { + return Err(invalid_value("Version", "unsupported version").into()); + } + if flags != 0 { + return Err(invalid_value("Flags", "unsupported flags").into()); + } + + self.full_box = FullBoxState { version, flags }; + self.metadata_blocks = decode_metadata_blocks_from_reader_async( + "MetadataBlocks", + reader, + payload_size - 4, + ) + .await?; + Ok(Some(payload_size)) + }) + } } /// Registers the currently implemented FLAC boxes in `registry`. diff --git a/src/boxes/iamf.rs b/src/boxes/iamf.rs new file mode 100644 index 0000000..fc3e62f --- /dev/null +++ b/src/boxes/iamf.rs @@ -0,0 +1,300 @@ +//! IAMF sample-entry child box definitions. + +use std::io::Write; + +#[cfg(feature = "async")] +use crate::async_io::{AsyncReadSeek, AsyncWriteSeek}; +#[cfg(feature = "async")] +use crate::codec::CodecFuture; +use crate::codec::{ + CodecBox, CodecError, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, + FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, +}; +use crate::{FourCc, codec_field}; + +fn missing_field(field_name: &'static str) -> FieldValueError { + FieldValueError::MissingField { field_name } +} + +fn unexpected_field(field_name: &'static str, value: FieldValue) -> FieldValueError { + FieldValueError::UnexpectedType { + field_name, + expected: "matching codec field value", + actual: value.kind_name(), + } +} + +fn invalid_value(field_name: &'static str, reason: &'static str) -> FieldValueError { + FieldValueError::InvalidValue { field_name, reason } +} + +fn u8_from_unsigned(field_name: &'static str, value: u64) -> Result { + u8::try_from(value).map_err(|_| invalid_value(field_name, "value does not fit in u8")) +} + +fn write_leb128( + writer: &mut dyn Write, + field_name: &'static str, + mut value: usize, +) -> Result { + let mut written = 0u64; + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + writer.write_all(&[byte])?; + written += 1; + if value == 0 { + return Ok(written); + } + if written > 10 { + return Err( + invalid_value(field_name, "leb128 length exceeds the supported range").into(), + ); + } + } +} + +fn read_leb128( + reader: &mut dyn ReadSeek, + field_name: &'static str, +) -> Result<(usize, u64), CodecError> { + let mut value = 0usize; + let mut shift = 0usize; + let mut read = 0u64; + loop { + let mut byte = [0u8; 1]; + std::io::Read::read_exact(reader, &mut byte)?; + read += 1; + value |= usize::from(byte[0] & 0x7f) << shift; + if byte[0] & 0x80 == 0 { + return Ok((value, read)); + } + shift += 7; + if shift >= usize::BITS as usize { + return Err( + invalid_value(field_name, "leb128 length exceeds the supported range").into(), + ); + } + } +} + +#[cfg(feature = "async")] +async fn write_leb128_async( + writer: &mut dyn AsyncWriteSeek, + field_name: &'static str, + mut value: usize, +) -> Result { + let mut written = 0u64; + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + tokio::io::AsyncWriteExt::write_all(writer, &[byte]).await?; + written += 1; + if value == 0 { + return Ok(written); + } + if written > 10 { + return Err( + invalid_value(field_name, "leb128 length exceeds the supported range").into(), + ); + } + } +} + +#[cfg(feature = "async")] +async fn read_leb128_async( + reader: &mut dyn AsyncReadSeek, + field_name: &'static str, +) -> Result<(usize, u64), CodecError> { + let mut value = 0usize; + let mut shift = 0usize; + let mut read = 0u64; + loop { + let mut byte = [0u8; 1]; + tokio::io::AsyncReadExt::read_exact(reader, &mut byte).await?; + read += 1; + value |= usize::from(byte[0] & 0x7f) << shift; + if byte[0] & 0x80 == 0 { + return Ok((value, read)); + } + shift += 7; + if shift >= usize::BITS as usize { + return Err( + invalid_value(field_name, "leb128 length exceeds the supported range").into(), + ); + } + } +} + +/// IAMF configuration box carried by `iamf` sample entries. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Iacb { + /// Configuration-record version. + pub configuration_version: u8, + /// Raw concatenated IAMF descriptor OBUs stored in the configuration record. + pub config_obus: Vec, +} + +impl FieldHooks for Iacb {} + +impl ImmutableBox for Iacb { + fn box_type(&self) -> FourCc { + FourCc::from_bytes(*b"iacb") + } +} + +impl MutableBox for Iacb {} + +impl FieldValueRead for Iacb { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "ConfigurationVersion" => { + Ok(FieldValue::Unsigned(u64::from(self.configuration_version))) + } + "ConfigObus" => Ok(FieldValue::Bytes(self.config_obus.clone())), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Iacb { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("ConfigurationVersion", FieldValue::Unsigned(value)) => { + self.configuration_version = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("ConfigObus", FieldValue::Bytes(value)) => { + self.config_obus = value; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Iacb { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("ConfigurationVersion", 0, with_bit_width(8)), + codec_field!("ConfigObus", 1, with_bit_width(8), as_bytes()), + ]); + + fn custom_marshal(&self, writer: &mut dyn Write) -> Result, CodecError> { + if self.configuration_version != 1 { + return Err(invalid_value( + "ConfigurationVersion", + "only version 1 is currently supported", + ) + .into()); + } + writer.write_all(&[self.configuration_version])?; + let mut written = 1u64; + written += write_leb128(writer, "ConfigObus", self.config_obus.len())?; + writer.write_all(&self.config_obus)?; + written += u64::try_from(self.config_obus.len()).unwrap(); + Ok(Some(written)) + } + + fn custom_unmarshal( + &mut self, + reader: &mut dyn ReadSeek, + payload_size: u64, + ) -> Result, CodecError> { + let mut version = [0u8; 1]; + std::io::Read::read_exact(reader, &mut version)?; + self.configuration_version = version[0]; + if self.configuration_version != 1 { + return Err(invalid_value( + "ConfigurationVersion", + "only version 1 is currently supported", + ) + .into()); + } + let (config_len, leb128_len_read) = read_leb128(reader, "ConfigObus")?; + let header_size = 1u64 + .checked_add(leb128_len_read) + .ok_or_else(|| invalid_value("ConfigObus", "payload header size overflowed"))?; + let total = header_size + .checked_add(u64::try_from(config_len).unwrap()) + .ok_or_else(|| invalid_value("ConfigObus", "payload size overflowed"))?; + if total != payload_size { + return Err(invalid_value( + "ConfigObus", + "payload length did not match the declared leb128 size", + ) + .into()); + } + self.config_obus.resize(config_len, 0); + reader.read_exact(&mut self.config_obus)?; + Ok(Some(total)) + } + + #[cfg(feature = "async")] + fn custom_marshal_async<'a>( + &'a self, + writer: &'a mut dyn AsyncWriteSeek, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if self.configuration_version != 1 { + return Err(invalid_value( + "ConfigurationVersion", + "only version 1 is currently supported", + ) + .into()); + } + tokio::io::AsyncWriteExt::write_all(writer, &[self.configuration_version]).await?; + let mut written = 1u64; + written += write_leb128_async(writer, "ConfigObus", self.config_obus.len()).await?; + tokio::io::AsyncWriteExt::write_all(writer, &self.config_obus).await?; + written += u64::try_from(self.config_obus.len()).unwrap(); + Ok(Some(written)) + }) + } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + let mut version = [0u8; 1]; + tokio::io::AsyncReadExt::read_exact(reader, &mut version).await?; + self.configuration_version = version[0]; + if self.configuration_version != 1 { + return Err(invalid_value( + "ConfigurationVersion", + "only version 1 is currently supported", + ) + .into()); + } + let (config_len, leb128_len_read) = read_leb128_async(reader, "ConfigObus").await?; + let header_size = 1u64 + .checked_add(leb128_len_read) + .ok_or_else(|| invalid_value("ConfigObus", "payload header size overflowed"))?; + let total = header_size + .checked_add(u64::try_from(config_len).unwrap()) + .ok_or_else(|| invalid_value("ConfigObus", "payload size overflowed"))?; + if total != payload_size { + return Err(invalid_value( + "ConfigObus", + "payload length did not match the declared leb128 size", + ) + .into()); + } + self.config_obus.resize(config_len, 0); + tokio::io::AsyncReadExt::read_exact(reader, &mut self.config_obus).await?; + Ok(Some(total)) + }) + } +} diff --git a/src/boxes/isma_cryp.rs b/src/boxes/isma_cryp.rs index 3187b33..0660df8 100644 --- a/src/boxes/isma_cryp.rs +++ b/src/boxes/isma_cryp.rs @@ -2,10 +2,14 @@ use std::io::Write; +#[cfg(feature = "async")] +use crate::async_io::AsyncReadSeek; use crate::boxes::BoxRegistry; +#[cfg(feature = "async")] +use crate::codec::CodecFuture; use crate::codec::{ CodecBox, CodecError, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, - FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, StringFieldMode, read_exact_vec_untrusted, + FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, StringFieldMode, }; use crate::{FourCc, codec_field}; @@ -190,38 +194,43 @@ impl CodecBox for Ikms { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, CodecError> { - let payload = read_exact_vec_untrusted( - reader, - usize::try_from(payload_size) - .map_err(|_| invalid_value("iKMS payload", "payload size does not fit in usize"))?, - )?; - if payload.len() < 4 { + if payload_size < 4 { return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof).into()); } + let mut fixed = [0_u8; 4]; + reader.read_exact(&mut fixed)?; - self.set_version(payload[0]); + self.set_version(fixed[0]); if self.version() > 1 { return Err(CodecError::UnsupportedVersion { box_type: self.box_type(), version: self.version(), }); } - self.set_flags(u32::from_be_bytes([0, payload[1], payload[2], payload[3]])); - - let mut cursor = 4usize; - if self.version() == 1 && payload.len().saturating_sub(cursor) >= 8 { - self.kms_id = u32::from_be_bytes(payload[cursor..cursor + 4].try_into().unwrap()); - self.kms_version = - u32::from_be_bytes(payload[cursor + 4..cursor + 8].try_into().unwrap()); - cursor += 8; + self.set_flags(u32::from_be_bytes([0, fixed[1], fixed[2], fixed[3]])); + + let mut remaining = payload_size - 4; + if self.version() == 1 && remaining >= 8 { + let mut version_fields = [0_u8; 8]; + reader.read_exact(&mut version_fields)?; + self.kms_id = u32::from_be_bytes(version_fields[..4].try_into().unwrap()); + self.kms_version = u32::from_be_bytes(version_fields[4..].try_into().unwrap()); + remaining -= 8; } else { self.kms_id = 0; self.kms_version = 0; } - if cursor < payload.len() { - let uri_bytes = &payload[cursor..]; - let uri_bytes = uri_bytes.strip_suffix(&[0]).unwrap_or(uri_bytes); + if remaining != 0 { + let mut uri_bytes = vec![ + 0_u8; + usize::try_from(remaining).map_err(|_| invalid_value( + "KmsUri", + "payload size does not fit in usize", + ))? + ]; + reader.read_exact(&mut uri_bytes)?; + let uri_bytes = uri_bytes.strip_suffix(&[0]).unwrap_or(&uri_bytes); self.kms_uri = decode_utf8_string("KmsUri", uri_bytes)?; } else { self.kms_uri.clear(); @@ -229,6 +238,59 @@ impl CodecBox for Ikms { Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size < 4 { + return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof).into()); + } + let mut fixed = [0_u8; 4]; + tokio::io::AsyncReadExt::read_exact(reader, &mut fixed).await?; + + self.set_version(fixed[0]); + if self.version() > 1 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version: self.version(), + }); + } + self.set_flags(u32::from_be_bytes([0, fixed[1], fixed[2], fixed[3]])); + + let mut remaining = payload_size - 4; + if self.version() == 1 && remaining >= 8 { + let mut version_fields = [0_u8; 8]; + tokio::io::AsyncReadExt::read_exact(reader, &mut version_fields).await?; + self.kms_id = u32::from_be_bytes(version_fields[..4].try_into().unwrap()); + self.kms_version = u32::from_be_bytes(version_fields[4..].try_into().unwrap()); + remaining -= 8; + } else { + self.kms_id = 0; + self.kms_version = 0; + } + + if remaining != 0 { + let mut uri_bytes = vec![ + 0_u8; + usize::try_from(remaining).map_err(|_| invalid_value( + "KmsUri", + "payload size does not fit in usize", + ))? + ]; + tokio::io::AsyncReadExt::read_exact(reader, &mut uri_bytes).await?; + let uri_bytes = uri_bytes.strip_suffix(&[0]).unwrap_or(&uri_bytes); + self.kms_uri = decode_utf8_string("KmsUri", uri_bytes)?; + } else { + self.kms_uri.clear(); + } + + Ok(Some(payload_size)) + }) + } } /// IAEC sample-format box that describes selective-encryption, key-indicator, and IV widths. @@ -331,14 +393,11 @@ impl CodecBox for Isfm { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, CodecError> { - let payload = read_exact_vec_untrusted( - reader, - usize::try_from(payload_size) - .map_err(|_| invalid_value("iSFM payload", "payload size does not fit in usize"))?, - )?; - if payload.len() != 7 { + if payload_size != 7 { return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof).into()); } + let mut payload = [0_u8; 7]; + reader.read_exact(&mut payload)?; self.set_version(payload[0]); if self.version() != 0 { @@ -353,6 +412,34 @@ impl CodecBox for Isfm { self.iv_length = payload[6]; Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size != 7 { + return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof).into()); + } + let mut payload = [0_u8; 7]; + tokio::io::AsyncReadExt::read_exact(reader, &mut payload).await?; + + self.set_version(payload[0]); + if self.version() != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version: self.version(), + }); + } + self.set_flags(u32::from_be_bytes([0, payload[1], payload[2], payload[3]])); + self.selective_encryption = (payload[4] & 0x80) != 0; + self.key_indicator_length = payload[5]; + self.iv_length = payload[6]; + Ok(Some(payload_size)) + }) + } } /// IAEC salt box carried under `schi`. @@ -410,10 +497,24 @@ impl CodecBox for Islt { if payload_size != 8 { return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof).into()); } - let payload = read_exact_vec_untrusted(reader, 8)?; - self.salt.copy_from_slice(&payload); + reader.read_exact(&mut self.salt)?; Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size != 8 { + return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof).into()); + } + tokio::io::AsyncReadExt::read_exact(reader, &mut self.salt).await?; + Ok(Some(payload_size)) + }) + } } /// Registers the landed ISMA Cryp box families in the supplied registry. diff --git a/src/boxes/iso14496_12.rs b/src/boxes/iso14496_12.rs index 98e0c76..d5e01e1 100644 --- a/src/boxes/iso14496_12.rs +++ b/src/boxes/iso14496_12.rs @@ -9,7 +9,8 @@ use tokio::io::{AsyncReadExt, AsyncSeekExt}; #[cfg(feature = "async")] use crate::async_io::AsyncReadSeek; use crate::boxes::iso23001_7::{ - Senc, decode_senc_payload, encode_senc_payload, render_senc_samples_display, + Senc, decode_senc_payload, decode_senc_payload_from_reader, encode_senc_payload, + render_senc_samples_display, }; use crate::boxes::{AnyTypeBox, BoxLookupContext, BoxRegistry}; #[cfg(feature = "async")] @@ -17,8 +18,10 @@ use crate::codec::CodecFuture; use crate::codec::{ ANY_VERSION, CodecBox, CodecError, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, StringFieldMode, - read_exact_vec_untrusted, untrusted_prealloc_hint, + read_exact_array_untrusted, read_exact_vec_untrusted, untrusted_prealloc_hint, }; +#[cfg(feature = "async")] +use crate::codec::{read_exact_array_untrusted_async, read_exact_vec_untrusted_async}; use crate::header::{BoxInfo, SMALL_HEADER_SIZE}; use crate::{FourCc, codec_field}; @@ -511,6 +514,358 @@ fn decode_uuid_fragment_run_table( }) } +fn read_uuid_fragment_run_entries_from_reader( + field_name: &'static str, + reader: &mut dyn ReadSeek, + version: u8, + fragment_count: u8, +) -> Result, FieldValueError> { + let mut entries = Vec::with_capacity(untrusted_prealloc_hint(usize::from(fragment_count))); + for _ in 0..fragment_count { + let (fragment_absolute_time, fragment_absolute_duration) = match version { + 0 => { + let entry = read_exact_array_untrusted::<8, _>(reader).map_err(|_| { + invalid_value( + field_name, + "fragment run table payload length does not match the fragment count", + ) + })?; + ( + u64::from(read_u32(&entry, 0)), + u64::from(read_u32(&entry, 4)), + ) + } + 1 => { + let entry = read_exact_array_untrusted::<16, _>(reader).map_err(|_| { + invalid_value( + field_name, + "fragment run table payload length does not match the fragment count", + ) + })?; + (read_u64(&entry, 0), read_u64(&entry, 8)) + } + _ => { + return Err(invalid_value( + field_name, + "fragment run table payload version is not supported", + )); + } + }; + entries.push(UuidFragmentRunEntry { + fragment_absolute_time, + fragment_absolute_duration, + }); + } + Ok(entries) +} + +#[cfg(feature = "async")] +async fn read_uuid_fragment_run_entries_from_reader_async( + field_name: &'static str, + reader: &mut dyn AsyncReadSeek, + version: u8, + fragment_count: u8, +) -> Result, FieldValueError> { + let mut entries = Vec::with_capacity(untrusted_prealloc_hint(usize::from(fragment_count))); + for _ in 0..fragment_count { + let (fragment_absolute_time, fragment_absolute_duration) = match version { + 0 => { + let entry = read_exact_array_untrusted_async::<8, _>(reader) + .await + .map_err(|_| { + invalid_value( + field_name, + "fragment run table payload length does not match the fragment count", + ) + })?; + ( + u64::from(read_u32(&entry, 0)), + u64::from(read_u32(&entry, 4)), + ) + } + 1 => { + let entry = read_exact_array_untrusted_async::<16, _>(reader) + .await + .map_err(|_| { + invalid_value( + field_name, + "fragment run table payload length does not match the fragment count", + ) + })?; + (read_u64(&entry, 0), read_u64(&entry, 8)) + } + _ => { + return Err(invalid_value( + field_name, + "fragment run table payload version is not supported", + )); + } + }; + entries.push(UuidFragmentRunEntry { + fragment_absolute_time, + fragment_absolute_duration, + }); + } + Ok(entries) +} + +fn decode_uuid_payload_from_reader( + user_type: [u8; 16], + reader: &mut dyn ReadSeek, + payload_size: u64, +) -> Result { + if user_type == UUID_SPHERICAL_VIDEO_V1 { + let bytes = if payload_size == 0 { + Vec::new() + } else { + read_exact_vec_untrusted( + reader, + usize::try_from(payload_size) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?, + ) + .map_err(|_| invalid_value("Payload", "payload is truncated"))? + }; + return Ok(UuidPayload::SphericalVideoV1(SphericalVideoV1Metadata { + xml_data: bytes, + })); + } + if user_type == UUID_FRAGMENT_ABSOLUTE_TIMING { + return Ok(UuidPayload::FragmentAbsoluteTiming(match payload_size { + 12 => { + let payload = read_exact_array_untrusted::<12, _>(reader).map_err(|_| { + invalid_value("Payload", "fragment timing payload is truncated") + })?; + UuidFragmentAbsoluteTiming { + version: payload[0], + flags: u32::from_be_bytes([0, payload[1], payload[2], payload[3]]), + fragment_absolute_time: u64::from(read_u32(&payload, 4)), + fragment_absolute_duration: u64::from(read_u32(&payload, 8)), + } + } + 20 => { + let payload = read_exact_array_untrusted::<20, _>(reader).map_err(|_| { + invalid_value("Payload", "fragment timing payload is truncated") + })?; + UuidFragmentAbsoluteTiming { + version: payload[0], + flags: u32::from_be_bytes([0, payload[1], payload[2], payload[3]]), + fragment_absolute_time: read_u64(&payload, 4), + fragment_absolute_duration: read_u64(&payload, 12), + } + } + _ => { + if payload_size < 4 { + return Err(invalid_value( + "Payload", + "fragment timing payload is truncated", + )); + } + let header = read_exact_array_untrusted::<4, _>(reader).map_err(|_| { + invalid_value("Payload", "fragment timing payload is truncated") + })?; + return Err(match header[0] { + 0 => invalid_value( + "Payload", + "fragment timing payload length does not match version 0", + ), + 1 => invalid_value( + "Payload", + "fragment timing payload length does not match version 1", + ), + _ => invalid_value( + "Payload", + "fragment timing payload version is not supported", + ), + }); + } + })); + } + if user_type == UUID_FRAGMENT_RUN_TABLE { + if payload_size < 5 { + return Err(invalid_value( + "Payload", + "fragment run table payload is truncated", + )); + } + let header = read_exact_array_untrusted::<5, _>(reader) + .map_err(|_| invalid_value("Payload", "fragment run table payload is truncated"))?; + let version = header[0]; + let flags = u32::from_be_bytes([0, header[1], header[2], header[3]]); + let fragment_count = header[4]; + let entries = + read_uuid_fragment_run_entries_from_reader("Payload", reader, version, fragment_count)?; + return Ok(UuidPayload::FragmentRunTable(UuidFragmentRunTable { + version, + flags, + fragment_count, + entries, + })); + } + if user_type == UUID_SAMPLE_ENCRYPTION { + return Ok(UuidPayload::SampleEncryption( + decode_senc_payload_from_reader(reader, payload_size).map_err(|error| match error { + CodecError::FieldValue(field_error) => field_error, + CodecError::UnsupportedVersion { .. } => invalid_value( + "Payload", + "sample encryption payload version is not supported", + ), + CodecError::InvalidLength { .. } => invalid_value( + "Payload", + "sample count does not match the number of sample records", + ), + _ => invalid_value("Payload", "sample encryption payload is invalid"), + })?, + )); + } + Ok(UuidPayload::Raw(if payload_size == 0 { + Vec::new() + } else { + read_exact_vec_untrusted( + reader, + usize::try_from(payload_size) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?, + ) + .map_err(|_| invalid_value("Payload", "payload is truncated"))? + })) +} + +#[cfg(feature = "async")] +async fn decode_uuid_payload_from_reader_async( + user_type: [u8; 16], + reader: &mut dyn AsyncReadSeek, + payload_size: u64, +) -> Result { + if user_type == UUID_SPHERICAL_VIDEO_V1 { + let bytes = if payload_size == 0 { + Vec::new() + } else { + read_exact_vec_untrusted_async( + reader, + usize::try_from(payload_size) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?, + ) + .await + .map_err(|_| invalid_value("Payload", "payload is truncated"))? + }; + return Ok(UuidPayload::SphericalVideoV1(SphericalVideoV1Metadata { + xml_data: bytes, + })); + } + if user_type == UUID_FRAGMENT_ABSOLUTE_TIMING { + return Ok(UuidPayload::FragmentAbsoluteTiming(match payload_size { + 12 => { + let payload = read_exact_array_untrusted_async::<12, _>(reader) + .await + .map_err(|_| { + invalid_value("Payload", "fragment timing payload is truncated") + })?; + UuidFragmentAbsoluteTiming { + version: payload[0], + flags: u32::from_be_bytes([0, payload[1], payload[2], payload[3]]), + fragment_absolute_time: u64::from(read_u32(&payload, 4)), + fragment_absolute_duration: u64::from(read_u32(&payload, 8)), + } + } + 20 => { + let payload = read_exact_array_untrusted_async::<20, _>(reader) + .await + .map_err(|_| { + invalid_value("Payload", "fragment timing payload is truncated") + })?; + UuidFragmentAbsoluteTiming { + version: payload[0], + flags: u32::from_be_bytes([0, payload[1], payload[2], payload[3]]), + fragment_absolute_time: read_u64(&payload, 4), + fragment_absolute_duration: read_u64(&payload, 12), + } + } + _ => { + if payload_size < 4 { + return Err(invalid_value( + "Payload", + "fragment timing payload is truncated", + )); + } + let header = read_exact_array_untrusted_async::<4, _>(reader) + .await + .map_err(|_| { + invalid_value("Payload", "fragment timing payload is truncated") + })?; + return Err(match header[0] { + 0 => invalid_value( + "Payload", + "fragment timing payload length does not match version 0", + ), + 1 => invalid_value( + "Payload", + "fragment timing payload length does not match version 1", + ), + _ => invalid_value( + "Payload", + "fragment timing payload version is not supported", + ), + }); + } + })); + } + if user_type == UUID_FRAGMENT_RUN_TABLE { + if payload_size < 5 { + return Err(invalid_value( + "Payload", + "fragment run table payload is truncated", + )); + } + let header = read_exact_array_untrusted_async::<5, _>(reader) + .await + .map_err(|_| invalid_value("Payload", "fragment run table payload is truncated"))?; + let version = header[0]; + let flags = u32::from_be_bytes([0, header[1], header[2], header[3]]); + let fragment_count = header[4]; + let entries = read_uuid_fragment_run_entries_from_reader_async( + "Payload", + reader, + version, + fragment_count, + ) + .await?; + return Ok(UuidPayload::FragmentRunTable(UuidFragmentRunTable { + version, + flags, + fragment_count, + entries, + })); + } + if user_type == UUID_SAMPLE_ENCRYPTION { + return Ok(UuidPayload::SampleEncryption( + crate::boxes::iso23001_7::decode_senc_payload_from_reader_async(reader, payload_size) + .await + .map_err(|error| match error { + CodecError::FieldValue(field_error) => field_error, + CodecError::UnsupportedVersion { .. } => invalid_value( + "Payload", + "sample encryption payload version is not supported", + ), + CodecError::InvalidLength { .. } => invalid_value( + "Payload", + "sample count does not match the number of sample records", + ), + _ => invalid_value("Payload", "sample encryption payload is invalid"), + })?, + )); + } + Ok(UuidPayload::Raw(if payload_size == 0 { + Vec::new() + } else { + read_exact_vec_untrusted_async( + reader, + usize::try_from(payload_size) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?, + ) + .await + .map_err(|_| invalid_value("Payload", "payload is truncated"))? + })) +} + fn encode_uuid_payload( user_type: [u8; 16], payload: &UuidPayload, @@ -809,70 +1164,255 @@ fn decode_loudness_entries( Ok(entries) } -fn render_loudness_measurements(measurements: &[LoudnessMeasurement]) -> String { - render_array(measurements.iter().map(|measurement| { - format!( - "{{MethodDefinition={} MethodValue={} MeasurementSystem={} Reliability={}}}", - measurement.method_definition, - measurement.method_value, - measurement.measurement_system, - measurement.reliability, - ) - })) -} +fn decode_loudness_entries_from_reader( + field_name: &'static str, + version: u8, + reader: &mut dyn ReadSeek, + payload_size: u64, +) -> Result, CodecError> { + if version > 1 { + return Err(invalid_value(field_name, "unsupported loudness box version").into()); + } -fn render_loudness_entries(version: u8, entries: &[LoudnessEntry]) -> String { - render_array(entries.iter().map(|entry| { - let mut fields = Vec::new(); - if version >= 1 { - fields.push(format!("EQSetID={}", entry.eq_set_id)); + let mut remaining = payload_size; + let entry_count = if version >= 1 { + if remaining == 0 { + return Err(invalid_value(field_name, "payload is truncated").into()); } - fields.push(format!("DownmixID={}", entry.downmix_id)); - fields.push(format!("DRCSetID={}", entry.drc_set_id)); - fields.push(format!("BsSamplePeakLevel={}", entry.bs_sample_peak_level)); - fields.push(format!("BsTruePeakLevel={}", entry.bs_true_peak_level)); - fields.push(format!( - "MeasurementSystemForTP={}", - entry.measurement_system_for_tp - )); - fields.push(format!("ReliabilityForTP={}", entry.reliability_for_tp)); - fields.push(format!( - "Measurements={}", - render_loudness_measurements(&entry.measurements) - )); - format!("{{{}}}", fields.join(" ")) - })) -} + let header = read_exact_array_untrusted::<1, _>(reader).map_err(CodecError::Io)?[0]; + remaining -= 1; + let info_type = header >> 6; + if info_type != 0 { + return Err(invalid_value(field_name, "loudness info type is not supported").into()); + } + usize::from(header & 0x3f) + } else { + 1 + }; -fn quoted_fourcc(value: FourCc) -> String { - format!("\"{value}\"") -} + let mut entries = Vec::with_capacity(untrusted_prealloc_hint(entry_count)); + for _ in 0..entry_count { + let eq_set_id = if version >= 1 { + if remaining == 0 { + return Err(invalid_value(field_name, "payload is truncated").into()); + } + let value = read_exact_array_untrusted::<1, _>(reader).map_err(CodecError::Io)?[0]; + remaining -= 1; + value & 0x3f + } else { + 0 + }; -fn quote_bytes(bytes: &[u8]) -> String { - format!("\"{}\"", escape_bytes(bytes)) -} + if remaining < 7 { + return Err(invalid_value(field_name, "payload is truncated").into()); + } + let prefix = read_exact_array_untrusted::<7, _>(reader).map_err(CodecError::Io)?; + remaining -= 7; -fn escape_bytes(bytes: &[u8]) -> String { - bytes - .iter() - .map(|byte| escape_display_char(char::from(*byte))) - .collect() -} + let downmix_and_drc = u16::from_be_bytes([prefix[0], prefix[1]]); + let peak_levels = + (u32::from(prefix[2]) << 16) | (u32::from(prefix[3]) << 8) | u32::from(prefix[4]); + let measurement_system_and_reliability_for_tp = prefix[5]; + let measurement_count = usize::from(prefix[6]); + let required_measurement_bytes = measurement_count + .checked_mul(3) + .ok_or_else(|| invalid_value(field_name, "payload is too large to decode"))?; + if remaining < required_measurement_bytes as u64 { + return Err(invalid_value(field_name, "payload is truncated").into()); + } -fn escape_display_char(value: char) -> char { - if value.is_control() || !value.is_ascii_graphic() && value != ' ' { - '.' - } else { - value - } -} + let mut measurements = Vec::with_capacity(untrusted_prealloc_hint(measurement_count)); + for _ in 0..measurement_count { + let chunk = read_exact_array_untrusted::<3, _>(reader).map_err(CodecError::Io)?; + remaining -= 3; + measurements.push(LoudnessMeasurement { + method_definition: chunk[0], + method_value: chunk[1], + measurement_system: chunk[2] >> 4, + reliability: chunk[2] & 0x0f, + }); + } -fn format_fixed_16_16_signed(value: i32) -> String { - if value & 0xffff == 0 { - return (value >> 16).to_string(); - } - format!("{:.5}", f64::from(value) / 65536.0) -} + entries.push(LoudnessEntry { + eq_set_id, + downmix_id: downmix_and_drc >> 6, + drc_set_id: (downmix_and_drc & 0x3f) as u8, + bs_sample_peak_level: ((peak_levels >> 12) & 0x0fff) as u16, + bs_true_peak_level: (peak_levels & 0x0fff) as u16, + measurement_system_for_tp: measurement_system_and_reliability_for_tp >> 4, + reliability_for_tp: measurement_system_and_reliability_for_tp & 0x0f, + measurements, + }); + } + + if remaining != 0 { + return Err(invalid_value(field_name, "payload has trailing bytes").into()); + } + + Ok(entries) +} + +#[cfg(feature = "async")] +async fn decode_loudness_entries_from_reader_async( + field_name: &'static str, + version: u8, + reader: &mut dyn AsyncReadSeek, + payload_size: u64, +) -> Result, CodecError> { + if version > 1 { + return Err(invalid_value(field_name, "unsupported loudness box version").into()); + } + + let mut remaining = payload_size; + let entry_count = if version >= 1 { + if remaining == 0 { + return Err(invalid_value(field_name, "payload is truncated").into()); + } + let header = read_exact_array_untrusted_async::<1, _>(reader) + .await + .map_err(CodecError::Io)?[0]; + remaining -= 1; + let info_type = header >> 6; + if info_type != 0 { + return Err(invalid_value(field_name, "loudness info type is not supported").into()); + } + usize::from(header & 0x3f) + } else { + 1 + }; + + let mut entries = Vec::with_capacity(untrusted_prealloc_hint(entry_count)); + for _ in 0..entry_count { + let eq_set_id = if version >= 1 { + if remaining == 0 { + return Err(invalid_value(field_name, "payload is truncated").into()); + } + let value = read_exact_array_untrusted_async::<1, _>(reader) + .await + .map_err(CodecError::Io)?[0]; + remaining -= 1; + value & 0x3f + } else { + 0 + }; + + if remaining < 7 { + return Err(invalid_value(field_name, "payload is truncated").into()); + } + let prefix = read_exact_array_untrusted_async::<7, _>(reader) + .await + .map_err(CodecError::Io)?; + remaining -= 7; + + let downmix_and_drc = u16::from_be_bytes([prefix[0], prefix[1]]); + let peak_levels = + (u32::from(prefix[2]) << 16) | (u32::from(prefix[3]) << 8) | u32::from(prefix[4]); + let measurement_system_and_reliability_for_tp = prefix[5]; + let measurement_count = usize::from(prefix[6]); + let required_measurement_bytes = measurement_count + .checked_mul(3) + .ok_or_else(|| invalid_value(field_name, "payload is too large to decode"))?; + if remaining < required_measurement_bytes as u64 { + return Err(invalid_value(field_name, "payload is truncated").into()); + } + + let mut measurements = Vec::with_capacity(untrusted_prealloc_hint(measurement_count)); + for _ in 0..measurement_count { + let chunk = read_exact_array_untrusted_async::<3, _>(reader) + .await + .map_err(CodecError::Io)?; + remaining -= 3; + measurements.push(LoudnessMeasurement { + method_definition: chunk[0], + method_value: chunk[1], + measurement_system: chunk[2] >> 4, + reliability: chunk[2] & 0x0f, + }); + } + + entries.push(LoudnessEntry { + eq_set_id, + downmix_id: downmix_and_drc >> 6, + drc_set_id: (downmix_and_drc & 0x3f) as u8, + bs_sample_peak_level: ((peak_levels >> 12) & 0x0fff) as u16, + bs_true_peak_level: (peak_levels & 0x0fff) as u16, + measurement_system_for_tp: measurement_system_and_reliability_for_tp >> 4, + reliability_for_tp: measurement_system_and_reliability_for_tp & 0x0f, + measurements, + }); + } + + if remaining != 0 { + return Err(invalid_value(field_name, "payload has trailing bytes").into()); + } + + Ok(entries) +} + +fn render_loudness_measurements(measurements: &[LoudnessMeasurement]) -> String { + render_array(measurements.iter().map(|measurement| { + format!( + "{{MethodDefinition={} MethodValue={} MeasurementSystem={} Reliability={}}}", + measurement.method_definition, + measurement.method_value, + measurement.measurement_system, + measurement.reliability, + ) + })) +} + +fn render_loudness_entries(version: u8, entries: &[LoudnessEntry]) -> String { + render_array(entries.iter().map(|entry| { + let mut fields = Vec::new(); + if version >= 1 { + fields.push(format!("EQSetID={}", entry.eq_set_id)); + } + fields.push(format!("DownmixID={}", entry.downmix_id)); + fields.push(format!("DRCSetID={}", entry.drc_set_id)); + fields.push(format!("BsSamplePeakLevel={}", entry.bs_sample_peak_level)); + fields.push(format!("BsTruePeakLevel={}", entry.bs_true_peak_level)); + fields.push(format!( + "MeasurementSystemForTP={}", + entry.measurement_system_for_tp + )); + fields.push(format!("ReliabilityForTP={}", entry.reliability_for_tp)); + fields.push(format!( + "Measurements={}", + render_loudness_measurements(&entry.measurements) + )); + format!("{{{}}}", fields.join(" ")) + })) +} + +fn quoted_fourcc(value: FourCc) -> String { + format!("\"{value}\"") +} + +fn quote_bytes(bytes: &[u8]) -> String { + format!("\"{}\"", escape_bytes(bytes)) +} + +fn escape_bytes(bytes: &[u8]) -> String { + bytes + .iter() + .map(|byte| escape_display_char(char::from(*byte))) + .collect() +} + +fn escape_display_char(value: char) -> char { + if value.is_control() || !value.is_ascii_graphic() && value != ' ' { + '.' + } else { + value + } +} + +fn format_fixed_16_16_signed(value: i32) -> String { + if value & 0xffff == 0 { + return (value >> 16).to_string(); + } + format!("{:.5}", f64::from(value) / 65536.0) +} fn format_fixed_16_16_unsigned(value: u32) -> String { if value & 0xffff == 0 { @@ -1026,6 +1566,51 @@ fn parse_avc_parameter_sets( Ok(parameter_sets) } +fn read_avc_parameter_sets_from_reader( + field_name: &'static str, + reader: &mut dyn ReadSeek, + expected_count: u8, +) -> Result, CodecError> { + let mut parameter_sets = + Vec::with_capacity(untrusted_prealloc_hint(usize::from(expected_count))); + for _ in 0..expected_count { + let length = u16::from_be_bytes( + read_exact_array_untrusted::<2, _>(reader) + .map_err(|_| invalid_value(field_name, "parameter-set payload is truncated"))?, + ); + let mut nal_unit = vec![0_u8; usize::from(length)]; + reader + .read_exact(&mut nal_unit) + .map_err(|_| invalid_value(field_name, "parameter-set payload is truncated"))?; + parameter_sets.push(AVCParameterSet { length, nal_unit }); + } + Ok(parameter_sets) +} + +#[cfg(feature = "async")] +async fn read_avc_parameter_sets_from_reader_async( + field_name: &'static str, + reader: &mut dyn AsyncReadSeek, + expected_count: u8, +) -> Result, CodecError> { + let mut parameter_sets = + Vec::with_capacity(untrusted_prealloc_hint(usize::from(expected_count))); + for _ in 0..expected_count { + let length = u16::from_be_bytes( + read_exact_array_untrusted_async::<2, _>(reader) + .await + .map_err(|_| invalid_value(field_name, "parameter-set payload is truncated"))?, + ); + let mut nal_unit = vec![0_u8; usize::from(length)]; + reader + .read_exact(&mut nal_unit) + .await + .map_err(|_| invalid_value(field_name, "parameter-set payload is truncated"))?; + parameter_sets.push(AVCParameterSet { length, nal_unit }); + } + Ok(parameter_sets) +} + fn render_avc_parameter_sets(parameter_sets: &[AVCParameterSet]) -> String { render_array(parameter_sets.iter().map(|parameter_set| { format!( @@ -1185,6 +1770,104 @@ fn parse_hevc_nalu_arrays( Ok(arrays) } +fn read_hevc_nalu_arrays_from_reader( + field_name: &'static str, + reader: &mut dyn ReadSeek, + expected_count: u8, +) -> Result, CodecError> { + let mut arrays = Vec::with_capacity(untrusted_prealloc_hint(usize::from(expected_count))); + for _ in 0..expected_count { + let header = read_exact_array_untrusted::<3, _>(reader).map_err(|_| { + invalid_value( + field_name, + "NAL-array payload length does not match the entry count", + ) + })?; + let completeness = header[0] & 0x80 != 0; + let reserved = header[0] & 0x40 != 0; + let nalu_type = header[0] & 0x3f; + let num_nalus = u16::from_be_bytes([header[1], header[2]]); + let mut nalus = Vec::with_capacity(untrusted_prealloc_hint(usize::from(num_nalus))); + for _ in 0..num_nalus { + let length = + u16::from_be_bytes(read_exact_array_untrusted::<2, _>(reader).map_err(|_| { + invalid_value( + field_name, + "NAL-array payload length does not match the entry count", + ) + })?); + let mut nal_unit = vec![0_u8; usize::from(length)]; + reader.read_exact(&mut nal_unit).map_err(|_| { + invalid_value( + field_name, + "NAL-array payload length does not match the entry count", + ) + })?; + nalus.push(HEVCNalu { length, nal_unit }); + } + arrays.push(HEVCNaluArray { + completeness, + reserved, + nalu_type, + num_nalus, + nalus, + }); + } + Ok(arrays) +} + +#[cfg(feature = "async")] +async fn read_hevc_nalu_arrays_from_reader_async( + field_name: &'static str, + reader: &mut dyn AsyncReadSeek, + expected_count: u8, +) -> Result, CodecError> { + let mut arrays = Vec::with_capacity(untrusted_prealloc_hint(usize::from(expected_count))); + for _ in 0..expected_count { + let header = read_exact_array_untrusted_async::<3, _>(reader) + .await + .map_err(|_| { + invalid_value( + field_name, + "NAL-array payload length does not match the entry count", + ) + })?; + let completeness = header[0] & 0x80 != 0; + let reserved = header[0] & 0x40 != 0; + let nalu_type = header[0] & 0x3f; + let num_nalus = u16::from_be_bytes([header[1], header[2]]); + let mut nalus = Vec::with_capacity(untrusted_prealloc_hint(usize::from(num_nalus))); + for _ in 0..num_nalus { + let length = u16::from_be_bytes( + read_exact_array_untrusted_async::<2, _>(reader) + .await + .map_err(|_| { + invalid_value( + field_name, + "NAL-array payload length does not match the entry count", + ) + })?, + ); + let mut nal_unit = vec![0_u8; usize::from(length)]; + reader.read_exact(&mut nal_unit).await.map_err(|_| { + invalid_value( + field_name, + "NAL-array payload length does not match the entry count", + ) + })?; + nalus.push(HEVCNalu { length, nal_unit }); + } + arrays.push(HEVCNaluArray { + completeness, + reserved, + nalu_type, + num_nalus, + nalus, + }); + } + Ok(arrays) +} + fn render_hevc_nalu_arrays(arrays: &[HEVCNaluArray]) -> String { render_array(arrays.iter().map(|array| { format!( @@ -1453,6 +2136,7 @@ simple_container_box!(Tref, *b"tref"); raw_data_box!(Free, *b"free"); raw_data_box!(Skip, *b"skip"); raw_data_box!(Mdat, *b"mdat"); +raw_data_box!(Chnl, *b"chnl"); /// Closed-caption sample-data box that preserves its payload bytes verbatim. #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -1642,29 +2326,72 @@ macro_rules! define_loudness_info_box { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, CodecError> { - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - if payload_len < 4 { + if payload_size < 4 { return Err(invalid_value("Payload", "payload is too short").into()); } - let payload = read_exact_vec_untrusted(reader, payload_len)?; - let version = payload[0]; + let header = read_exact_array_untrusted::<4, _>(reader).map_err(CodecError::Io)?; + let version = header[0]; if version > 1 { return Err(CodecError::UnsupportedVersion { box_type: self.box_type(), version, }); } - let flags = u32::from_be_bytes([0, payload[1], payload[2], payload[3]]); + let flags = u32::from_be_bytes([0, header[1], header[2], header[3]]); if flags != 0 { return Err(invalid_value("Flags", "non-zero flags are not supported").into()); } self.full_box = FullBoxState { version, flags }; - self.entries = decode_loudness_entries("Entries", version, &payload[4..])?; + self.entries = decode_loudness_entries_from_reader( + "Entries", + version, + reader, + payload_size - 4, + )?; Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size < 4 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + let header = read_exact_array_untrusted_async::<4, _>(reader) + .await + .map_err(CodecError::Io)?; + let version = header[0]; + if version > 1 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + let flags = u32::from_be_bytes([0, header[1], header[2], header[3]]); + if flags != 0 { + return Err( + invalid_value("Flags", "non-zero flags are not supported").into() + ); + } + + self.full_box = FullBoxState { version, flags }; + self.entries = decode_loudness_entries_from_reader_async( + "Entries", + version, + reader, + payload_size - 4, + ) + .await?; + Ok(Some(payload_size)) + }) + } } }; } @@ -2078,17 +2805,35 @@ impl CodecBox for Uuid { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, CodecError> { - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - if payload_len < 16 { + if payload_size < 16 { return Err(invalid_value("Payload", "payload is too short").into()); } - let payload = read_exact_vec_untrusted(reader, payload_len)?; - self.user_type = payload[..16].try_into().unwrap(); - self.payload = decode_uuid_payload(self.user_type, &payload[16..])?; + let user_type = read_exact_array_untrusted::<16, _>(reader)?; + self.user_type = user_type; + self.payload = decode_uuid_payload_from_reader(self.user_type, reader, payload_size - 16)?; Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size < 16 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + let user_type = read_exact_array_untrusted_async::<16, _>(reader).await?; + self.user_type = user_type; + self.payload = + decode_uuid_payload_from_reader_async(self.user_type, reader, payload_size - 16) + .await?; + Ok(Some(payload_size)) + }) + } } /// File type and compatibility declaration box. @@ -2898,12 +3643,8 @@ fn validate_c_string_value(field_name: &'static str, value: &str) -> Result<(), Ok(()) } -fn decode_c_string(field_name: &'static str, bytes: &[u8]) -> Result { - let end = bytes - .iter() - .position(|byte| *byte == 0) - .unwrap_or(bytes.len()); - String::from_utf8(bytes[..end].to_vec()).map_err(|_| CodecError::InvalidUtf8 { field_name }) +fn decode_utf8_owned(field_name: &'static str, bytes: Vec) -> Result { + String::from_utf8(bytes).map_err(|_| CodecError::InvalidUtf8 { field_name }) } fn parse_required_c_string( @@ -2919,34 +3660,191 @@ fn parse_required_c_string( Ok((value, end + 1)) } -fn decode_required_c_string( +fn read_required_c_string_from_reader( + reader: &mut dyn ReadSeek, + remaining: &mut u64, field_name: &'static str, - bytes: &[u8], ) -> Result<(String, usize), CodecError> { - let Some(end) = bytes.iter().position(|byte| *byte == 0) else { - return Err(invalid_value(field_name, "string is not NUL-terminated").into()); - }; + let mut bytes = Vec::new(); + let mut consumed = 0usize; - let value = String::from_utf8(bytes[..end].to_vec()) - .map_err(|_| CodecError::InvalidUtf8 { field_name })?; - Ok((value, end + 1)) + while *remaining != 0 { + let byte = read_exact_array_untrusted::<1, _>(reader).map_err(CodecError::Io)?[0]; + *remaining -= 1; + consumed += 1; + if byte == 0 { + return Ok((decode_utf8_owned(field_name, bytes)?, consumed)); + } + bytes.push(byte); + } + + Err(invalid_value(field_name, "string is not NUL-terminated").into()) } -fn looks_like_missing_elng_full_box_header(bytes: &[u8]) -> bool { - let Some(end) = bytes.iter().position(|byte| *byte == 0) else { - return false; - }; +#[cfg(feature = "async")] +async fn read_required_c_string_from_reader_async( + reader: &mut dyn AsyncReadSeek, + remaining: &mut u64, + field_name: &'static str, +) -> Result<(String, usize), CodecError> { + let mut bytes = Vec::new(); + let mut consumed = 0usize; - end > 0 - && bytes[end..].iter().all(|byte| *byte == 0) - && bytes[..end] - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') + while *remaining != 0 { + let byte = read_exact_array_untrusted_async::<1, _>(reader) + .await + .map_err(CodecError::Io)?[0]; + *remaining -= 1; + consumed += 1; + if byte == 0 { + return Ok((decode_utf8_owned(field_name, bytes)?, consumed)); + } + bytes.push(byte); + } + + Err(invalid_value(field_name, "string is not NUL-terminated").into()) } -/// Extended-language box carried alongside `mdhd` when a track uses a language tag that does not -/// fit the compact ISO-639-2 code stored in the media header. -#[derive(Clone, Debug, Default, PartialEq, Eq)] +fn try_decode_legacy_elng_from_reader( + reader: &mut dyn ReadSeek, + start: u64, + payload_size: u64, +) -> Result, CodecError> { + reader + .seek(SeekFrom::Start(start)) + .map_err(CodecError::Io)?; + let mut remaining = payload_size; + let mut bytes = Vec::new(); + let mut seen_zero = false; + let mut trailing_all_zero = true; + + while remaining != 0 { + let byte = read_exact_array_untrusted::<1, _>(reader).map_err(CodecError::Io)?[0]; + remaining -= 1; + if seen_zero { + trailing_all_zero &= byte == 0; + continue; + } + if byte == 0 { + seen_zero = true; + continue; + } + bytes.push(byte); + } + + if seen_zero + && !bytes.is_empty() + && trailing_all_zero + && bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') + { + return Ok(Some(decode_utf8_owned("ExtendedLanguage", bytes)?)); + } + + Ok(None) +} + +#[cfg(feature = "async")] +async fn try_decode_legacy_elng_from_reader_async( + reader: &mut dyn AsyncReadSeek, + start: u64, + payload_size: u64, +) -> Result, CodecError> { + reader + .seek(SeekFrom::Start(start)) + .await + .map_err(CodecError::Io)?; + let mut remaining = payload_size; + let mut bytes = Vec::new(); + let mut seen_zero = false; + let mut trailing_all_zero = true; + + while remaining != 0 { + let byte = read_exact_array_untrusted_async::<1, _>(reader) + .await + .map_err(CodecError::Io)?[0]; + remaining -= 1; + if seen_zero { + trailing_all_zero &= byte == 0; + continue; + } + if byte == 0 { + seen_zero = true; + continue; + } + bytes.push(byte); + } + + if seen_zero + && !bytes.is_empty() + && trailing_all_zero + && bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') + { + return Ok(Some(decode_utf8_owned("ExtendedLanguage", bytes)?)); + } + + Ok(None) +} + +fn read_optional_c_string_tail_from_reader( + reader: &mut dyn ReadSeek, + remaining: u64, + field_name: &'static str, +) -> Result { + let mut bytes = Vec::new(); + let mut trailing = remaining; + let mut seen_zero = false; + + while trailing != 0 { + let byte = read_exact_array_untrusted::<1, _>(reader).map_err(CodecError::Io)?[0]; + trailing -= 1; + if seen_zero { + continue; + } + if byte == 0 { + seen_zero = true; + continue; + } + bytes.push(byte); + } + + decode_utf8_owned(field_name, bytes) +} + +#[cfg(feature = "async")] +async fn read_optional_c_string_tail_from_reader_async( + reader: &mut dyn AsyncReadSeek, + remaining: u64, + field_name: &'static str, +) -> Result { + let mut bytes = Vec::new(); + let mut trailing = remaining; + let mut seen_zero = false; + + while trailing != 0 { + let byte = read_exact_array_untrusted_async::<1, _>(reader) + .await + .map_err(CodecError::Io)?[0]; + trailing -= 1; + if seen_zero { + continue; + } + if byte == 0 { + seen_zero = true; + continue; + } + bytes.push(byte); + } + + decode_utf8_owned(field_name, bytes) +} + +/// Extended-language box carried alongside `mdhd` when a track uses a language tag that does not +/// fit the compact ISO-639-2 code stored in the media header. +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Elng { full_box: FullBoxState, pub extended_language: String, @@ -3048,23 +3946,21 @@ impl CodecBox for Elng { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, CodecError> { - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; - - if (payload.len() < 4 || !payload.starts_with(&[0, 0, 0, 0])) - && looks_like_missing_elng_full_box_header(&payload) - { + let start = reader.stream_position().map_err(CodecError::Io)?; + if let Some(language) = try_decode_legacy_elng_from_reader(reader, start, payload_size)? { self.full_box = FullBoxState::default(); - self.extended_language = decode_c_string("ExtendedLanguage", &payload)?; + self.extended_language = language; self.missing_full_box_header = true; return Ok(Some(payload_size)); } - - if payload.len() < 4 { + if payload_size < 4 { return Err(invalid_value("Payload", "payload is too short").into()); } + reader + .seek(SeekFrom::Start(start)) + .map_err(CodecError::Io)?; + let payload = read_exact_array_untrusted::<4, _>(reader).map_err(CodecError::Io)?; let version = payload[0]; if version != 0 { return Err(CodecError::UnsupportedVersion { @@ -3078,10 +3974,63 @@ impl CodecBox for Elng { } self.full_box = FullBoxState { version, flags }; - self.extended_language = decode_c_string("ExtendedLanguage", &payload[4..])?; + self.extended_language = + read_optional_c_string_tail_from_reader(reader, payload_size - 4, "ExtendedLanguage")?; self.missing_full_box_header = false; Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + let start = reader.stream_position().await.map_err(CodecError::Io)?; + if let Some(language) = + try_decode_legacy_elng_from_reader_async(reader, start, payload_size).await? + { + self.full_box = FullBoxState::default(); + self.extended_language = language; + self.missing_full_box_header = true; + return Ok(Some(payload_size)); + } + + if payload_size < 4 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + reader + .seek(SeekFrom::Start(start)) + .await + .map_err(CodecError::Io)?; + let payload = read_exact_array_untrusted_async::<4, _>(reader) + .await + .map_err(CodecError::Io)?; + let version = payload[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + let flags = read_uint(&payload, 1, 3) as u32; + if flags != 0 { + return Err(invalid_value("Flags", "non-zero flags are not supported").into()); + } + + self.full_box = FullBoxState { version, flags }; + self.extended_language = read_optional_c_string_tail_from_reader_async( + reader, + payload_size - 4, + "ExtendedLanguage", + ) + .await?; + self.missing_full_box_header = false; + Ok(Some(payload_size)) + }) + } } /// Movie header box. @@ -5865,15 +6814,12 @@ impl CodecBox for Kind { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, CodecError> { - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; - - if payload.len() < 6 { + if payload_size < 6 { return Err(invalid_value("Payload", "payload is too short").into()); } - let version = payload[0]; + let header = read_exact_array_untrusted::<4, _>(reader).map_err(CodecError::Io)?; + let version = header[0]; if version != 0 { return Err(CodecError::UnsupportedVersion { box_type: self.box_type(), @@ -5881,21 +6827,64 @@ impl CodecBox for Kind { }); } - let (scheme_uri, scheme_len) = decode_required_c_string("SchemeURI", &payload[4..])?; - let value_offset = 4 + scheme_len; - let (value, value_len) = decode_required_c_string("Value", &payload[value_offset..])?; - if value_offset + value_len != payload.len() { + let mut remaining = payload_size - 4; + let (scheme_uri, _) = + read_required_c_string_from_reader(reader, &mut remaining, "SchemeURI")?; + let (value, _) = read_required_c_string_from_reader(reader, &mut remaining, "Value")?; + if remaining != 0 { return Err(invalid_value("Payload", "payload has trailing bytes").into()); } self.full_box = FullBoxState { version, - flags: read_uint(&payload, 1, 3) as u32, + flags: u32::from_be_bytes([0, header[1], header[2], header[3]]), }; self.scheme_uri = scheme_uri; self.value = value; Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size < 6 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + let header = read_exact_array_untrusted_async::<4, _>(reader) + .await + .map_err(CodecError::Io)?; + let version = header[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + let mut remaining = payload_size - 4; + let (scheme_uri, _) = + read_required_c_string_from_reader_async(reader, &mut remaining, "SchemeURI") + .await?; + let (value, _) = + read_required_c_string_from_reader_async(reader, &mut remaining, "Value").await?; + if remaining != 0 { + return Err(invalid_value("Payload", "payload has trailing bytes").into()); + } + + self.full_box = FullBoxState { + version, + flags: u32::from_be_bytes([0, header[1], header[2], header[3]]), + }; + self.scheme_uri = scheme_uri; + self.value = value; + Ok(Some(payload_size)) + }) + } } /// MIME metadata box that preserves whether the payload omitted the trailing NUL byte. @@ -6000,15 +6989,12 @@ impl CodecBox for Mime { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, CodecError> { - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; - - if payload.len() < 5 { + if payload_size < 5 { return Err(invalid_value("Payload", "payload is too short").into()); } - let version = payload[0]; + let header = read_exact_array_untrusted::<4, _>(reader).map_err(CodecError::Io)?; + let version = header[0]; if version != 0 { return Err(CodecError::UnsupportedVersion { box_type: self.box_type(), @@ -6016,28 +7002,87 @@ impl CodecBox for Mime { }); } - let content_bytes = if payload.last() == Some(&0) { - self.lacks_zero_termination = false; - &payload[4..payload.len() - 1] + let mut content_bytes = if payload_size == 4 { + Vec::new() } else { - self.lacks_zero_termination = true; - &payload[4..] + read_exact_vec_untrusted( + reader, + usize::try_from(payload_size - 4) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?, + ) + .map_err(CodecError::Io)? }; + let has_terminator = content_bytes.last() == Some(&0); + self.lacks_zero_termination = !has_terminator; + if has_terminator { + content_bytes.pop(); + } if content_bytes.contains(&0) { return Err(invalid_value("ContentType", "value must not contain NUL bytes").into()); } self.full_box = FullBoxState { version, - flags: read_uint(&payload, 1, 3) as u32, + flags: u32::from_be_bytes([0, header[1], header[2], header[3]]), }; - self.content_type = - String::from_utf8(content_bytes.to_vec()).map_err(|_| CodecError::InvalidUtf8 { - field_name: "ContentType", - })?; + self.content_type = decode_utf8_owned("ContentType", content_bytes)?; Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size < 5 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + let header = read_exact_array_untrusted_async::<4, _>(reader) + .await + .map_err(CodecError::Io)?; + let version = header[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + let mut content_bytes = if payload_size == 4 { + Vec::new() + } else { + read_exact_vec_untrusted_async( + reader, + usize::try_from(payload_size - 4) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?, + ) + .await + .map_err(CodecError::Io)? + }; + + let has_terminator = content_bytes.last() == Some(&0); + self.lacks_zero_termination = !has_terminator; + if has_terminator { + content_bytes.pop(); + } + if content_bytes.contains(&0) { + return Err( + invalid_value("ContentType", "value must not contain NUL bytes").into(), + ); + } + + self.full_box = FullBoxState { + version, + flags: u32::from_be_bytes([0, header[1], header[2], header[3]]), + }; + self.content_type = decode_utf8_owned("ContentType", content_bytes)?; + Ok(Some(payload_size)) + }) + } } /// Handler reference box. @@ -6874,6 +7919,82 @@ impl CodecBox for Sdtp { const SUPPORTED_VERSIONS: &'static [u8] = &[0]; } +/// Sample padding bits box. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Padb { + full_box: FullBoxState, + pub sample_count: u32, + pub padding_bits: Vec, +} + +impl FieldHooks for Padb { + fn field_length(&self, name: &'static str) -> Option { + match name { + "PaddingBits" => usize::try_from(self.sample_count) + .ok() + .and_then(|count| count.checked_add(1)) + .map(|count| count / 2) + .and_then(|count| field_len_bytes(count, 1)), + _ => None, + } + } + + fn display_field(&self, name: &'static str) -> Option { + match name { + "PaddingBits" => Some(quote_bytes(&self.padding_bits)), + _ => None, + } + } +} + +impl_full_box!(Padb, *b"padb"); + +impl FieldValueRead for Padb { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "SampleCount" => Ok(FieldValue::Unsigned(u64::from(self.sample_count))), + "PaddingBits" => Ok(FieldValue::Bytes(self.padding_bits.clone())), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Padb { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("SampleCount", FieldValue::Unsigned(value)) => { + self.sample_count = u32_from_unsigned(field_name, value)?; + Ok(()) + } + ("PaddingBits", FieldValue::Bytes(bytes)) => { + self.padding_bits = bytes; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Padb { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("Version", 0, with_bit_width(8), as_version_field()), + codec_field!("Flags", 1, with_bit_width(24), as_flags_field()), + codec_field!("SampleCount", 2, with_bit_width(32)), + codec_field!( + "PaddingBits", + 3, + with_bit_width(8), + with_dynamic_length(), + as_bytes() + ), + ]); + const SUPPORTED_VERSIONS: &'static [u8] = &[0]; +} + /// Length-prefixed roll-distance description. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct RollDistanceWithLength { @@ -9241,6 +10362,92 @@ fn parse_silb_schemes( Ok(schemes) } +fn read_silb_schemes_from_reader( + field_name: &'static str, + reader: &mut dyn ReadSeek, + payload_len: u64, + scheme_count: u32, +) -> Result, CodecError> { + let mut remaining = payload_len; + let mut schemes = Vec::with_capacity(untrusted_prealloc_hint( + usize::try_from(scheme_count).unwrap_or(0), + )); + + for _ in 0..scheme_count { + let (scheme_id_uri, _) = + read_required_c_string_from_reader(reader, &mut remaining, field_name)?; + let (value, _) = read_required_c_string_from_reader(reader, &mut remaining, field_name)?; + if remaining == 0 { + return Err(invalid_value(field_name, "scheme flag payload is truncated").into()); + } + let at_least_one_flag = match read_exact_array_untrusted::<1, _>(reader)?[0] { + 0 => false, + 1 => true, + _ => return Err(invalid_value(field_name, "scheme flag byte must be 0 or 1").into()), + }; + remaining -= 1; + schemes.push(SilbEntry { + scheme_id_uri, + value, + at_least_one_flag, + }); + } + + if remaining != 0 { + return Err(invalid_value( + field_name, + "scheme payload length does not match the scheme count", + ) + .into()); + } + + Ok(schemes) +} + +#[cfg(feature = "async")] +async fn read_silb_schemes_from_reader_async( + field_name: &'static str, + reader: &mut dyn AsyncReadSeek, + payload_len: u64, + scheme_count: u32, +) -> Result, CodecError> { + let mut remaining = payload_len; + let mut schemes = Vec::with_capacity(untrusted_prealloc_hint( + usize::try_from(scheme_count).unwrap_or(0), + )); + + for _ in 0..scheme_count { + let (scheme_id_uri, _) = + read_required_c_string_from_reader_async(reader, &mut remaining, field_name).await?; + let (value, _) = + read_required_c_string_from_reader_async(reader, &mut remaining, field_name).await?; + if remaining == 0 { + return Err(invalid_value(field_name, "scheme flag payload is truncated").into()); + } + let at_least_one_flag = match read_exact_array_untrusted_async::<1, _>(reader).await?[0] { + 0 => false, + 1 => true, + _ => return Err(invalid_value(field_name, "scheme flag byte must be 0 or 1").into()), + }; + remaining -= 1; + schemes.push(SilbEntry { + scheme_id_uri, + value, + at_least_one_flag, + }); + } + + if remaining != 0 { + return Err(invalid_value( + field_name, + "scheme payload length does not match the scheme count", + ) + .into()); + } + + Ok(schemes) +} + impl FieldHooks for Silb { fn display_field(&self, name: &'static str) -> Option { match name { @@ -9328,15 +10535,12 @@ impl CodecBox for Silb { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, CodecError> { - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; - - if payload.len() < 9 { + if payload_size < 9 { return Err(invalid_value("Payload", "payload is too short").into()); } - let version = payload[0]; + let header = read_exact_array_untrusted::<8, _>(reader).map_err(CodecError::Io)?; + let version = header[0]; if version != 0 { return Err(CodecError::UnsupportedVersion { box_type: self.box_type(), @@ -9344,35 +10548,93 @@ impl CodecBox for Silb { }); } - let other_schemes_flag = match payload[payload.len() - 1] { + let scheme_payload_len = payload_size - 9; + self.full_box = FullBoxState { + version, + flags: u32::from_be_bytes([0, header[1], header[2], header[3]]), + }; + self.scheme_count = read_u32(&header, 4); + self.schemes = read_silb_schemes_from_reader( + "Schemes", + reader, + scheme_payload_len, + self.scheme_count, + )?; + let other_schemes_flag = match read_exact_array_untrusted::<1, _>(reader) + .map_err(CodecError::Io)?[0] + { 0 => false, 1 => true, _ => { return Err(invalid_value("OtherSchemesFlag", "flag byte must be 0 or 1").into()); } }; - - self.full_box = FullBoxState { - version, - flags: read_uint(&payload, 1, 3) as u32, - }; - self.scheme_count = read_u32(&payload, 4); - self.schemes = - parse_silb_schemes("Schemes", self.scheme_count, &payload[8..payload.len() - 1])?; self.other_schemes_flag = other_schemes_flag; Ok(Some(payload_size)) } -} -/// Embedded event-message instance box. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Emib { - full_box: FullBoxState, - pub presentation_time_delta: i64, - pub event_duration: u32, - pub id: u32, - pub scheme_id_uri: String, + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size < 9 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + let header = read_exact_array_untrusted_async::<8, _>(reader) + .await + .map_err(CodecError::Io)?; + let version = header[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + let scheme_payload_len = payload_size - 9; + self.full_box = FullBoxState { + version, + flags: u32::from_be_bytes([0, header[1], header[2], header[3]]), + }; + self.scheme_count = read_u32(&header, 4); + self.schemes = read_silb_schemes_from_reader_async( + "Schemes", + reader, + scheme_payload_len, + self.scheme_count, + ) + .await?; + let other_schemes_flag = match read_exact_array_untrusted_async::<1, _>(reader) + .await + .map_err(CodecError::Io)?[0] + { + 0 => false, + 1 => true, + _ => { + return Err( + invalid_value("OtherSchemesFlag", "flag byte must be 0 or 1").into(), + ); + } + }; + self.other_schemes_flag = other_schemes_flag; + Ok(Some(payload_size)) + }) + } +} + +/// Embedded event-message instance box. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Emib { + full_box: FullBoxState, + pub presentation_time_delta: i64, + pub event_duration: u32, + pub id: u32, + pub scheme_id_uri: String, pub value: String, pub message_data: Vec, } @@ -9496,15 +10758,12 @@ impl CodecBox for Emib { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, CodecError> { - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; - - if payload.len() < 24 { + if payload_size < 24 { return Err(invalid_value("Payload", "payload is too short").into()); } - let version = payload[0]; + let header = read_exact_array_untrusted::<24, _>(reader).map_err(CodecError::Io)?; + let version = header[0]; if version != 0 { return Err(CodecError::UnsupportedVersion { box_type: self.box_type(), @@ -9512,28 +10771,96 @@ impl CodecBox for Emib { }); } - if read_u32(&payload, 4) != 0 { + if read_u32(&header, 4) != 0 { return Err(invalid_value("Reserved", "reserved field must be zero").into()); } - let (scheme_id_uri, scheme_len) = decode_required_c_string("SchemeIdUri", &payload[24..])?; - let value_offset = 24 + scheme_len; - let (value, value_len) = decode_required_c_string("Value", &payload[value_offset..])?; - let message_offset = value_offset + value_len; + let mut remaining = payload_size - 24; + let (scheme_id_uri, _) = + read_required_c_string_from_reader(reader, &mut remaining, "SchemeIdUri")?; + let (value, _) = read_required_c_string_from_reader(reader, &mut remaining, "Value")?; + let message_data = if remaining == 0 { + Vec::new() + } else { + read_exact_vec_untrusted( + reader, + usize::try_from(remaining) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?, + ) + .map_err(CodecError::Io)? + }; self.full_box = FullBoxState { version, - flags: read_uint(&payload, 1, 3) as u32, + flags: u32::from_be_bytes([0, header[1], header[2], header[3]]), }; - self.presentation_time_delta = read_i64(&payload, 8); - self.event_duration = read_u32(&payload, 16); - self.id = read_u32(&payload, 20); + self.presentation_time_delta = read_i64(&header, 8); + self.event_duration = read_u32(&header, 16); + self.id = read_u32(&header, 20); self.scheme_id_uri = scheme_id_uri; self.value = value; - self.message_data = payload[message_offset..].to_vec(); + self.message_data = message_data; Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size < 24 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + let header = read_exact_array_untrusted_async::<24, _>(reader) + .await + .map_err(CodecError::Io)?; + let version = header[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + if read_u32(&header, 4) != 0 { + return Err(invalid_value("Reserved", "reserved field must be zero").into()); + } + + let mut remaining = payload_size - 24; + let (scheme_id_uri, _) = + read_required_c_string_from_reader_async(reader, &mut remaining, "SchemeIdUri") + .await?; + let (value, _) = + read_required_c_string_from_reader_async(reader, &mut remaining, "Value").await?; + let message_data = if remaining == 0 { + Vec::new() + } else { + read_exact_vec_untrusted_async( + reader, + usize::try_from(remaining) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?, + ) + .await + .map_err(CodecError::Io)? + }; + + self.full_box = FullBoxState { + version, + flags: u32::from_be_bytes([0, header[1], header[2], header[3]]), + }; + self.presentation_time_delta = read_i64(&header, 8); + self.event_duration = read_u32(&header, 16); + self.id = read_u32(&header, 20); + self.scheme_id_uri = scheme_id_uri; + self.value = value; + self.message_data = message_data; + Ok(Some(payload_size)) + }) + } } /// Empty embedded event-message box. @@ -9585,6 +10912,22 @@ impl CodecBox for Emeb { } Ok(Some(0)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + let start = reader.stream_position().await?; + if payload_size != 0 { + reader.seek(SeekFrom::Start(start)).await?; + return Err(invalid_value("Payload", "payload must be empty").into()); + } + Ok(Some(0)) + }) + } } /// Field-ordering leaf used by some video sample entries. @@ -10162,12 +11505,11 @@ impl CodecBox for VisualSampleEntry { const VISUAL_SAMPLE_ENTRY_HEADER_SIZE: usize = 78; let start = reader.stream_position()?; - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; - if payload.len() < VISUAL_SAMPLE_ENTRY_HEADER_SIZE { + if payload_size < VISUAL_SAMPLE_ENTRY_HEADER_SIZE as u64 { return Err(invalid_value("Payload", "payload is too short").into()); } + let payload = read_exact_array_untrusted::(reader) + .map_err(CodecError::Io)?; if read_u16(&payload, 0) != 0 { return Err(CodecError::ConstantMismatch { @@ -10216,6 +11558,75 @@ impl CodecBox for VisualSampleEntry { ))?; Ok(Some(VISUAL_SAMPLE_ENTRY_HEADER_SIZE as u64)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + const VISUAL_SAMPLE_ENTRY_HEADER_SIZE: usize = 78; + + let start = reader.stream_position().await?; + if payload_size < VISUAL_SAMPLE_ENTRY_HEADER_SIZE as u64 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + let payload = + read_exact_array_untrusted_async::(reader) + .await + .map_err(CodecError::Io)?; + + if read_u16(&payload, 0) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved0A", + constant: "0", + }); + } + if read_u16(&payload, 2) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved0B", + constant: "0", + }); + } + if read_u16(&payload, 4) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved0C", + constant: "0", + }); + } + if read_u16(&payload, 10) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved1", + constant: "0", + }); + } + + self.sample_entry.data_reference_index = read_u16(&payload, 6); + self.pre_defined = read_u16(&payload, 8); + self.pre_defined2 = [ + read_u32(&payload, 12), + read_u32(&payload, 16), + read_u32(&payload, 20), + ]; + self.width = read_u16(&payload, 24); + self.height = read_u16(&payload, 26); + self.horizresolution = read_u32(&payload, 28); + self.vertresolution = read_u32(&payload, 32); + self.reserved2 = read_u32(&payload, 36); + self.frame_count = read_u16(&payload, 40); + self.compressorname = payload[42..74].try_into().unwrap(); + self.depth = read_u16(&payload, 74); + self.pre_defined3 = read_i16(&payload, 76); + + reader + .seek(SeekFrom::Start( + start + VISUAL_SAMPLE_ENTRY_HEADER_SIZE as u64, + )) + .await?; + Ok(Some(VISUAL_SAMPLE_ENTRY_HEADER_SIZE as u64)) + }) + } } pub(crate) fn split_box_children_with_optional_trailing_bytes(bytes: &[u8]) -> usize { @@ -10390,6 +11801,201 @@ impl CodecBox for AudioSampleEntry { with_dynamic_presence() ), ]); + + fn custom_unmarshal( + &mut self, + reader: &mut dyn ReadSeek, + payload_size: u64, + ) -> Result, CodecError> { + const AUDIO_SAMPLE_ENTRY_HEADER_SIZE: usize = 28; + const QUICKTIME_VENDOR_ENTRY_TYPES: [FourCc; 3] = [ + FourCc::from_bytes(*b"ipcm"), + FourCc::from_bytes(*b"fpcm"), + FourCc::from_bytes(*b"spex"), + ]; + + let start = reader.stream_position()?; + if payload_size < AUDIO_SAMPLE_ENTRY_HEADER_SIZE as u64 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + let payload = read_exact_array_untrusted::(reader) + .map_err(CodecError::Io)?; + + if read_u16(&payload, 0) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved0A", + constant: "0", + }); + } + if read_u16(&payload, 2) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved0B", + constant: "0", + }); + } + if read_u16(&payload, 4) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved0C", + constant: "0", + }); + } + + self.sample_entry.data_reference_index = read_u16(&payload, 6); + self.entry_version = read_u16(&payload, 8); + let allow_quicktime_vendor_words = self.entry_version == 0 + && QUICKTIME_VENDOR_ENTRY_TYPES.contains(&self.sample_entry.box_type); + if !allow_quicktime_vendor_words { + if read_u16(&payload, 10) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved1A", + constant: "0", + }); + } + if read_u16(&payload, 12) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved1B", + constant: "0", + }); + } + if read_u16(&payload, 14) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved1C", + constant: "0", + }); + } + if read_u16(&payload, 22) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved2", + constant: "0", + }); + } + } + + self.channel_count = read_u16(&payload, 16); + self.sample_size = read_u16(&payload, 18); + self.pre_defined = read_u16(&payload, 20); + self.sample_rate = read_u32(&payload, 24); + self.quicktime_data = match self.entry_version { + 1 => read_exact_array_untrusted::<16, _>(reader) + .map_err(CodecError::Io)? + .to_vec(), + 2 => read_exact_array_untrusted::<36, _>(reader) + .map_err(CodecError::Io)? + .to_vec(), + _ => Vec::new(), + }; + + let consumed = AUDIO_SAMPLE_ENTRY_HEADER_SIZE + + match self.entry_version { + 1 => 16, + 2 => 36, + _ => 0, + }; + reader.seek(SeekFrom::Start(start + consumed as u64))?; + Ok(Some(consumed as u64)) + } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + const AUDIO_SAMPLE_ENTRY_HEADER_SIZE: usize = 28; + const QUICKTIME_VENDOR_ENTRY_TYPES: [FourCc; 3] = [ + FourCc::from_bytes(*b"ipcm"), + FourCc::from_bytes(*b"fpcm"), + FourCc::from_bytes(*b"spex"), + ]; + + let start = reader.stream_position().await?; + if payload_size < AUDIO_SAMPLE_ENTRY_HEADER_SIZE as u64 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + let payload = + read_exact_array_untrusted_async::(reader) + .await + .map_err(CodecError::Io)?; + + if read_u16(&payload, 0) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved0A", + constant: "0", + }); + } + if read_u16(&payload, 2) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved0B", + constant: "0", + }); + } + if read_u16(&payload, 4) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved0C", + constant: "0", + }); + } + + self.sample_entry.data_reference_index = read_u16(&payload, 6); + self.entry_version = read_u16(&payload, 8); + let allow_quicktime_vendor_words = self.entry_version == 0 + && QUICKTIME_VENDOR_ENTRY_TYPES.contains(&self.sample_entry.box_type); + if !allow_quicktime_vendor_words { + if read_u16(&payload, 10) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved1A", + constant: "0", + }); + } + if read_u16(&payload, 12) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved1B", + constant: "0", + }); + } + if read_u16(&payload, 14) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved1C", + constant: "0", + }); + } + if read_u16(&payload, 22) != 0 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved2", + constant: "0", + }); + } + } + + self.channel_count = read_u16(&payload, 16); + self.sample_size = read_u16(&payload, 18); + self.pre_defined = read_u16(&payload, 20); + self.sample_rate = read_u32(&payload, 24); + self.quicktime_data = match self.entry_version { + 1 => read_exact_array_untrusted_async::<16, _>(reader) + .await + .map_err(CodecError::Io)? + .to_vec(), + 2 => read_exact_array_untrusted_async::<36, _>(reader) + .await + .map_err(CodecError::Io)? + .to_vec(), + _ => Vec::new(), + }; + + let consumed = AUDIO_SAMPLE_ENTRY_HEADER_SIZE + + match self.entry_version { + 1 => 16, + 2 => 36, + _ => 0, + }; + reader + .seek(SeekFrom::Start(start + consumed as u64)) + .await?; + Ok(Some(consumed as u64)) + }) + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -10457,6 +12063,67 @@ impl CodecBox for WaveAudioData { )]); } +#[derive(Clone, Debug, PartialEq, Eq)] +struct OpaqueCodecSpecificData { + box_type: FourCc, + data: Vec, +} + +impl Default for OpaqueCodecSpecificData { + fn default() -> Self { + Self { + box_type: FourCc::ANY, + data: Vec::new(), + } + } +} + +impl FieldHooks for OpaqueCodecSpecificData {} + +impl ImmutableBox for OpaqueCodecSpecificData { + fn box_type(&self) -> FourCc { + self.box_type + } +} + +impl MutableBox for OpaqueCodecSpecificData {} + +impl AnyTypeBox for OpaqueCodecSpecificData { + fn set_box_type(&mut self, box_type: FourCc) { + self.box_type = box_type; + } +} + +impl FieldValueRead for OpaqueCodecSpecificData { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "Data" => Ok(FieldValue::Bytes(self.data.clone())), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for OpaqueCodecSpecificData { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("Data", FieldValue::Bytes(value)) => { + self.data = value; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for OpaqueCodecSpecificData { + const FIELD_TABLE: FieldTable = + FieldTable::new(&[codec_field!("Data", 0, with_bit_width(8), as_bytes())]); +} + /// One length-prefixed AVC parameter-set record carried by `avcC`. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct AVCParameterSet { @@ -10805,10 +12472,11 @@ impl CodecBox for AVCDecoderConfiguration { payload_size: u64, ) -> Result, CodecError> { let start = reader.stream_position()?; - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - let payload = match read_exact_vec_untrusted(reader, payload_len) { - Ok(payload) => payload, + if payload_size < 6 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + let header = match read_exact_array_untrusted::<6, _>(reader) { + Ok(header) => header, Err(error) => { reader.seek(SeekFrom::Start(start))?; return Err(error.into()); @@ -10816,21 +12484,12 @@ impl CodecBox for AVCDecoderConfiguration { }; let parse_result = (|| -> Result<(), CodecError> { - if payload.len() < 6 { - return Err(invalid_value("Payload", "payload is too short").into()); - } - - let mut offset = 0_usize; - self.configuration_version = payload[offset]; - offset += 1; - self.profile = payload[offset]; - offset += 1; - self.profile_compatibility = payload[offset]; - offset += 1; - self.level = payload[offset]; - offset += 1; + self.configuration_version = header[0]; + self.profile = header[1]; + self.profile_compatibility = header[2]; + self.level = header[3]; - let length_size = payload[offset]; + let length_size = header[4]; if length_size >> 2 != 0x3f { return Err(CodecError::ConstantMismatch { field_name: "Reserved", @@ -10838,9 +12497,8 @@ impl CodecBox for AVCDecoderConfiguration { }); } self.length_size_minus_one = length_size & 0x03; - offset += 1; - let sequence_count = payload[offset]; + let sequence_count = header[5]; if sequence_count >> 5 != 0x07 { return Err(CodecError::ConstantMismatch { field_name: "Reserved2", @@ -10848,71 +12506,18 @@ impl CodecBox for AVCDecoderConfiguration { }); } self.num_of_sequence_parameter_sets = sequence_count & 0x1f; - offset += 1; + self.sequence_parameter_sets = read_avc_parameter_sets_from_reader( + "SequenceParameterSets", + reader, + self.num_of_sequence_parameter_sets, + )?; - let sequence_start = offset; - self.sequence_parameter_sets = Vec::with_capacity(untrusted_prealloc_hint( - usize::from(self.num_of_sequence_parameter_sets), - )); - for _ in 0..self.num_of_sequence_parameter_sets { - if payload.len().saturating_sub(offset) < 2 { - return Err(invalid_value( - "SequenceParameterSets", - "parameter-set payload length does not match the entry count", - ) - .into()); - } - let length = read_u16(&payload, offset); - offset += 2; - let end = offset + usize::from(length); - if end > payload.len() { - return Err(invalid_value( - "SequenceParameterSets", - "parameter-set payload length does not match the entry count", - ) - .into()); - } - self.sequence_parameter_sets.push(AVCParameterSet { - length, - nal_unit: payload[offset..end].to_vec(), - }); - offset = end; - } - let _ = sequence_start; - - if offset >= payload.len() { - return Err(invalid_value("Payload", "payload is too short").into()); - } - self.num_of_picture_parameter_sets = payload[offset]; - offset += 1; - - self.picture_parameter_sets = Vec::with_capacity(untrusted_prealloc_hint(usize::from( + self.num_of_picture_parameter_sets = read_exact_array_untrusted::<1, _>(reader)?[0]; + self.picture_parameter_sets = read_avc_parameter_sets_from_reader( + "PictureParameterSets", + reader, self.num_of_picture_parameter_sets, - ))); - for _ in 0..self.num_of_picture_parameter_sets { - if payload.len().saturating_sub(offset) < 2 { - return Err(invalid_value( - "PictureParameterSets", - "parameter-set payload length does not match the entry count", - ) - .into()); - } - let length = read_u16(&payload, offset); - offset += 2; - let end = offset + usize::from(length); - if end > payload.len() { - return Err(invalid_value( - "PictureParameterSets", - "parameter-set payload length does not match the entry count", - ) - .into()); - } - self.picture_parameter_sets.push(AVCParameterSet { - length, - nal_unit: payload[offset..end].to_vec(), - }); - offset = end; - } + )?; self.high_profile_fields_enabled = false; self.chroma_format = 0; @@ -10921,7 +12526,7 @@ impl CodecBox for AVCDecoderConfiguration { self.num_of_sequence_parameter_set_ext = 0; self.sequence_parameter_sets_ext.clear(); - let remaining = payload.len().saturating_sub(offset); + let remaining = payload_size - (reader.stream_position()? - start); if avc_profile_supports_extensions(self.profile) && remaining != 0 { if remaining < 4 { return Err(invalid_value("Payload", "payload is truncated").into()); @@ -10929,7 +12534,7 @@ impl CodecBox for AVCDecoderConfiguration { self.high_profile_fields_enabled = true; - let chroma_format = payload[offset]; + let chroma_format = read_exact_array_untrusted::<1, _>(reader)?[0]; if chroma_format >> 2 != 0x3f { return Err(CodecError::ConstantMismatch { field_name: "Reserved3", @@ -10937,9 +12542,8 @@ impl CodecBox for AVCDecoderConfiguration { }); } self.chroma_format = chroma_format & 0x03; - offset += 1; - let bit_depth_luma = payload[offset]; + let bit_depth_luma = read_exact_array_untrusted::<1, _>(reader)?[0]; if bit_depth_luma >> 3 != 0x1f { return Err(CodecError::ConstantMismatch { field_name: "Reserved4", @@ -10947,9 +12551,8 @@ impl CodecBox for AVCDecoderConfiguration { }); } self.bit_depth_luma_minus8 = bit_depth_luma & 0x07; - offset += 1; - let bit_depth_chroma = payload[offset]; + let bit_depth_chroma = read_exact_array_untrusted::<1, _>(reader)?[0]; if bit_depth_chroma >> 3 != 0x1f { return Err(CodecError::ConstantMismatch { field_name: "Reserved5", @@ -10957,41 +12560,17 @@ impl CodecBox for AVCDecoderConfiguration { }); } self.bit_depth_chroma_minus8 = bit_depth_chroma & 0x07; - offset += 1; - - self.num_of_sequence_parameter_set_ext = payload[offset]; - offset += 1; - self.sequence_parameter_sets_ext = Vec::with_capacity(untrusted_prealloc_hint( - usize::from(self.num_of_sequence_parameter_set_ext), - )); - for _ in 0..self.num_of_sequence_parameter_set_ext { - if payload.len().saturating_sub(offset) < 2 { - return Err(invalid_value( - "SequenceParameterSetsExt", - "parameter-set payload length does not match the entry count", - ) - .into()); - } - let length = read_u16(&payload, offset); - offset += 2; - let end = offset + usize::from(length); - if end > payload.len() { - return Err(invalid_value( - "SequenceParameterSetsExt", - "parameter-set payload length does not match the entry count", - ) - .into()); - } - self.sequence_parameter_sets_ext.push(AVCParameterSet { - length, - nal_unit: payload[offset..end].to_vec(), - }); - offset = end; - } + self.num_of_sequence_parameter_set_ext = + read_exact_array_untrusted::<1, _>(reader)?[0]; + self.sequence_parameter_sets_ext = read_avc_parameter_sets_from_reader( + "SequenceParameterSetsExt", + reader, + self.num_of_sequence_parameter_set_ext, + )?; } - if offset != payload.len() { + if reader.stream_position()? - start != payload_size { return Err(invalid_value("Payload", "payload has trailing bytes").into()); } @@ -11005,6 +12584,133 @@ impl CodecBox for AVCDecoderConfiguration { Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + let start = reader.stream_position().await?; + if payload_size < 6 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + let header = match read_exact_array_untrusted_async::<6, _>(reader).await { + Ok(header) => header, + Err(error) => { + reader.seek(SeekFrom::Start(start)).await?; + return Err(error.into()); + } + }; + let parse_result = async { + self.configuration_version = header[0]; + self.profile = header[1]; + self.profile_compatibility = header[2]; + self.level = header[3]; + + let length_size = header[4]; + if length_size >> 2 != 0x3f { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved", + constant: "63", + }); + } + self.length_size_minus_one = length_size & 0x03; + + let sequence_count = header[5]; + if sequence_count >> 5 != 0x07 { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved2", + constant: "7", + }); + } + self.num_of_sequence_parameter_sets = sequence_count & 0x1f; + self.sequence_parameter_sets = read_avc_parameter_sets_from_reader_async( + "SequenceParameterSets", + reader, + self.num_of_sequence_parameter_sets, + ) + .await?; + + self.num_of_picture_parameter_sets = + read_exact_array_untrusted_async::<1, _>(reader).await?[0]; + self.picture_parameter_sets = read_avc_parameter_sets_from_reader_async( + "PictureParameterSets", + reader, + self.num_of_picture_parameter_sets, + ) + .await?; + + self.high_profile_fields_enabled = false; + self.chroma_format = 0; + self.bit_depth_luma_minus8 = 0; + self.bit_depth_chroma_minus8 = 0; + self.num_of_sequence_parameter_set_ext = 0; + self.sequence_parameter_sets_ext.clear(); + + let remaining = payload_size - (reader.stream_position().await? - start); + if avc_profile_supports_extensions(self.profile) && remaining != 0 { + if remaining < 4 { + return Err(invalid_value("Payload", "payload is truncated").into()); + } + + self.high_profile_fields_enabled = true; + + let chroma_format = read_exact_array_untrusted_async::<1, _>(reader).await?[0]; + if chroma_format >> 2 != 0x3f { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved3", + constant: "63", + }); + } + self.chroma_format = chroma_format & 0x03; + + let bit_depth_luma = read_exact_array_untrusted_async::<1, _>(reader).await?[0]; + if bit_depth_luma >> 3 != 0x1f { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved4", + constant: "31", + }); + } + self.bit_depth_luma_minus8 = bit_depth_luma & 0x07; + + let bit_depth_chroma = + read_exact_array_untrusted_async::<1, _>(reader).await?[0]; + if bit_depth_chroma >> 3 != 0x1f { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved5", + constant: "31", + }); + } + self.bit_depth_chroma_minus8 = bit_depth_chroma & 0x07; + + self.num_of_sequence_parameter_set_ext = + read_exact_array_untrusted_async::<1, _>(reader).await?[0]; + self.sequence_parameter_sets_ext = read_avc_parameter_sets_from_reader_async( + "SequenceParameterSetsExt", + reader, + self.num_of_sequence_parameter_set_ext, + ) + .await?; + } + + if reader.stream_position().await? - start != payload_size { + return Err(invalid_value("Payload", "payload has trailing bytes").into()); + } + + Ok(()) + } + .await; + + if let Err(error) = parse_result { + reader.seek(SeekFrom::Start(start)).await?; + return Err(error); + } + + Ok(Some(payload_size)) + }) + } } /// One length-prefixed HEVC NAL-unit record carried by `hvcC`. @@ -11252,8 +12958,8 @@ impl CodecBox for HEVCDecoderConfiguration { codec_field!("BitDepthChromaMinus8", 11, with_bit_width(3), as_hex()), codec_field!("AvgFrameRate", 12, with_bit_width(16)), codec_field!("ConstantFrameRate", 13, with_bit_width(2), as_hex()), - codec_field!("NumTemporalLayers", 14, with_bit_width(2), as_hex()), - codec_field!("TemporalIdNested", 15, with_bit_width(2), as_hex()), + codec_field!("NumTemporalLayers", 14, with_bit_width(3), as_hex()), + codec_field!("TemporalIdNested", 15, with_bit_width(1), as_hex()), codec_field!("LengthSizeMinusOne", 16, with_bit_width(2), as_hex()), codec_field!("NumOfNaluArrays", 17, with_bit_width(8), as_hex()), codec_field!( @@ -11298,11 +13004,11 @@ impl CodecBox for HEVCDecoderConfiguration { if self.constant_frame_rate > 0x03 { return Err(invalid_value("ConstantFrameRate", "value does not fit in 2 bits").into()); } - if self.num_temporal_layers > 0x03 { - return Err(invalid_value("NumTemporalLayers", "value does not fit in 2 bits").into()); + if self.num_temporal_layers > 0x07 { + return Err(invalid_value("NumTemporalLayers", "value does not fit in 3 bits").into()); } - if self.temporal_id_nested > 0x03 { - return Err(invalid_value("TemporalIdNested", "value does not fit in 2 bits").into()); + if self.temporal_id_nested > 0x01 { + return Err(invalid_value("TemporalIdNested", "value does not fit in 1 bit").into()); } if self.length_size_minus_one > 0x03 { return Err(invalid_value("LengthSizeMinusOne", "value does not fit in 2 bits").into()); @@ -11327,7 +13033,7 @@ impl CodecBox for HEVCDecoderConfiguration { )); payload.extend_from_slice(&self.general_constraint_indicator); payload.push(self.general_level_idc); - payload.extend_from_slice(&(0xe000 | self.min_spatial_segmentation_idc).to_be_bytes()); + payload.extend_from_slice(&(0xf000 | self.min_spatial_segmentation_idc).to_be_bytes()); payload.push(0xfc | self.parallelism_type); payload.push(0xfc | self.chroma_format_idc); payload.push(0xf8 | self.bit_depth_luma_minus8); @@ -11335,7 +13041,7 @@ impl CodecBox for HEVCDecoderConfiguration { payload.extend_from_slice(&self.avg_frame_rate.to_be_bytes()); payload.push( (self.constant_frame_rate << 6) - | (self.num_temporal_layers << 4) + | (self.num_temporal_layers << 3) | (self.temporal_id_nested << 2) | self.length_size_minus_one, ); @@ -11352,53 +13058,42 @@ impl CodecBox for HEVCDecoderConfiguration { payload_size: u64, ) -> Result, CodecError> { let start = reader.stream_position()?; - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - let payload = match read_exact_vec_untrusted(reader, payload_len) { - Ok(payload) => payload, + if payload_size < 23 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + let header = match read_exact_array_untrusted::<23, _>(reader) { + Ok(header) => header, Err(error) => { reader.seek(SeekFrom::Start(start))?; return Err(error.into()); } }; - let parse_result = (|| -> Result<(), CodecError> { - if payload.len() < 23 { - return Err(invalid_value("Payload", "payload is too short").into()); - } - - let mut offset = 0_usize; - self.configuration_version = payload[offset]; - offset += 1; + self.configuration_version = header[0]; - let profile_header = payload[offset]; + let profile_header = header[1]; self.general_profile_space = profile_header >> 6; self.general_tier_flag = profile_header & 0x20 != 0; self.general_profile_idc = profile_header & 0x1f; - offset += 1; - let profile_compatibility: [u8; 4] = payload[offset..offset + 4].try_into().unwrap(); + let profile_compatibility: [u8; 4] = header[2..6].try_into().unwrap(); self.general_profile_compatibility = unpack_hevc_profile_compatibility(&profile_compatibility); - offset += 4; - self.general_constraint_indicator = payload[offset..offset + 6].try_into().unwrap(); - offset += 6; + self.general_constraint_indicator = header[6..12].try_into().unwrap(); - self.general_level_idc = payload[offset]; - offset += 1; + self.general_level_idc = header[12]; - let segmentation = read_u16(&payload, offset); - if segmentation >> 12 != 0x0e { + let segmentation = read_u16(&header, 13); + if segmentation >> 12 != 0x0f { return Err(CodecError::ConstantMismatch { field_name: "Reserved1", - constant: "14", + constant: "15", }); } self.min_spatial_segmentation_idc = segmentation & 0x0fff; - offset += 2; - let parallelism = payload[offset]; + let parallelism = header[15]; if parallelism >> 2 != 0x3f { return Err(CodecError::ConstantMismatch { field_name: "Reserved2", @@ -11406,9 +13101,8 @@ impl CodecBox for HEVCDecoderConfiguration { }); } self.parallelism_type = parallelism & 0x03; - offset += 1; - let chroma_format = payload[offset]; + let chroma_format = header[16]; if chroma_format >> 2 != 0x3f { return Err(CodecError::ConstantMismatch { field_name: "Reserved3", @@ -11416,9 +13110,8 @@ impl CodecBox for HEVCDecoderConfiguration { }); } self.chroma_format_idc = chroma_format & 0x03; - offset += 1; - let bit_depth_luma = payload[offset]; + let bit_depth_luma = header[17]; if bit_depth_luma >> 3 != 0x1f { return Err(CodecError::ConstantMismatch { field_name: "Reserved4", @@ -11426,9 +13119,8 @@ impl CodecBox for HEVCDecoderConfiguration { }); } self.bit_depth_luma_minus8 = bit_depth_luma & 0x07; - offset += 1; - let bit_depth_chroma = payload[offset]; + let bit_depth_chroma = header[18]; if bit_depth_chroma >> 3 != 0x1f { return Err(CodecError::ConstantMismatch { field_name: "Reserved5", @@ -11436,23 +13128,22 @@ impl CodecBox for HEVCDecoderConfiguration { }); } self.bit_depth_chroma_minus8 = bit_depth_chroma & 0x07; - offset += 1; - self.avg_frame_rate = read_u16(&payload, offset); - offset += 2; + self.avg_frame_rate = read_u16(&header, 19); - let layer_header = payload[offset]; + let layer_header = header[21]; self.constant_frame_rate = layer_header >> 6; - self.num_temporal_layers = (layer_header >> 4) & 0x03; - self.temporal_id_nested = (layer_header >> 2) & 0x03; + self.num_temporal_layers = (layer_header >> 3) & 0x07; + self.temporal_id_nested = (layer_header >> 2) & 0x01; self.length_size_minus_one = layer_header & 0x03; - offset += 1; - - self.num_of_nalu_arrays = payload[offset]; - offset += 1; + self.num_of_nalu_arrays = header[22]; self.nalu_arrays = - parse_hevc_nalu_arrays("NaluArrays", &payload[offset..], self.num_of_nalu_arrays)?; + read_hevc_nalu_arrays_from_reader("NaluArrays", reader, self.num_of_nalu_arrays)?; + + if reader.stream_position()? - start != payload_size { + return Err(invalid_value("Payload", "payload has trailing bytes").into()); + } Ok(()) })(); @@ -11464,6 +13155,323 @@ impl CodecBox for HEVCDecoderConfiguration { Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + let start = reader.stream_position().await?; + if payload_size < 23 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + let header = match read_exact_array_untrusted_async::<23, _>(reader).await { + Ok(header) => header, + Err(error) => { + reader.seek(SeekFrom::Start(start)).await?; + return Err(error.into()); + } + }; + let parse_result = async { + self.configuration_version = header[0]; + + let profile_header = header[1]; + self.general_profile_space = profile_header >> 6; + self.general_tier_flag = profile_header & 0x20 != 0; + self.general_profile_idc = profile_header & 0x1f; + + let profile_compatibility: [u8; 4] = header[2..6].try_into().unwrap(); + self.general_profile_compatibility = + unpack_hevc_profile_compatibility(&profile_compatibility); + + self.general_constraint_indicator = header[6..12].try_into().unwrap(); + + self.general_level_idc = header[12]; + + let segmentation = read_u16(&header, 13); + if segmentation >> 12 != 0x0f { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved1", + constant: "15", + }); + } + self.min_spatial_segmentation_idc = segmentation & 0x0fff; + + let parallelism = header[15]; + if parallelism >> 2 != 0x3f { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved2", + constant: "63", + }); + } + self.parallelism_type = parallelism & 0x03; + + let chroma_format = header[16]; + if chroma_format >> 2 != 0x3f { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved3", + constant: "63", + }); + } + self.chroma_format_idc = chroma_format & 0x03; + + let bit_depth_luma = header[17]; + if bit_depth_luma >> 3 != 0x1f { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved4", + constant: "31", + }); + } + self.bit_depth_luma_minus8 = bit_depth_luma & 0x07; + + let bit_depth_chroma = header[18]; + if bit_depth_chroma >> 3 != 0x1f { + return Err(CodecError::ConstantMismatch { + field_name: "Reserved5", + constant: "31", + }); + } + self.bit_depth_chroma_minus8 = bit_depth_chroma & 0x07; + + self.avg_frame_rate = read_u16(&header, 19); + + let layer_header = header[21]; + self.constant_frame_rate = layer_header >> 6; + self.num_temporal_layers = (layer_header >> 3) & 0x07; + self.temporal_id_nested = (layer_header >> 2) & 0x01; + self.length_size_minus_one = layer_header & 0x03; + + self.num_of_nalu_arrays = header[22]; + self.nalu_arrays = read_hevc_nalu_arrays_from_reader_async( + "NaluArrays", + reader, + self.num_of_nalu_arrays, + ) + .await?; + + if reader.stream_position().await? - start != payload_size { + return Err(invalid_value("Payload", "payload has trailing bytes").into()); + } + + Ok(()) + } + .await; + + if let Err(error) = parse_result { + reader.seek(SeekFrom::Start(start)).await?; + return Err(error); + } + + Ok(Some(payload_size)) + }) + } +} + +/// Generic media sample entry used by subtitle-style or other non-audio or non-visual handlers. +/// +/// The typed header only carries the shared sample-entry fields. Any codec-specific payload or +/// child boxes remain outside this struct and are encoded through the normal child-box path. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GenericMediaSampleEntry { + pub sample_entry: SampleEntry, +} + +impl FieldHooks for GenericMediaSampleEntry {} + +impl ImmutableBox for GenericMediaSampleEntry { + fn box_type(&self) -> FourCc { + self.sample_entry.box_type + } +} + +impl MutableBox for GenericMediaSampleEntry {} + +impl AnyTypeBox for GenericMediaSampleEntry { + fn set_box_type(&mut self, box_type: FourCc) { + self.sample_entry.box_type = box_type; + } +} + +impl FieldValueRead for GenericMediaSampleEntry { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "DataReferenceIndex" => Ok(FieldValue::Unsigned(u64::from( + self.sample_entry.data_reference_index, + ))), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for GenericMediaSampleEntry { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("DataReferenceIndex", FieldValue::Unsigned(value)) => { + self.sample_entry.data_reference_index = u16_from_unsigned(field_name, value)?; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for GenericMediaSampleEntry { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("Reserved0A", 0, with_bit_width(16), with_constant("0")), + codec_field!("Reserved0B", 1, with_bit_width(16), with_constant("0")), + codec_field!("Reserved0C", 2, with_bit_width(16), with_constant("0")), + codec_field!("DataReferenceIndex", 3, with_bit_width(16)), + ]); +} + +/// Opaque timed-text sample entry used for legacy `text` and `tx3g` carriage. +/// +/// The fixed sample-entry header is preserved, and the remaining payload bytes are carried +/// opaquely because these legacy text entries store non-box inline data after the shared header. +#[derive(Clone, Debug, PartialEq, Eq)] +struct OpaqueTextSampleEntry { + sample_entry: SampleEntry, + data: Vec, +} + +impl Default for OpaqueTextSampleEntry { + fn default() -> Self { + Self { + sample_entry: SampleEntry { + box_type: FourCc::ANY, + data_reference_index: 0, + }, + data: Vec::new(), + } + } +} + +impl FieldHooks for OpaqueTextSampleEntry {} + +impl ImmutableBox for OpaqueTextSampleEntry { + fn box_type(&self) -> FourCc { + self.sample_entry.box_type + } +} + +impl MutableBox for OpaqueTextSampleEntry {} + +impl AnyTypeBox for OpaqueTextSampleEntry { + fn set_box_type(&mut self, box_type: FourCc) { + self.sample_entry.box_type = box_type; + } +} + +impl FieldValueRead for OpaqueTextSampleEntry { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "DataReferenceIndex" => Ok(FieldValue::Unsigned(u64::from( + self.sample_entry.data_reference_index, + ))), + "Data" => Ok(FieldValue::Bytes(self.data.clone())), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for OpaqueTextSampleEntry { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("DataReferenceIndex", FieldValue::Unsigned(value)) => { + self.sample_entry.data_reference_index = u16_from_unsigned(field_name, value)?; + Ok(()) + } + ("Data", FieldValue::Bytes(value)) => { + self.data = value; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for OpaqueTextSampleEntry { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("Reserved0A", 0, with_bit_width(16), with_constant("0")), + codec_field!("Reserved0B", 1, with_bit_width(16), with_constant("0")), + codec_field!("Reserved0C", 2, with_bit_width(16), with_constant("0")), + codec_field!("DataReferenceIndex", 3, with_bit_width(16)), + codec_field!("Data", 4, with_bit_width(8), as_bytes()), + ]); +} + +/// DVB subtitle decoder configuration carried by `dvsC` child boxes under `dvbs`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DvsC { + /// DVB subtitle composition page identifier. + pub composition_page_id: u16, + /// DVB subtitle ancillary page identifier. + pub ancillary_page_id: u16, + /// DVB subtitle service type. + pub subtitle_type: u8, +} + +impl FieldHooks for DvsC {} + +impl ImmutableBox for DvsC { + fn box_type(&self) -> FourCc { + FourCc::from_bytes(*b"dvsC") + } +} + +impl MutableBox for DvsC {} + +impl FieldValueRead for DvsC { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "CompositionPageID" => Ok(FieldValue::Unsigned(u64::from(self.composition_page_id))), + "AncillaryPageID" => Ok(FieldValue::Unsigned(u64::from(self.ancillary_page_id))), + "SubtitleType" => Ok(FieldValue::Unsigned(u64::from(self.subtitle_type))), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for DvsC { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("CompositionPageID", FieldValue::Unsigned(value)) => { + self.composition_page_id = u16_from_unsigned(field_name, value)?; + Ok(()) + } + ("AncillaryPageID", FieldValue::Unsigned(value)) => { + self.ancillary_page_id = u16_from_unsigned(field_name, value)?; + Ok(()) + } + ("SubtitleType", FieldValue::Unsigned(value)) => { + self.subtitle_type = u8_from_unsigned(field_name, value)?; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for DvsC { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("CompositionPageID", 0, with_bit_width(16)), + codec_field!("AncillaryPageID", 1, with_bit_width(16)), + codec_field!("SubtitleType", 2, with_bit_width(8)), + ]); } /// XML subtitle sample entry that stores namespace and schema strings. @@ -11678,6 +13686,14 @@ fn is_quicktime_wave_audio_context(context: BoxLookupContext) -> bool { context.is_quicktime_compatible() && context.under_wave() } +fn is_audio_sample_entry_child_context(context: BoxLookupContext) -> bool { + context.under_audio_sample_entry() +} + +fn is_audio_sample_entry_root_context(context: BoxLookupContext) -> bool { + !context.under_audio_sample_entry() +} + fn matches_audio_sample_entry_context(box_type: FourCc, context: BoxLookupContext) -> bool { (box_type == FourCc::from_bytes(*b"enca") || box_type == FourCc::from_bytes(*b"mp4a")) && !is_quicktime_wave_audio_context(context) @@ -11705,6 +13721,9 @@ pub fn register_boxes(registry: &mut BoxRegistry) { registry.register::(FourCc::from_bytes(*b"evte")); registry.register::(FourCc::from_bytes(*b"alou")); registry.register_any::(FourCc::from_bytes(*b"avc1")); + registry.register_any::(FourCc::from_bytes(*b"avc2")); + registry.register_any::(FourCc::from_bytes(*b"avc3")); + registry.register_any::(FourCc::from_bytes(*b"avc4")); registry.register_contextual_any::( FourCc::from_bytes(*b"enca"), is_quicktime_wave_audio_context, @@ -11716,6 +13735,8 @@ pub fn register_boxes(registry: &mut BoxRegistry) { registry.register::(FourCc::from_bytes(*b"ftyp")); registry.register::(FourCc::from_bytes(*b"hdlr")); registry.register::(FourCc::from_bytes(*b"hvcC")); + registry.register_any::(FourCc::from_bytes(*b"dvhe")); + registry.register_any::(FourCc::from_bytes(*b"dvh1")); registry.register_any::(FourCc::from_bytes(*b"hev1")); registry.register_any::(FourCc::from_bytes(*b"hvc1")); registry.register::(FourCc::from_bytes(*b"kind")); @@ -11732,6 +13753,7 @@ pub fn register_boxes(registry: &mut BoxRegistry) { registry.register::(FourCc::from_bytes(*b"mime")); registry.register::(FourCc::from_bytes(*b"nmhd")); registry.register::(FourCc::from_bytes(*b"prft")); + registry.register::(FourCc::from_bytes(*b"chnl")); registry.register::(FourCc::from_bytes(*b"minf")); registry.register::(FourCc::from_bytes(*b"moof")); registry.register::(FourCc::from_bytes(*b"moov")); @@ -11741,8 +13763,91 @@ pub fn register_boxes(registry: &mut BoxRegistry) { FourCc::from_bytes(*b"mp4a"), is_quicktime_wave_audio_context, ); + registry.register_contextual_any::( + FourCc::from_bytes(*b"alac"), + is_audio_sample_entry_root_context, + ); + registry.register_any::(FourCc::from_bytes(*b"samr")); + registry.register_any::(FourCc::from_bytes(*b"sawb")); + registry.register_any::(FourCc::from_bytes(*b"sqcp")); + registry.register_any::(FourCc::from_bytes(*b"sevc")); + registry.register_any::(FourCc::from_bytes(*b"ssmv")); + registry.register_any::(FourCc::from_bytes(*b"alaw")); + registry.register_any::(FourCc::from_bytes(*b"MLAW")); + registry.register_any::(FourCc::from_bytes(*b".mp3")); + registry.register_any::(FourCc::from_bytes(*b"ulaw")); + registry.register_any::(FourCc::from_bytes([0x6D, 0x73, 0x00, 0x02])); + registry.register_any::(FourCc::from_bytes([0x6D, 0x73, 0x00, 0x11])); + registry.register_any::(FourCc::from_bytes(*b"CSVD")); + registry.register_any::(FourCc::from_bytes(*b"OPCM")); + registry.register_any::(FourCc::from_bytes(*b"DSTD")); + registry.register_any::(FourCc::from_bytes(*b"YPCM")); + registry.register_any::(FourCc::from_bytes(*b"TSPE")); + registry.register_any::(FourCc::from_bytes(*b"G610")); + registry.register_any::(FourCc::from_bytes(*b"IPCM")); + registry.register_contextual_any::( + FourCc::from_bytes(*b"alac"), + is_audio_sample_entry_child_context, + ); + registry.register_contextual_any::( + FourCc::from_u32(0), + is_audio_sample_entry_child_context, + ); + registry.register_any::(FourCc::from_bytes(*b"spex")); + registry.register_any::(FourCc::from_bytes(*b"dtsc")); + registry.register_any::(FourCc::from_bytes(*b"dtse")); + registry.register_any::(FourCc::from_bytes(*b"dtsh")); + registry.register_any::(FourCc::from_bytes(*b"dtsl")); + registry.register_any::(FourCc::from_bytes(*b"dtsm")); + registry.register_any::(FourCc::from_bytes(*b"dts-")); + registry.register_any::(FourCc::from_bytes(*b"dtsx")); + registry.register_any::(FourCc::from_bytes(*b"dtsy")); + registry.register_any::(FourCc::from_bytes(*b"iamf")); + registry.register::(FourCc::from_bytes(*b"ddts")); + registry.register::(FourCc::from_bytes(*b"udts")); + registry.register::(FourCc::from_bytes(*b"iacb")); registry.register_dynamic_any::(matches_audio_sample_entry_context); + registry.register_any::(FourCc::from_bytes(*b"text")); + registry.register_any::(FourCc::from_bytes(*b"tx3g")); + registry.register_any::(FourCc::from_bytes(*b"dvbs")); + registry.register_any::(FourCc::from_bytes(*b"dvbt")); + registry.register_any::(FourCc::from_bytes(*b"mp4s")); + registry.register_any::(FourCc::from_bytes(*b"H263")); + registry.register_any::(FourCc::from_bytes(*b"DIV3")); + registry.register_any::(FourCc::from_bytes(*b"DIV4")); + registry.register_any::(FourCc::from_bytes(*b"divx")); + registry.register_any::(FourCc::from_bytes(*b"BGR3")); + registry.register_any::(FourCc::from_bytes(*b"MJPG")); + registry.register_any::(FourCc::from_bytes(*b"MPEG")); + registry.register_any::(FourCc::from_bytes(*b"SVQ1")); + registry.register_any::(FourCc::from_bytes(*b"mjp2")); + registry.register_any::(FourCc::from_bytes(*b"PNG ")); + registry.register_any::(FourCc::from_bytes(*b"apco")); + registry.register_any::(FourCc::from_bytes(*b"apcn")); + registry.register_any::(FourCc::from_bytes(*b"apch")); + registry.register_any::(FourCc::from_bytes(*b"apcs")); + registry.register_any::(FourCc::from_bytes(*b"ap4x")); + registry.register_any::(FourCc::from_bytes(*b"ap4h")); + registry.register_any::(FourCc::from_bytes(*b"jpeg")); registry.register_any::(FourCc::from_bytes(*b"mp4v")); + registry.register_any::(FourCc::from_bytes(*b"s263")); + registry.register_any::(FourCc::from_bytes(*b"png ")); + registry.register_any::(FourCc::from_bytes(*b"uncv")); + registry.register_any::(FourCc::from_bytes(*b"QDM2")); + registry.register_any::(FourCc::from_bytes(*b"auxi")); + registry.register_any::(FourCc::from_bytes(*b"jp2h")); + registry.register_any::(FourCc::from_bytes(*b"ramf")); + registry.register_any::(FourCc::from_bytes(*b"cmpd")); + registry.register_any::(FourCc::from_bytes(*b"uncC")); + registry.register_any::(FourCc::from_bytes(*b"dvcC")); + registry.register_any::(FourCc::from_bytes(*b"dvvC")); + registry.register_any::(FourCc::from_bytes(*b"lhvC")); + registry.register_any::(FourCc::from_bytes(*b"chrm")); + registry.register_any::(FourCc::from_bytes(*b"vexu")); + registry.register_any::(FourCc::from_bytes(*b"hfov")); + registry.register_any::(FourCc::from_bytes(*b"clli")); + registry.register_any::(FourCc::from_bytes(*b"mdcv")); + registry.register::(FourCc::from_bytes(*b"dvsC")); registry.register::(FourCc::from_bytes(*b"pasp")); registry.register::(FourCc::from_bytes(*b"saio")); registry.register::(FourCc::from_bytes(*b"saiz")); @@ -11769,6 +13874,7 @@ pub fn register_boxes(registry: &mut BoxRegistry) { registry.register::(FourCc::from_bytes(*b"stts")); registry.register::(FourCc::from_bytes(*b"styp")); registry.register::(FourCc::from_bytes(*b"subs")); + registry.register::(FourCc::from_bytes(*b"padb")); registry.register::(FourCc::from_bytes(*b"tfdt")); registry.register::(FourCc::from_bytes(*b"tfhd")); registry.register::(FourCc::from_bytes(*b"tfra")); @@ -11789,6 +13895,7 @@ pub fn register_boxes(registry: &mut BoxRegistry) { registry.register::(FourCc::from_bytes(*b"mpod")); registry.register::(FourCc::from_bytes(*b"subt")); registry.register::(FourCc::from_bytes(*b"udta")); + registry.register_any::(FourCc::from_bytes(*b"swre")); registry.register::(FourCc::from_bytes(*b"uuid")); registry.register::(FourCc::from_bytes(*b"url ")); registry.register::(FourCc::from_bytes(*b"urn ")); diff --git a/src/boxes/iso14496_14.rs b/src/boxes/iso14496_14.rs index 9055685..3571ec6 100644 --- a/src/boxes/iso14496_14.rs +++ b/src/boxes/iso14496_14.rs @@ -2,12 +2,18 @@ use std::io::{Cursor, Read}; +#[cfg(feature = "async")] +use crate::async_io::AsyncReadSeek; use crate::boxes::BoxRegistry; use crate::codec::{ CodecBox, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, FieldValueWrite, - ImmutableBox, MutableBox, read_exact_vec_untrusted, + ImmutableBox, MutableBox, ReadSeek, read_exact_array_untrusted, read_exact_vec_untrusted, }; +#[cfg(feature = "async")] +use crate::codec::{CodecFuture, read_exact_array_untrusted_async}; use crate::{FourCc, codec_field}; +#[cfg(feature = "async")] +use tokio::io::AsyncReadExt; /// Descriptor tag used by the elementary-stream descriptor record. pub const ES_DESCRIPTOR_TAG: u8 = 0x03; @@ -84,7 +90,10 @@ fn escape_display_char(value: char) -> char { } } -fn read_u8(reader: &mut Cursor<&[u8]>, field_name: &'static str) -> Result { +fn read_u8( + reader: &mut R, + field_name: &'static str, +) -> Result { let mut buf = [0_u8; 1]; reader .read_exact(&mut buf) @@ -92,7 +101,10 @@ fn read_u8(reader: &mut Cursor<&[u8]>, field_name: &'static str) -> Result, field_name: &'static str) -> Result { +fn read_u16( + reader: &mut R, + field_name: &'static str, +) -> Result { let mut buf = [0_u8; 2]; reader .read_exact(&mut buf) @@ -100,7 +112,10 @@ fn read_u16(reader: &mut Cursor<&[u8]>, field_name: &'static str) -> Result, field_name: &'static str) -> Result { +fn read_u24( + reader: &mut R, + field_name: &'static str, +) -> Result { let mut buf = [0_u8; 3]; reader .read_exact(&mut buf) @@ -108,7 +123,10 @@ fn read_u24(reader: &mut Cursor<&[u8]>, field_name: &'static str) -> Result, field_name: &'static str) -> Result { +fn read_u32( + reader: &mut R, + field_name: &'static str, +) -> Result { let mut buf = [0_u8; 4]; reader .read_exact(&mut buf) @@ -116,8 +134,8 @@ fn read_u32(reader: &mut Cursor<&[u8]>, field_name: &'static str) -> Result, +fn read_exact_bytes( + reader: &mut R, len: usize, field_name: &'static str, ) -> Result, FieldValueError> { @@ -125,8 +143,8 @@ fn read_exact_bytes( .map_err(|_| invalid_value(field_name, "descriptor payload is truncated")) } -fn read_uvarint( - reader: &mut Cursor<&[u8]>, +fn read_uvarint( + reader: &mut R, field_name: &'static str, ) -> Result { let mut value = 0_u64; @@ -142,6 +160,364 @@ fn read_uvarint( } } +#[cfg(feature = "async")] +async fn read_u8_async(reader: &mut R, field_name: &'static str) -> Result +where + R: tokio::io::AsyncRead + Unpin + ?Sized, +{ + let mut buf = [0_u8; 1]; + reader + .read_exact(&mut buf) + .await + .map_err(|_| invalid_value(field_name, "descriptor stream is truncated"))?; + Ok(buf[0]) +} + +#[cfg(feature = "async")] +async fn read_u16_async(reader: &mut R, field_name: &'static str) -> Result +where + R: tokio::io::AsyncRead + Unpin + ?Sized, +{ + let mut buf = [0_u8; 2]; + reader + .read_exact(&mut buf) + .await + .map_err(|_| invalid_value(field_name, "descriptor stream is truncated"))?; + Ok(u16::from_be_bytes(buf)) +} + +#[cfg(feature = "async")] +async fn read_u24_async(reader: &mut R, field_name: &'static str) -> Result +where + R: tokio::io::AsyncRead + Unpin + ?Sized, +{ + let mut buf = [0_u8; 3]; + reader + .read_exact(&mut buf) + .await + .map_err(|_| invalid_value(field_name, "descriptor stream is truncated"))?; + Ok((u32::from(buf[0]) << 16) | (u32::from(buf[1]) << 8) | u32::from(buf[2])) +} + +#[cfg(feature = "async")] +async fn read_u32_async(reader: &mut R, field_name: &'static str) -> Result +where + R: tokio::io::AsyncRead + Unpin + ?Sized, +{ + let mut buf = [0_u8; 4]; + reader + .read_exact(&mut buf) + .await + .map_err(|_| invalid_value(field_name, "descriptor stream is truncated"))?; + Ok(u32::from_be_bytes(buf)) +} + +#[cfg(feature = "async")] +async fn read_exact_bytes_async( + reader: &mut R, + len: usize, + field_name: &'static str, +) -> Result, FieldValueError> +where + R: tokio::io::AsyncRead + Unpin + ?Sized, +{ + let mut data = Vec::with_capacity(len.min(64 * 1024)); + let mut chunk = [0_u8; 4096]; + let mut remaining = len; + while remaining != 0 { + let to_read = remaining.min(chunk.len()); + reader + .read_exact(&mut chunk[..to_read]) + .await + .map_err(|_| invalid_value(field_name, "descriptor payload is truncated"))?; + data.extend_from_slice(&chunk[..to_read]); + remaining -= to_read; + } + Ok(data) +} + +#[cfg(feature = "async")] +async fn read_uvarint_async( + reader: &mut R, + field_name: &'static str, +) -> Result +where + R: tokio::io::AsyncRead + Unpin + ?Sized, +{ + let mut value = 0_u64; + loop { + let octet = read_u8_async(reader, field_name).await?; + value = (value << 7) | u64::from(octet & 0x7f); + if value > u64::from(u32::MAX) { + return Err(invalid_value(field_name, "value does not fit in u32")); + } + if octet & 0x80 == 0 { + return Ok(value as u32); + } + } +} + +fn parse_descriptor_stream_from_reader( + field_name: &'static str, + reader: &mut R, + payload_size: usize, +) -> Result, FieldValueError> { + let mut limited = reader.take(payload_size as u64); + let mut descriptors = Vec::new(); + + while limited.limit() != 0 { + let tag = read_u8(&mut limited, field_name)?; + let size = read_uvarint(&mut limited, "Size")?; + + let mut descriptor = Descriptor { + tag, + size, + ..Descriptor::default() + }; + + match tag { + MP4_OBJECT_DESCRIPTOR_TAG => { + let data_len = usize::try_from(size).map_err(|_| { + invalid_value(field_name, "descriptor size does not fit in usize") + })?; + let data = read_exact_bytes(&mut limited, data_len, field_name)?; + parse_object_descriptor_payload("ObjectDescriptor", &data)?; + descriptor.data = data; + } + MP4_INITIAL_OBJECT_DESCRIPTOR_TAG => { + let data_len = usize::try_from(size).map_err(|_| { + invalid_value(field_name, "descriptor size does not fit in usize") + })?; + let data = read_exact_bytes(&mut limited, data_len, field_name)?; + parse_initial_object_descriptor_payload("InitialObjectDescriptor", &data)?; + descriptor.data = data; + } + ES_DESCRIPTOR_TAG => { + descriptor.es_descriptor = Some(parse_es_descriptor("ESDescriptor", &mut limited)?); + } + DECODER_CONFIG_DESCRIPTOR_TAG => { + descriptor.decoder_config_descriptor = Some(parse_decoder_config_descriptor( + "DecoderConfigDescriptor", + &mut limited, + )?); + } + ES_ID_INC_DESCRIPTOR_TAG => { + let data_len = usize::try_from(size).map_err(|_| { + invalid_value(field_name, "descriptor size does not fit in usize") + })?; + let data = read_exact_bytes(&mut limited, data_len, field_name)?; + parse_es_id_inc_descriptor_payload("EsIdIncDescriptor", &data)?; + descriptor.data = data; + } + ES_ID_REF_DESCRIPTOR_TAG => { + let data_len = usize::try_from(size).map_err(|_| { + invalid_value(field_name, "descriptor size does not fit in usize") + })?; + let data = read_exact_bytes(&mut limited, data_len, field_name)?; + parse_es_id_ref_descriptor_payload("EsIdRefDescriptor", &data)?; + descriptor.data = data; + } + IPMP_DESCRIPTOR_POINTER_TAG => { + let data_len = usize::try_from(size).map_err(|_| { + invalid_value(field_name, "descriptor size does not fit in usize") + })?; + let data = read_exact_bytes(&mut limited, data_len, field_name)?; + parse_ipmp_descriptor_pointer_payload("IpmpDescriptorPointer", &data)?; + descriptor.data = data; + } + IPMP_DESCRIPTOR_TAG => { + let data_len = usize::try_from(size).map_err(|_| { + invalid_value(field_name, "descriptor size does not fit in usize") + })?; + let data = read_exact_bytes(&mut limited, data_len, field_name)?; + parse_ipmp_descriptor_payload("IpmpDescriptor", &data)?; + descriptor.data = data; + } + _ => { + let data_len = usize::try_from(size).map_err(|_| { + invalid_value(field_name, "descriptor size does not fit in usize") + })?; + let data = read_exact_bytes(&mut limited, data_len, field_name)?; + descriptor.data = data; + } + } + + descriptors.push(descriptor); + } + + Ok(descriptors) +} + +#[cfg(feature = "async")] +async fn parse_es_descriptor_async( + field_name: &'static str, + reader: &mut R, +) -> Result +where + R: tokio::io::AsyncRead + Unpin + ?Sized, +{ + let es_id = read_u16_async(reader, field_name).await?; + let packed = read_u8_async(reader, field_name).await?; + let stream_dependence_flag = packed & 0x80 != 0; + let url_flag = packed & 0x40 != 0; + let ocr_stream_flag = packed & 0x20 != 0; + let stream_priority = packed & 0x1f; + + let depends_on_es_id = if stream_dependence_flag { + read_u16_async(reader, field_name).await? + } else { + 0 + }; + let (url_length, url_string) = if url_flag { + let url_length = read_u8_async(reader, field_name).await?; + let url_string = + read_exact_bytes_async(reader, usize::from(url_length), field_name).await?; + (url_length, url_string) + } else { + (0, Vec::new()) + }; + let ocr_es_id = if ocr_stream_flag { + read_u16_async(reader, field_name).await? + } else { + 0 + }; + + Ok(EsDescriptor { + es_id, + stream_dependence_flag, + url_flag, + ocr_stream_flag, + stream_priority, + depends_on_es_id, + url_length, + url_string, + ocr_es_id, + }) +} + +#[cfg(feature = "async")] +async fn parse_decoder_config_descriptor_async( + field_name: &'static str, + reader: &mut R, +) -> Result +where + R: tokio::io::AsyncRead + Unpin + ?Sized, +{ + let object_type_indication = read_u8_async(reader, field_name).await?; + let packed = read_u8_async(reader, field_name).await?; + let stream_type = packed >> 2; + let up_stream = packed & 0x02 != 0; + let reserved = packed & 0x01 != 0; + let buffer_size_db = read_u24_async(reader, field_name).await?; + let max_bitrate = read_u32_async(reader, field_name).await?; + let avg_bitrate = read_u32_async(reader, field_name).await?; + + Ok(DecoderConfigDescriptor { + object_type_indication, + stream_type, + up_stream, + reserved, + buffer_size_db, + max_bitrate, + avg_bitrate, + }) +} + +#[cfg(feature = "async")] +async fn parse_descriptor_stream_from_reader_async( + field_name: &'static str, + reader: &mut R, + payload_size: usize, +) -> Result, FieldValueError> +where + R: tokio::io::AsyncRead + Unpin + ?Sized, +{ + let mut limited = reader.take(payload_size as u64); + let mut descriptors = Vec::new(); + + while limited.limit() != 0 { + let tag = read_u8_async(&mut limited, field_name).await?; + let size = read_uvarint_async(&mut limited, "Size").await?; + let mut descriptor = Descriptor { + tag, + size, + ..Descriptor::default() + }; + + match tag { + MP4_OBJECT_DESCRIPTOR_TAG => { + let data_len = usize::try_from(size).map_err(|_| { + invalid_value(field_name, "descriptor size does not fit in usize") + })?; + let data = read_exact_bytes_async(&mut limited, data_len, field_name).await?; + parse_object_descriptor_payload("ObjectDescriptor", &data)?; + descriptor.data = data; + } + MP4_INITIAL_OBJECT_DESCRIPTOR_TAG => { + let data_len = usize::try_from(size).map_err(|_| { + invalid_value(field_name, "descriptor size does not fit in usize") + })?; + let data = read_exact_bytes_async(&mut limited, data_len, field_name).await?; + parse_initial_object_descriptor_payload("InitialObjectDescriptor", &data)?; + descriptor.data = data; + } + ES_DESCRIPTOR_TAG => { + descriptor.es_descriptor = + Some(parse_es_descriptor_async("ESDescriptor", &mut limited).await?); + } + DECODER_CONFIG_DESCRIPTOR_TAG => { + descriptor.decoder_config_descriptor = Some( + parse_decoder_config_descriptor_async("DecoderConfigDescriptor", &mut limited) + .await?, + ); + } + ES_ID_INC_DESCRIPTOR_TAG => { + let data_len = usize::try_from(size).map_err(|_| { + invalid_value(field_name, "descriptor size does not fit in usize") + })?; + let data = read_exact_bytes_async(&mut limited, data_len, field_name).await?; + parse_es_id_inc_descriptor_payload("EsIdIncDescriptor", &data)?; + descriptor.data = data; + } + ES_ID_REF_DESCRIPTOR_TAG => { + let data_len = usize::try_from(size).map_err(|_| { + invalid_value(field_name, "descriptor size does not fit in usize") + })?; + let data = read_exact_bytes_async(&mut limited, data_len, field_name).await?; + parse_es_id_ref_descriptor_payload("EsIdRefDescriptor", &data)?; + descriptor.data = data; + } + IPMP_DESCRIPTOR_POINTER_TAG => { + let data_len = usize::try_from(size).map_err(|_| { + invalid_value(field_name, "descriptor size does not fit in usize") + })?; + let data = read_exact_bytes_async(&mut limited, data_len, field_name).await?; + parse_ipmp_descriptor_pointer_payload("IpmpDescriptorPointer", &data)?; + descriptor.data = data; + } + IPMP_DESCRIPTOR_TAG => { + let data_len = usize::try_from(size).map_err(|_| { + invalid_value(field_name, "descriptor size does not fit in usize") + })?; + let data = read_exact_bytes_async(&mut limited, data_len, field_name).await?; + parse_ipmp_descriptor_payload("IpmpDescriptor", &data)?; + descriptor.data = data; + } + _ => { + let data_len = usize::try_from(size).map_err(|_| { + invalid_value(field_name, "descriptor size does not fit in usize") + })?; + let data = read_exact_bytes_async(&mut limited, data_len, field_name).await?; + descriptor.data = data; + } + } + + descriptors.push(descriptor); + } + + Ok(descriptors) +} + fn write_u16(buffer: &mut Vec, value: u16) { buffer.extend_from_slice(&value.to_be_bytes()); } @@ -170,14 +546,35 @@ fn write_uvarint( )); } - for shift in [21_u32, 14, 7] { - let octet = (((value >> shift) as u8) & 0x7f) | 0x80; - buffer.push(octet); + let mut octets = [0_u8; 4]; + let mut count = 0_usize; + let mut remaining = value; + loop { + octets[3 - count] = (remaining & 0x7f) as u8; + count += 1; + remaining >>= 7; + if remaining == 0 { + break; + } + } + for octet in octets[(4 - count)..].iter().take(count.saturating_sub(1)) { + buffer.push(*octet | 0x80); } - buffer.push((value & 0x7f) as u8); + buffer.push(octets[3]); Ok(()) } +#[cfg(feature = "mux")] +fn uvarint_len(value: u32) -> u32 { + let mut encoded_len = 1_u32; + let mut remaining = value; + while remaining > 0x7f { + encoded_len += 1; + remaining >>= 7; + } + encoded_len +} + fn descriptor_tag_name(tag: u8) -> Option<&'static str> { match tag { MP4_OBJECT_DESCRIPTOR_TAG => Some("MP4ObjectDescr"), @@ -423,9 +820,9 @@ fn encode_command_payload(command: &DescriptorCommand) -> Result<(u8, Vec), } } -fn parse_es_descriptor( +fn parse_es_descriptor( field_name: &'static str, - reader: &mut Cursor<&[u8]>, + reader: &mut R, ) -> Result { let es_id = read_u16(reader, field_name)?; let packed = read_u8(reader, field_name)?; @@ -465,9 +862,9 @@ fn parse_es_descriptor( }) } -fn parse_decoder_config_descriptor( +fn parse_decoder_config_descriptor( field_name: &'static str, - reader: &mut Cursor<&[u8]>, + reader: &mut R, ) -> Result { let object_type_indication = read_u8(reader, field_name)?; let packed = read_u8(reader, field_name)?; @@ -1037,6 +1434,101 @@ impl Esds { self.first_descriptor_with_tag(DECODER_SPECIFIC_INFO_TAG) .map(|descriptor| descriptor.data.as_slice()) } + + #[cfg(feature = "mux")] + pub(crate) fn normalize_descriptor_sizes_for_mux(&mut self) -> Result<(), FieldValueError> { + let mut payload_sizes = self + .descriptors + .iter() + .map(canonical_descriptor_payload_size) + .collect::, _>>()?; + + if let Some(decoder_config_index) = self + .descriptors + .iter() + .position(|descriptor| descriptor.tag == DECODER_CONFIG_DESCRIPTOR_TAG) + { + let mut decoder_config_size = payload_sizes[decoder_config_index]; + for nested_size in payload_sizes + .iter() + .skip(decoder_config_index + 1) + .zip(self.descriptors.iter().skip(decoder_config_index + 1)) + .take_while(|(_, descriptor)| descriptor.tag == DECODER_SPECIFIC_INFO_TAG) + .map(|(payload_size, _)| descriptor_marshaled_size(*payload_size)) + { + decoder_config_size = decoder_config_size + .checked_add(nested_size) + .ok_or_else(|| invalid_value("Size", "descriptor size overflow"))?; + } + payload_sizes[decoder_config_index] = decoder_config_size; + } + + if let Some(es_descriptor_index) = self + .descriptors + .iter() + .position(|descriptor| descriptor.tag == ES_DESCRIPTOR_TAG) + { + let mut es_descriptor_size = payload_sizes[es_descriptor_index]; + let mut descriptor_index = es_descriptor_index + 1; + while descriptor_index < self.descriptors.len() { + let nested_size = descriptor_marshaled_size(payload_sizes[descriptor_index]); + es_descriptor_size = es_descriptor_size + .checked_add(nested_size) + .ok_or_else(|| invalid_value("Size", "descriptor size overflow"))?; + descriptor_index += 1; + if self.descriptors[descriptor_index - 1].tag == DECODER_CONFIG_DESCRIPTOR_TAG { + while descriptor_index < self.descriptors.len() + && self.descriptors[descriptor_index].tag == DECODER_SPECIFIC_INFO_TAG + { + descriptor_index += 1; + } + } + } + payload_sizes[es_descriptor_index] = es_descriptor_size; + } + + for (descriptor, payload_size) in self.descriptors.iter_mut().zip(payload_sizes) { + descriptor.size = payload_size; + } + + Ok(()) + } +} + +#[cfg(feature = "mux")] +fn canonical_descriptor_payload_size(descriptor: &Descriptor) -> Result { + match descriptor.tag { + ES_DESCRIPTOR_TAG => { + let nested = descriptor + .es_descriptor + .as_ref() + .ok_or_else(|| invalid_value("ESDescriptor", "descriptor payload is missing"))?; + let payload = encode_es_descriptor("ESDescriptor", nested)?; + u32::try_from(payload.len()).map_err(|_| invalid_value("Size", "descriptor too large")) + } + DECODER_CONFIG_DESCRIPTOR_TAG => { + let nested = descriptor + .decoder_config_descriptor + .as_ref() + .ok_or_else(|| { + invalid_value("DecoderConfigDescriptor", "descriptor payload is missing") + })?; + let payload = encode_decoder_config_descriptor("DecoderConfigDescriptor", nested)?; + u32::try_from(payload.len()).map_err(|_| invalid_value("Size", "descriptor too large")) + } + _ => { + if descriptor.data.len() != descriptor.size as usize { + return Err(invalid_value("Data", "value length does not match Size")); + } + u32::try_from(descriptor.data.len()) + .map_err(|_| invalid_value("Size", "descriptor too large")) + } + } +} + +#[cfg(feature = "mux")] +fn descriptor_marshaled_size(payload_size: u32) -> u32 { + 1 + uvarint_len(payload_size) + payload_size } impl FieldValueRead for Esds { @@ -1073,6 +1565,73 @@ impl CodecBox for Esds { codec_field!("Descriptors", 2, with_bit_width(8), as_bytes()), ]); const SUPPORTED_VERSIONS: &'static [u8] = &[0]; + + fn custom_unmarshal( + &mut self, + reader: &mut dyn ReadSeek, + payload_size: u64, + ) -> Result, crate::codec::CodecError> { + if payload_size < 4 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + let header = + read_exact_array_untrusted::<4, _>(reader).map_err(crate::codec::CodecError::Io)?; + let version = header[0]; + if version != 0 { + return Err(crate::codec::CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + self.full_box = FullBoxState { + version, + flags: u32::from_be_bytes([0, header[1], header[2], header[3]]), + }; + self.descriptors = parse_descriptor_stream_from_reader( + "Descriptors", + reader, + usize::try_from(payload_size - 4) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?, + )?; + Ok(Some(payload_size)) + } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, crate::codec::CodecError>> { + Box::pin(async move { + if payload_size < 4 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + let header = read_exact_array_untrusted_async::<4, _>(reader) + .await + .map_err(crate::codec::CodecError::Io)?; + let version = header[0]; + if version != 0 { + return Err(crate::codec::CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + self.full_box = FullBoxState { + version, + flags: u32::from_be_bytes([0, header[1], header[2], header[3]]), + }; + self.descriptors = parse_descriptor_stream_from_reader_async( + "Descriptors", + reader, + usize::try_from(payload_size - 4) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?, + ) + .await?; + Ok(Some(payload_size)) + }) + } } /// Initial-object descriptor box carried under `moov`. @@ -1174,6 +1733,93 @@ impl CodecBox for Iods { codec_field!("Descriptor", 2, with_bit_width(8), as_bytes()), ]); const SUPPORTED_VERSIONS: &'static [u8] = &[0]; + + fn custom_unmarshal( + &mut self, + reader: &mut dyn ReadSeek, + payload_size: u64, + ) -> Result, crate::codec::CodecError> { + if payload_size < 4 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + let header = + read_exact_array_untrusted::<4, _>(reader).map_err(crate::codec::CodecError::Io)?; + let version = header[0]; + if version != 0 { + return Err(crate::codec::CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + let descriptors = parse_descriptor_stream_from_reader( + "Descriptor", + reader, + usize::try_from(payload_size - 4) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?, + )?; + self.descriptor = match descriptors.len() { + 0 => None, + 1 => Some(descriptors.into_iter().next().unwrap()), + _ => { + return Err( + invalid_value("Descriptor", "iods may carry at most one descriptor").into(), + ); + } + }; + self.full_box = FullBoxState { + version, + flags: u32::from_be_bytes([0, header[1], header[2], header[3]]), + }; + Ok(Some(payload_size)) + } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, crate::codec::CodecError>> { + Box::pin(async move { + if payload_size < 4 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + let header = read_exact_array_untrusted_async::<4, _>(reader) + .await + .map_err(crate::codec::CodecError::Io)?; + let version = header[0]; + if version != 0 { + return Err(crate::codec::CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + let descriptors = parse_descriptor_stream_from_reader_async( + "Descriptor", + reader, + usize::try_from(payload_size - 4) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?, + ) + .await?; + self.descriptor = match descriptors.len() { + 0 => None, + 1 => Some(descriptors.into_iter().next().unwrap()), + _ => { + return Err(invalid_value( + "Descriptor", + "iods may carry at most one descriptor", + ) + .into()); + } + }; + self.full_box = FullBoxState { + version, + flags: u32::from_be_bytes([0, header[1], header[2], header[3]]), + }; + Ok(Some(payload_size)) + }) + } } /// One tag-sized record within the `esds` descriptor stream. diff --git a/src/boxes/iso23001_7.rs b/src/boxes/iso23001_7.rs index aeacf99..3a9882d 100644 --- a/src/boxes/iso23001_7.rs +++ b/src/boxes/iso23001_7.rs @@ -2,13 +2,21 @@ use std::io::{SeekFrom, Write}; +#[cfg(feature = "async")] +use crate::async_io::AsyncReadSeek; use crate::boxes::BoxRegistry; +#[cfg(feature = "async")] +use crate::codec::CodecFuture; +#[cfg(feature = "async")] +use crate::codec::read_exact_array_untrusted_async; use crate::codec::{ CodecBox, CodecError, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, - FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, read_exact_vec_untrusted, + FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, read_exact_array_untrusted, untrusted_prealloc_hint, }; use crate::{FourCc, codec_field}; +#[cfg(feature = "async")] +use tokio::io::AsyncReadExt; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct FullBoxState { @@ -202,6 +210,78 @@ pub(crate) fn decode_senc_payload(payload: &[u8]) -> Result { Ok(senc) } +pub(crate) fn decode_senc_payload_from_reader( + reader: &mut dyn ReadSeek, + payload_size: u64, +) -> Result { + if payload_size < 8 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + let header = read_exact_array_untrusted::<8, _>(reader)?; + let version = header[0]; + let flags = u32::from_be_bytes([0, header[1], header[2], header[3]]); + let mut senc = Senc::default(); + if !senc.is_supported_version(version) { + return Err(CodecError::UnsupportedVersion { + box_type: senc.box_type(), + version, + }); + } + validate_senc_flags(flags)?; + + let sample_count = read_u32(&header, 4); + let samples = read_senc_samples_from_reader( + reader, + payload_size - 8, + sample_count, + flags & SENC_USE_SUBSAMPLE_ENCRYPTION != 0, + )?; + + senc.set_version(version); + senc.set_flags(flags); + senc.sample_count = sample_count; + senc.samples = samples; + Ok(senc) +} + +#[cfg(feature = "async")] +pub(crate) async fn decode_senc_payload_from_reader_async( + reader: &mut dyn AsyncReadSeek, + payload_size: u64, +) -> Result { + if payload_size < 8 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + let header = read_exact_array_untrusted_async::<8, _>(reader).await?; + let version = header[0]; + let flags = u32::from_be_bytes([0, header[1], header[2], header[3]]); + let mut senc = Senc::default(); + if !senc.is_supported_version(version) { + return Err(CodecError::UnsupportedVersion { + box_type: senc.box_type(), + version, + }); + } + validate_senc_flags(flags)?; + + let sample_count = read_u32(&header, 4); + let samples = read_senc_samples_from_reader_async( + reader, + payload_size - 8, + sample_count, + flags & SENC_USE_SUBSAMPLE_ENCRYPTION != 0, + ) + .await?; + + senc.set_version(version); + senc.set_flags(flags); + senc.sample_count = sample_count; + senc.samples = samples; + Ok(senc) +} + #[cfg(feature = "decrypt")] pub(crate) fn decode_senc_payload_with_iv_size( payload: &[u8], @@ -364,6 +444,144 @@ fn try_parse_senc_samples_with_iv_size( (cursor == bytes.len()).then_some(samples) } +fn try_read_senc_samples_with_iv_size_from_reader( + reader: &mut dyn ReadSeek, + payload_start: u64, + payload_len: u64, + sample_count: usize, + iv_size: usize, + use_subsample_encryption: bool, +) -> Result>, CodecError> { + reader.seek(SeekFrom::Start(payload_start))?; + let iv_len = u64::try_from(iv_size) + .map_err(|_| invalid_value("Samples", "IV size does not fit in u64"))?; + let mut remaining = payload_len; + let mut samples = Vec::with_capacity(untrusted_prealloc_hint(sample_count)); + + for _ in 0..sample_count { + if remaining < iv_len { + return Ok(None); + } + + let initialization_vector = if iv_size == 0 { + Vec::new() + } else { + let mut bytes = vec![0_u8; iv_size]; + reader.read_exact(&mut bytes)?; + remaining -= iv_len; + bytes + }; + + let mut subsamples = Vec::new(); + if use_subsample_encryption { + if remaining < 2 { + return Ok(None); + } + + let subsample_count = u16::from_be_bytes(read_exact_array_untrusted::<2, _>(reader)?); + remaining -= 2; + let subsample_bytes = u64::from(subsample_count) * 6; + if remaining < subsample_bytes { + return Ok(None); + } + + subsamples = Vec::with_capacity(untrusted_prealloc_hint(usize::from(subsample_count))); + for _ in 0..subsample_count { + let entry = read_exact_array_untrusted::<6, _>(reader)?; + subsamples.push(SencSubsample { + bytes_of_clear_data: u16::from_be_bytes([entry[0], entry[1]]), + bytes_of_protected_data: u32::from_be_bytes([ + entry[2], entry[3], entry[4], entry[5], + ]), + }); + } + remaining -= subsample_bytes; + } + + samples.push(SencSample { + initialization_vector, + subsamples, + }); + } + + if remaining != 0 { + return Ok(None); + } + + Ok(Some(samples)) +} + +#[cfg(feature = "async")] +async fn try_read_senc_samples_with_iv_size_from_reader_async( + reader: &mut dyn AsyncReadSeek, + payload_start: u64, + payload_len: u64, + sample_count: usize, + iv_size: usize, + use_subsample_encryption: bool, +) -> Result>, CodecError> { + use tokio::io::AsyncSeekExt; + + reader.seek(SeekFrom::Start(payload_start)).await?; + let iv_len = u64::try_from(iv_size) + .map_err(|_| invalid_value("Samples", "IV size does not fit in u64"))?; + let mut remaining = payload_len; + let mut samples = Vec::with_capacity(untrusted_prealloc_hint(sample_count)); + + for _ in 0..sample_count { + if remaining < iv_len { + return Ok(None); + } + + let initialization_vector = if iv_size == 0 { + Vec::new() + } else { + let mut bytes = vec![0_u8; iv_size]; + reader.read_exact(&mut bytes).await?; + remaining -= iv_len; + bytes + }; + + let mut subsamples = Vec::new(); + if use_subsample_encryption { + if remaining < 2 { + return Ok(None); + } + + let subsample_count = + u16::from_be_bytes(read_exact_array_untrusted_async::<2, _>(reader).await?); + remaining -= 2; + let subsample_bytes = u64::from(subsample_count) * 6; + if remaining < subsample_bytes { + return Ok(None); + } + + subsamples = Vec::with_capacity(untrusted_prealloc_hint(usize::from(subsample_count))); + for _ in 0..subsample_count { + let entry = read_exact_array_untrusted_async::<6, _>(reader).await?; + subsamples.push(SencSubsample { + bytes_of_clear_data: u16::from_be_bytes([entry[0], entry[1]]), + bytes_of_protected_data: u32::from_be_bytes([ + entry[2], entry[3], entry[4], entry[5], + ]), + }); + } + remaining -= subsample_bytes; + } + + samples.push(SencSample { + initialization_vector, + subsamples, + }); + } + + if remaining != 0 { + return Ok(None); + } + + Ok(Some(samples)) +} + fn parse_senc_samples( field_name: &'static str, bytes: &[u8], @@ -411,6 +629,124 @@ fn parse_senc_samples( }) } +fn read_senc_samples_from_reader( + reader: &mut dyn ReadSeek, + payload_len: u64, + sample_count: u32, + use_subsample_encryption: bool, +) -> Result, CodecError> { + let sample_count = usize::try_from(sample_count) + .map_err(|_| invalid_value("SampleCount", "sample count does not fit in usize"))?; + if sample_count == 0 { + if payload_len != 0 { + return Err(invalid_value( + "Samples", + "sample payload must be empty when sample count is zero", + ) + .into()); + } + return Ok(Vec::new()); + } + + let payload_start = reader.stream_position()?; + let max_iv_size = usize::try_from(payload_len.min(u64::from(u8::MAX))) + .map_err(|_| invalid_value("Samples", "sample payload is too large to decode"))?; + let mut matched = None; + for iv_size in 0..=max_iv_size { + let Some(samples) = try_read_senc_samples_with_iv_size_from_reader( + reader, + payload_start, + payload_len, + sample_count, + iv_size, + use_subsample_encryption, + )? + else { + continue; + }; + + if matched.is_some() { + return Err(invalid_value( + "Samples", + "sample IV size is ambiguous without external encryption parameters", + ) + .into()); + } + matched = Some(samples); + } + + reader.seek(SeekFrom::Start(payload_start + payload_len))?; + matched.ok_or_else(|| { + invalid_value( + "Samples", + "sample IV size cannot be inferred from the payload", + ) + .into() + }) +} + +#[cfg(feature = "async")] +async fn read_senc_samples_from_reader_async( + reader: &mut dyn AsyncReadSeek, + payload_len: u64, + sample_count: u32, + use_subsample_encryption: bool, +) -> Result, CodecError> { + use tokio::io::AsyncSeekExt; + + let sample_count = usize::try_from(sample_count) + .map_err(|_| invalid_value("SampleCount", "sample count does not fit in usize"))?; + if sample_count == 0 { + if payload_len != 0 { + return Err(invalid_value( + "Samples", + "sample payload must be empty when sample count is zero", + ) + .into()); + } + return Ok(Vec::new()); + } + + let payload_start = reader.stream_position().await?; + let max_iv_size = usize::try_from(payload_len.min(u64::from(u8::MAX))) + .map_err(|_| invalid_value("Samples", "sample payload is too large to decode"))?; + let mut matched = None; + for iv_size in 0..=max_iv_size { + let Some(samples) = try_read_senc_samples_with_iv_size_from_reader_async( + reader, + payload_start, + payload_len, + sample_count, + iv_size, + use_subsample_encryption, + ) + .await? + else { + continue; + }; + + if matched.is_some() { + return Err(invalid_value( + "Samples", + "sample IV size is ambiguous without external encryption parameters", + ) + .into()); + } + matched = Some(samples); + } + + reader + .seek(SeekFrom::Start(payload_start + payload_len)) + .await?; + matched.ok_or_else(|| { + invalid_value( + "Samples", + "sample IV size cannot be inferred from the payload", + ) + .into() + }) +} + fn validate_senc_flags(flags: u32) -> Result<(), FieldValueError> { if flags & !SENC_USE_SUBSAMPLE_ENCRYPTION != 0 { return Err(invalid_value( @@ -867,27 +1203,39 @@ impl CodecBox for Senc { payload_size: u64, ) -> Result, CodecError> { let start = reader.stream_position()?; - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - let payload = match read_exact_vec_untrusted(reader, payload_len) { - Ok(payload) => payload, + match decode_senc_payload_from_reader(reader, payload_size) { + Ok(decoded) => { + *self = decoded; + Ok(Some(payload_size)) + } Err(error) => { reader.seek(SeekFrom::Start(start))?; - return Err(error.into()); + Err(error) } - }; - - let parse_result = (|| -> Result<(), CodecError> { - *self = decode_senc_payload(&payload)?; - Ok(()) - })(); - - if let Err(error) = parse_result { - reader.seek(SeekFrom::Start(start))?; - return Err(error); } + } - Ok(Some(payload_size)) + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + use tokio::io::AsyncSeekExt; + + let start = reader.stream_position().await?; + match decode_senc_payload_from_reader_async(reader, payload_size).await { + Ok(decoded) => { + *self = decoded; + Ok(Some(payload_size)) + } + Err(error) => { + reader.seek(SeekFrom::Start(start)).await?; + Err(error) + } + } + }) } } diff --git a/src/boxes/metadata.rs b/src/boxes/metadata.rs index d49acc9..a99287b 100644 --- a/src/boxes/metadata.rs +++ b/src/boxes/metadata.rs @@ -9,9 +9,11 @@ use crate::boxes::{AnyTypeBox, BoxLookupContext, BoxRegistry}; use crate::codec::CodecFuture; use crate::codec::{ CodecBox, CodecError, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, - FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, read_exact_vec_untrusted, - untrusted_prealloc_hint, + FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, read_exact_array_untrusted, + read_exact_vec_untrusted, untrusted_prealloc_hint, }; +#[cfg(feature = "async")] +use crate::codec::{read_exact_array_untrusted_async, read_exact_vec_untrusted_async}; use crate::stringify::stringify; use crate::{BoxInfo, FourCc, codec_field}; @@ -47,6 +49,7 @@ const GENRE_METADATA_ITEM_TYPE: FourCc = FourCc::from_bytes([0xa9, b'g', b'e', b const GROUPING_METADATA_ITEM_TYPE: FourCc = FourCc::from_bytes([0xa9, b'g', b'r', b'p']); const LEGACY_GENRE_METADATA_ITEM_TYPE: FourCc = FourCc::from_bytes(*b"gnre"); const NAME_METADATA_ITEM_TYPE: FourCc = FourCc::from_bytes([0xa9, b'n', b'a', b'm']); +const ENCODING_METADATA_ITEM_TYPE: FourCc = FourCc::from_bytes([0xa9, b'e', b'n', b'c']); const TOOL_METADATA_ITEM_TYPE: FourCc = FourCc::from_bytes([0xa9, b't', b'o', b'o']); const SORT_ALBUM_ARTIST_METADATA_ITEM_TYPE: FourCc = FourCc::from_bytes(*b"soaa"); const SORT_ALBUM_METADATA_ITEM_TYPE: FourCc = FourCc::from_bytes(*b"soal"); @@ -83,6 +86,7 @@ const ILST_META_BOX_TYPES: &[FourCc] = &[ FourCc::from_bytes(*b"purl"), FourCc::from_bytes(*b"rtng"), FourCc::from_bytes(*b"sfID"), + ENCODING_METADATA_ITEM_TYPE, FourCc::from_bytes(*b"soaa"), FourCc::from_bytes(*b"soal"), FourCc::from_bytes(*b"soar"), @@ -281,10 +285,7 @@ fn encode_data_payload(data: &Data) -> Vec { payload } -fn decode_data_payload( - field_name: &'static str, - payload: Vec, -) -> Result { +fn decode_data_payload(field_name: &'static str, payload: &[u8]) -> Result { if payload.len() < 8 { return Err(invalid_value( field_name, @@ -413,7 +414,6 @@ pub(crate) fn is_ilst_meta_box_type(box_type: FourCc) -> bool { } /// Returns `true` when `box_type` falls into the numbered item range learned from `keys`. -#[allow(dead_code)] pub(crate) fn is_numbered_metadata_item_type( box_type: FourCc, quicktime_keys_meta_entry_count: usize, @@ -2318,7 +2318,7 @@ impl FieldValueWrite for NumberedMetadataItem { Ok(()) } ("Data", FieldValue::Bytes(value)) => { - self.data = decode_data_payload(field_name, value)?; + self.data = decode_data_payload(field_name, &value)?; Ok(()) } (field_name, value) => Err(unexpected_field(field_name, value)), @@ -2370,14 +2370,66 @@ impl CodecBox for NumberedMetadataItem { return Ok(None); } - let data_payload = read_exact_vec_untrusted(reader, (payload_size - 8) as usize)?; - self.full_box = FullBoxState::default(); self.layout = NumberedMetadataLayout::NestedDataBox; self.item_name = item_name; - self.data = decode_data_payload("Data", data_payload)?; + let mut data_header = [0_u8; 8]; + reader.read_exact(&mut data_header)?; + let data_len = usize::try_from(payload_size - 16) + .map_err(|_| invalid_value("Data", "payload exceeds the supported in-memory range"))?; + let mut data = vec![0_u8; data_len]; + reader.read_exact(&mut data)?; + self.data = Data { + data_type: u32::from_be_bytes(data_header[0..4].try_into().unwrap()), + data_lang: u32::from_be_bytes(data_header[4..8].try_into().unwrap()), + data, + }; Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, crate::codec::CodecError>> { + Box::pin(async move { + if payload_size < 16 { + return Ok(None); + } + + let start = tokio::io::AsyncSeekExt::stream_position(reader).await?; + let mut header = [0_u8; 8]; + tokio::io::AsyncReadExt::read_exact(reader, &mut header).await?; + + let nested_size = + u32::from_be_bytes([header[0], header[1], header[2], header[3]]) as u64; + let item_name = FourCc::from_bytes([header[4], header[5], header[6], header[7]]); + + if nested_size != payload_size || item_name != FourCc::from_bytes(*b"data") { + tokio::io::AsyncSeekExt::seek(reader, SeekFrom::Start(start)).await?; + return Ok(None); + } + + let mut data_header = [0_u8; 8]; + tokio::io::AsyncReadExt::read_exact(reader, &mut data_header).await?; + let data_len = usize::try_from(payload_size - 16).map_err(|_| { + invalid_value("Data", "payload exceeds the supported in-memory range") + })?; + let mut data = vec![0_u8; data_len]; + tokio::io::AsyncReadExt::read_exact(reader, &mut data).await?; + + self.full_box = FullBoxState::default(); + self.layout = NumberedMetadataLayout::NestedDataBox; + self.item_name = item_name; + self.data = Data { + data_type: u32::from_be_bytes(data_header[0..4].try_into().unwrap()), + data_lang: u32::from_be_bytes(data_header[4..8].try_into().unwrap()), + data, + }; + Ok(Some(payload_size)) + }) + } } /// Metadata key table that describes numbered item-list entries. @@ -2586,14 +2638,12 @@ impl CodecBox for Id32 { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, crate::codec::CodecError> { - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - if payload_len < 6 { + if payload_size < 6 { return Err(invalid_value("Payload", "payload is too short").into()); } - let payload = read_exact_vec_untrusted(reader, payload_len)?; - let version = payload[0]; + let header = read_exact_array_untrusted::<6, _>(reader)?; + let version = header[0]; if version != 0 { return Err(CodecError::UnsupportedVersion { box_type: self.box_type(), @@ -2601,17 +2651,62 @@ impl CodecBox for Id32 { }); } - let flags = u32::from_be_bytes([0, payload[1], payload[2], payload[3]]); + let flags = u32::from_be_bytes([0, header[1], header[2], header[3]]); if flags != 0 { return Err(invalid_value("Flags", "non-zero flags are not supported").into()); } + let trailing_len = usize::try_from(payload_size - 6) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; self.full_box = FullBoxState { version, flags }; self.language = - decode_iso639_2_language("Language", u16::from_be_bytes([payload[4], payload[5]]))?; - self.id3v2_data = payload[6..].to_vec(); + decode_iso639_2_language("Language", u16::from_be_bytes([header[4], header[5]]))?; + self.id3v2_data = if trailing_len == 0 { + Vec::new() + } else { + read_exact_vec_untrusted(reader, trailing_len)? + }; Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, crate::codec::CodecError>> { + Box::pin(async move { + if payload_size < 6 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + let header = read_exact_array_untrusted_async::<6, _>(reader).await?; + let version = header[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + let flags = u32::from_be_bytes([0, header[1], header[2], header[3]]); + if flags != 0 { + return Err(invalid_value("Flags", "non-zero flags are not supported").into()); + } + + let trailing_len = usize::try_from(payload_size - 6) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; + self.full_box = FullBoxState { version, flags }; + self.language = + decode_iso639_2_language("Language", u16::from_be_bytes([header[4], header[5]]))?; + self.id3v2_data = if trailing_len == 0 { + Vec::new() + } else { + read_exact_vec_untrusted_async(reader, trailing_len).await? + }; + Ok(Some(payload_size)) + }) + } } /// Registers the currently landed metadata boxes in `registry`. diff --git a/src/boxes/mod.rs b/src/boxes/mod.rs index 806db41..3213147 100644 --- a/src/boxes/mod.rs +++ b/src/boxes/mod.rs @@ -9,12 +9,18 @@ use crate::codec::{CodecBox, DynCodecBox}; pub mod av1; /// AVS3 sample-entry and decoder-configuration box definitions. pub mod avs3; +/// Dolby audio sample-entry child box definitions. +pub mod dolby; +/// DTS sample-entry child box definitions. +pub mod dts; /// ETSI TS 102 366 AC-3 sample-entry and decoder-configuration box definitions. pub mod etsi_ts_102_366; /// ETSI TS 103 190 AC-4 sample-entry and decoder-configuration box definitions. pub mod etsi_ts_103_190; /// FLAC sample-entry and decoder-configuration box definitions. pub mod flac; +/// IAMF sample-entry child box definitions. +pub mod iamf; /// ISMA Cryp protection-related box definitions. pub mod isma_cryp; /// ISO/IEC 14496-12 box definitions and codec support. @@ -60,6 +66,7 @@ pub struct BoxLookupContext { pub(crate) is_quicktime_compatible: bool, pub(crate) quicktime_keys_meta_entry_count: usize, pub(crate) ilst_meta_item: Option, + pub(crate) under_audio_sample_entry: bool, pub(crate) under_wave: bool, pub(crate) under_ilst: bool, pub(crate) under_ilst_meta: bool, @@ -74,6 +81,7 @@ impl BoxLookupContext { is_quicktime_compatible: false, quicktime_keys_meta_entry_count: 0, ilst_meta_item: None, + under_audio_sample_entry: false, under_wave: false, under_ilst: false, under_ilst_meta: false, @@ -112,6 +120,11 @@ impl BoxLookupContext { self.ilst_meta_item } + /// Returns `true` when the current lookup runs under an audio sample-entry box. + pub const fn under_audio_sample_entry(&self) -> bool { + self.under_audio_sample_entry + } + /// Returns `true` when the current lookup runs under a `wave` box. pub const fn under_wave(&self) -> bool { self.under_wave @@ -143,6 +156,61 @@ impl BoxLookupContext { const ILST: FourCc = FourCc::from_bytes(*b"ilst"); const UDTA: FourCc = FourCc::from_bytes(*b"udta"); const FREE_FORM: FourCc = FourCc::from_bytes(*b"----"); + const MP4A: FourCc = FourCc::from_bytes(*b"mp4a"); + const ENCA: FourCc = FourCc::from_bytes(*b"enca"); + const SAMR: FourCc = FourCc::from_bytes(*b"samr"); + const SAWB: FourCc = FourCc::from_bytes(*b"sawb"); + const SQCP: FourCc = FourCc::from_bytes(*b"sqcp"); + const SEVC: FourCc = FourCc::from_bytes(*b"sevc"); + const SSMV: FourCc = FourCc::from_bytes(*b"ssmv"); + const ALAC: FourCc = FourCc::from_bytes(*b"alac"); + const AC_3: FourCc = FourCc::from_bytes(*b"ac-3"); + const EC_3: FourCc = FourCc::from_bytes(*b"ec-3"); + const AC_4: FourCc = FourCc::from_bytes(*b"ac-4"); + const MLPA: FourCc = FourCc::from_bytes(*b"mlpa"); + const DTSC: FourCc = FourCc::from_bytes(*b"dtsc"); + const DTSE: FourCc = FourCc::from_bytes(*b"dtse"); + const DTSH: FourCc = FourCc::from_bytes(*b"dtsh"); + const DTSL: FourCc = FourCc::from_bytes(*b"dtsl"); + const DTSM: FourCc = FourCc::from_bytes(*b"dtsm"); + const DTS_MINUS: FourCc = FourCc::from_bytes(*b"dts-"); + const DTSX: FourCc = FourCc::from_bytes(*b"dtsx"); + const DTSY: FourCc = FourCc::from_bytes(*b"dtsy"); + const FLAC: FourCc = FourCc::from_bytes(*b"fLaC"); + const OPUS: FourCc = FourCc::from_bytes(*b"Opus"); + const IAMF: FourCc = FourCc::from_bytes(*b"iamf"); + const MHA1: FourCc = FourCc::from_bytes(*b"mha1"); + const MHM1: FourCc = FourCc::from_bytes(*b"mhm1"); + + if matches!( + box_type, + MP4A | ENCA + | SAMR + | SAWB + | SQCP + | SEVC + | SSMV + | ALAC + | AC_3 + | EC_3 + | AC_4 + | MLPA + | DTSC + | DTSE + | DTSH + | DTSL + | DTSM + | DTS_MINUS + | DTSX + | DTSY + | FLAC + | OPUS + | IAMF + | MHA1 + | MHM1 + ) { + self.under_audio_sample_entry = true; + } if box_type == WAVE { self.under_wave = true; @@ -443,6 +511,7 @@ pub fn default_registry() -> BoxRegistry { threegpp::register_boxes(&mut registry); av1::register_boxes(&mut registry); avs3::register_boxes(&mut registry); + dolby::register_boxes(&mut registry); etsi_ts_102_366::register_boxes(&mut registry); etsi_ts_103_190::register_boxes(&mut registry); flac::register_boxes(&mut registry); diff --git a/src/boxes/oma_dcf.rs b/src/boxes/oma_dcf.rs index ad9100d..e13c307 100644 --- a/src/boxes/oma_dcf.rs +++ b/src/boxes/oma_dcf.rs @@ -2,10 +2,14 @@ use std::io::Write; +#[cfg(feature = "async")] +use crate::async_io::AsyncReadSeek; use crate::boxes::BoxRegistry; +#[cfg(feature = "async")] +use crate::codec::CodecFuture; use crate::codec::{ CodecBox, CodecError, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, - FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, StringFieldMode, read_exact_vec_untrusted, + FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, StringFieldMode, }; use crate::{FourCc, codec_field}; @@ -299,15 +303,13 @@ impl CodecBox for Odhe { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, CodecError> { - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; - - if payload.len() < 5 { + if payload_size < 5 { return Err(invalid_value("Payload", "payload is too short").into()); } + let mut header = [0_u8; 5]; + reader.read_exact(&mut header).map_err(CodecError::Io)?; - let version = payload[0]; + let version = header[0]; if version != 0 { return Err(CodecError::UnsupportedVersion { box_type: self.box_type(), @@ -315,19 +317,67 @@ impl CodecBox for Odhe { }); } - let content_type_len = usize::from(payload[4]); + let content_type_len = usize::from(header[4]); let fixed_len = 5 + content_type_len; - if payload.len() < fixed_len { + if payload_size < u64::try_from(fixed_len).unwrap() { return Err(invalid_value("Payload", "content-type bytes are truncated").into()); } + let mut content_type_bytes = vec![0_u8; content_type_len]; + reader + .read_exact(&mut content_type_bytes) + .map_err(CodecError::Io)?; self.full_box = FullBoxState { version, - flags: ((payload[1] as u32) << 16) | ((payload[2] as u32) << 8) | u32::from(payload[3]), + flags: ((header[1] as u32) << 16) | ((header[2] as u32) << 8) | u32::from(header[3]), }; - self.content_type = decode_utf8_string("ContentType", &payload[5..fixed_len])?; + self.content_type = decode_utf8_string("ContentType", &content_type_bytes)?; Ok(Some(fixed_len as u64)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size < 5 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + let mut header = [0_u8; 5]; + tokio::io::AsyncReadExt::read_exact(reader, &mut header) + .await + .map_err(CodecError::Io)?; + + let version = header[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + let content_type_len = usize::from(header[4]); + let fixed_len = 5 + content_type_len; + if payload_size < u64::try_from(fixed_len).unwrap() { + return Err(invalid_value("Payload", "content-type bytes are truncated").into()); + } + let mut content_type_bytes = vec![0_u8; content_type_len]; + tokio::io::AsyncReadExt::read_exact(reader, &mut content_type_bytes) + .await + .map_err(CodecError::Io)?; + + self.full_box = FullBoxState { + version, + flags: ((header[1] as u32) << 16) + | ((header[2] as u32) << 8) + | u32::from(header[3]), + }; + self.content_type = decode_utf8_string("ContentType", &content_type_bytes)?; + Ok(Some(fixed_len as u64)) + }) + } } /// OMA DRM information box that carries encryption parameters, content identifiers, and nested metadata. @@ -483,15 +533,13 @@ impl CodecBox for Ohdr { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, CodecError> { - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; - - if payload.len() < 20 { + if payload_size < 20 { return Err(invalid_value("Payload", "payload is too short").into()); } + let mut header = [0_u8; 20]; + reader.read_exact(&mut header).map_err(CodecError::Io)?; - let version = payload[0]; + let version = header[0]; if version != 0 { return Err(CodecError::UnsupportedVersion { box_type: self.box_type(), @@ -499,37 +547,98 @@ impl CodecBox for Ohdr { }); } - let content_id_len = usize::from(read_u16(&payload, 14)); - let rights_issuer_url_len = usize::from(read_u16(&payload, 16)); - let textual_headers_len = usize::from(read_u16(&payload, 18)); + let content_id_len = usize::from(read_u16(&header, 14)); + let rights_issuer_url_len = usize::from(read_u16(&header, 16)); + let textual_headers_len = usize::from(read_u16(&header, 18)); let fixed_len = 20 + content_id_len + rights_issuer_url_len + textual_headers_len; - if payload.len() < fixed_len { + if payload_size < u64::try_from(fixed_len).unwrap() { return Err(invalid_value("Payload", "header strings are truncated").into()); } - - let content_id_offset = 20; - let rights_issuer_url_offset = content_id_offset + content_id_len; - let textual_headers_offset = rights_issuer_url_offset + rights_issuer_url_len; + let mut content_id_bytes = vec![0_u8; content_id_len]; + let mut rights_issuer_url_bytes = vec![0_u8; rights_issuer_url_len]; + let mut textual_headers = vec![0_u8; textual_headers_len]; + reader + .read_exact(&mut content_id_bytes) + .map_err(CodecError::Io)?; + reader + .read_exact(&mut rights_issuer_url_bytes) + .map_err(CodecError::Io)?; + reader + .read_exact(&mut textual_headers) + .map_err(CodecError::Io)?; self.full_box = FullBoxState { version, - flags: ((payload[1] as u32) << 16) | ((payload[2] as u32) << 8) | u32::from(payload[3]), + flags: ((header[1] as u32) << 16) | ((header[2] as u32) << 8) | u32::from(header[3]), }; - self.encryption_method = payload[4]; - self.padding_scheme = payload[5]; - self.plaintext_length = read_u64(&payload, 6); - self.content_id = decode_utf8_string( - "ContentId", - &payload[content_id_offset..rights_issuer_url_offset], - )?; - self.rights_issuer_url = decode_utf8_string( - "RightsIssuerUrl", - &payload[rights_issuer_url_offset..textual_headers_offset], - )?; - self.textual_headers = - payload[textual_headers_offset..textual_headers_offset + textual_headers_len].to_vec(); + self.encryption_method = header[4]; + self.padding_scheme = header[5]; + self.plaintext_length = read_u64(&header, 6); + self.content_id = decode_utf8_string("ContentId", &content_id_bytes)?; + self.rights_issuer_url = decode_utf8_string("RightsIssuerUrl", &rights_issuer_url_bytes)?; + self.textual_headers = textual_headers; Ok(Some(fixed_len as u64)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size < 20 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + let mut header = [0_u8; 20]; + tokio::io::AsyncReadExt::read_exact(reader, &mut header) + .await + .map_err(CodecError::Io)?; + + let version = header[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + let content_id_len = usize::from(read_u16(&header, 14)); + let rights_issuer_url_len = usize::from(read_u16(&header, 16)); + let textual_headers_len = usize::from(read_u16(&header, 18)); + let fixed_len = 20 + content_id_len + rights_issuer_url_len + textual_headers_len; + if payload_size < u64::try_from(fixed_len).unwrap() { + return Err(invalid_value("Payload", "header strings are truncated").into()); + } + let mut content_id_bytes = vec![0_u8; content_id_len]; + let mut rights_issuer_url_bytes = vec![0_u8; rights_issuer_url_len]; + let mut textual_headers = vec![0_u8; textual_headers_len]; + tokio::io::AsyncReadExt::read_exact(reader, &mut content_id_bytes) + .await + .map_err(CodecError::Io)?; + tokio::io::AsyncReadExt::read_exact(reader, &mut rights_issuer_url_bytes) + .await + .map_err(CodecError::Io)?; + tokio::io::AsyncReadExt::read_exact(reader, &mut textual_headers) + .await + .map_err(CodecError::Io)?; + + self.full_box = FullBoxState { + version, + flags: ((header[1] as u32) << 16) + | ((header[2] as u32) << 8) + | u32::from(header[3]), + }; + self.encryption_method = header[4]; + self.padding_scheme = header[5]; + self.plaintext_length = read_u64(&header, 6); + self.content_id = decode_utf8_string("ContentId", &content_id_bytes)?; + self.rights_issuer_url = + decode_utf8_string("RightsIssuerUrl", &rights_issuer_url_bytes)?; + self.textual_headers = textual_headers; + Ok(Some(fixed_len as u64)) + }) + } } /// OMA access-unit format box carried under `odkm`. @@ -616,13 +725,11 @@ impl CodecBox for Odaf { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, CodecError> { - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; - - if payload.len() != 7 { + if payload_size != 7 { return Err(invalid_value("Payload", "payload length must be exactly 7 bytes").into()); } + let mut payload = [0_u8; 7]; + reader.read_exact(&mut payload).map_err(CodecError::Io)?; let version = payload[0]; if version != 0 { @@ -641,6 +748,44 @@ impl CodecBox for Odaf { self.iv_length = payload[6]; Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size != 7 { + return Err( + invalid_value("Payload", "payload length must be exactly 7 bytes").into(), + ); + } + let mut payload = [0_u8; 7]; + tokio::io::AsyncReadExt::read_exact(reader, &mut payload) + .await + .map_err(CodecError::Io)?; + + let version = payload[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + self.full_box = FullBoxState { + version, + flags: ((payload[1] as u32) << 16) + | ((payload[2] as u32) << 8) + | u32::from(payload[3]), + }; + self.selective_encryption = payload[4] & 0x80 != 0; + self.key_indicator_length = payload[5]; + self.iv_length = payload[6]; + Ok(Some(payload_size)) + }) + } } /// OMA encrypted-payload box that stores an explicit payload length followed by encrypted bytes. @@ -728,15 +873,13 @@ impl CodecBox for Odda { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, CodecError> { - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; - - if payload.len() < 12 { + if payload_size < 12 { return Err(invalid_value("Payload", "payload is too short").into()); } + let mut header = [0_u8; 12]; + reader.read_exact(&mut header).map_err(CodecError::Io)?; - let version = payload[0]; + let version = header[0]; if version != 0 { return Err(CodecError::UnsupportedVersion { box_type: self.box_type(), @@ -744,24 +887,77 @@ impl CodecBox for Odda { }); } - let encrypted_data_length = usize::try_from(read_u64(&payload, 4)).map_err(|_| { + let encrypted_data_length = usize::try_from(read_u64(&header, 4)).map_err(|_| { invalid_value("EncryptedPayload", "payload length does not fit in usize") })?; - if payload.len() != 12 + encrypted_data_length { + if payload_size != 12 + u64::try_from(encrypted_data_length).unwrap() { return Err(invalid_value( "EncryptedPayload", "explicit payload length does not match the actual bytes", ) .into()); } + let mut encrypted_payload = vec![0_u8; encrypted_data_length]; + reader + .read_exact(&mut encrypted_payload) + .map_err(CodecError::Io)?; self.full_box = FullBoxState { version, - flags: ((payload[1] as u32) << 16) | ((payload[2] as u32) << 8) | u32::from(payload[3]), + flags: ((header[1] as u32) << 16) | ((header[2] as u32) << 8) | u32::from(header[3]), }; - self.encrypted_payload = payload[12..].to_vec(); + self.encrypted_payload = encrypted_payload; Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size < 12 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + let mut header = [0_u8; 12]; + tokio::io::AsyncReadExt::read_exact(reader, &mut header) + .await + .map_err(CodecError::Io)?; + + let version = header[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + let encrypted_data_length = usize::try_from(read_u64(&header, 4)).map_err(|_| { + invalid_value("EncryptedPayload", "payload length does not fit in usize") + })?; + if payload_size != 12 + u64::try_from(encrypted_data_length).unwrap() { + return Err(invalid_value( + "EncryptedPayload", + "explicit payload length does not match the actual bytes", + ) + .into()); + } + let mut encrypted_payload = vec![0_u8; encrypted_data_length]; + tokio::io::AsyncReadExt::read_exact(reader, &mut encrypted_payload) + .await + .map_err(CodecError::Io)?; + + self.full_box = FullBoxState { + version, + flags: ((header[1] as u32) << 16) + | ((header[2] as u32) << 8) + | u32::from(header[3]), + }; + self.encrypted_payload = encrypted_payload; + Ok(Some(payload_size)) + }) + } } /// OMA group-key box nested under `ohdr`. @@ -879,15 +1075,13 @@ impl CodecBox for Grpi { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, CodecError> { - let payload_len = usize::try_from(payload_size) - .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; - let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; - - if payload.len() < 9 { + if payload_size < 9 { return Err(invalid_value("Payload", "payload is too short").into()); } + let mut header = [0_u8; 9]; + reader.read_exact(&mut header).map_err(CodecError::Io)?; - let version = payload[0]; + let version = header[0]; if version != 0 { return Err(CodecError::UnsupportedVersion { box_type: self.box_type(), @@ -895,29 +1089,87 @@ impl CodecBox for Grpi { }); } - let group_id_len = usize::from(read_u16(&payload, 4)); - let group_key_len = usize::from(read_u16(&payload, 7)); + let group_id_len = usize::from(read_u16(&header, 4)); + let group_key_len = usize::from(read_u16(&header, 7)); let fixed_len = 9 + group_id_len + group_key_len; - if payload.len() != fixed_len { + if payload_size != u64::try_from(fixed_len).unwrap() { return Err(invalid_value( "Payload", "group-id and group-key lengths do not match the payload size", ) .into()); } - - let group_id_offset = 9; - let group_key_offset = group_id_offset + group_id_len; + let mut group_id_bytes = vec![0_u8; group_id_len]; + let mut group_key = vec![0_u8; group_key_len]; + reader + .read_exact(&mut group_id_bytes) + .map_err(CodecError::Io)?; + reader.read_exact(&mut group_key).map_err(CodecError::Io)?; self.full_box = FullBoxState { version, - flags: ((payload[1] as u32) << 16) | ((payload[2] as u32) << 8) | u32::from(payload[3]), + flags: ((header[1] as u32) << 16) | ((header[2] as u32) << 8) | u32::from(header[3]), }; - self.key_encryption_method = payload[6]; - self.group_id = decode_utf8_string("GroupId", &payload[group_id_offset..group_key_offset])?; - self.group_key = payload[group_key_offset..].to_vec(); + self.key_encryption_method = header[6]; + self.group_id = decode_utf8_string("GroupId", &group_id_bytes)?; + self.group_key = group_key; Ok(Some(payload_size)) } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async move { + if payload_size < 9 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + let mut header = [0_u8; 9]; + tokio::io::AsyncReadExt::read_exact(reader, &mut header) + .await + .map_err(CodecError::Io)?; + + let version = header[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + let group_id_len = usize::from(read_u16(&header, 4)); + let group_key_len = usize::from(read_u16(&header, 7)); + let fixed_len = 9 + group_id_len + group_key_len; + if payload_size != u64::try_from(fixed_len).unwrap() { + return Err(invalid_value( + "Payload", + "group-id and group-key lengths do not match the payload size", + ) + .into()); + } + let mut group_id_bytes = vec![0_u8; group_id_len]; + let mut group_key = vec![0_u8; group_key_len]; + tokio::io::AsyncReadExt::read_exact(reader, &mut group_id_bytes) + .await + .map_err(CodecError::Io)?; + tokio::io::AsyncReadExt::read_exact(reader, &mut group_key) + .await + .map_err(CodecError::Io)?; + + self.full_box = FullBoxState { + version, + flags: ((header[1] as u32) << 16) + | ((header[2] as u32) << 8) + | u32::from(header[3]), + }; + self.key_encryption_method = header[6]; + self.group_id = decode_utf8_string("GroupId", &group_id_bytes)?; + self.group_key = group_key; + Ok(Some(payload_size)) + }) + } } /// Registers the built-in OMA DCF boxes in the supplied registry. diff --git a/src/boxes/threegpp.rs b/src/boxes/threegpp.rs index 50975d1..380c2a4 100644 --- a/src/boxes/threegpp.rs +++ b/src/boxes/threegpp.rs @@ -1,4 +1,4 @@ -//! 3GPP user-data metadata string boxes scoped under `udta`. +//! 3GPP metadata and sample-entry child boxes. use crate::boxes::{AnyTypeBox, BoxLookupContext, BoxRegistry}; use crate::codec::{ @@ -13,6 +13,11 @@ const CPRT: FourCc = FourCc::from_bytes(*b"cprt"); const PERF: FourCc = FourCc::from_bytes(*b"perf"); const AUTH: FourCc = FourCc::from_bytes(*b"auth"); const GNRE: FourCc = FourCc::from_bytes(*b"gnre"); +const DAMR: FourCc = FourCc::from_bytes(*b"damr"); +const DQCP: FourCc = FourCc::from_bytes(*b"dqcp"); +const DEVC: FourCc = FourCc::from_bytes(*b"devc"); +const DSMV: FourCc = FourCc::from_bytes(*b"dsmv"); +const D263_BOX: FourCc = FourCc::from_bytes(*b"d263"); #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct FullBoxState { @@ -40,6 +45,10 @@ fn u8_from_unsigned(field_name: &'static str, value: u64) -> Result Result { + u16::try_from(value).map_err(|_| invalid_value(field_name, "value does not fit in u16")) +} + fn quote_bytes(bytes: &[u8]) -> String { format!("\"{}\"", escape_bytes(bytes)) } @@ -73,6 +82,257 @@ pub struct Udta3gppString { pub data: Vec, } +/// AMR-family decoder configuration carried by `damr` child boxes under `samr` and `sawb`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Damr { + /// Vendor identifier carried by the sample-entry child box. + pub vendor: u32, + /// Decoder version carried by the sample-entry child box. + pub decoder_version: u8, + /// Bitmask of AMR or AMR-WB frame types present in the stream. + pub mode_set: u16, + /// Mode-change cadence carried by the sample-entry child box. + pub mode_change_period: u8, + /// Number of codec frames stored in each MP4 sample. + pub frames_per_sample: u8, +} + +/// QCELP decoder configuration carried by `dqcp` child boxes under `sqcp`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Dqcp { + /// Vendor identifier carried by the sample-entry child box. + pub vendor: u32, + /// Decoder version carried by the sample-entry child box. + pub decoder_version: u8, + /// Number of codec frames stored in each MP4 sample. + pub frames_per_sample: u8, +} + +/// EVRC decoder configuration carried by `devc` child boxes under `sevc`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Devc { + /// Vendor identifier carried by the sample-entry child box. + pub vendor: u32, + /// Decoder version carried by the sample-entry child box. + pub decoder_version: u8, + /// Number of codec frames stored in each MP4 sample. + pub frames_per_sample: u8, +} + +/// SMV decoder configuration carried by `dsmv` child boxes under `ssmv`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Dsmv { + /// Vendor identifier carried by the sample-entry child box. + pub vendor: u32, + /// Decoder version carried by the sample-entry child box. + pub decoder_version: u8, + /// Number of codec frames stored in each MP4 sample. + pub frames_per_sample: u8, +} + +/// H.263 decoder configuration carried by `d263` child boxes under `s263`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct D263 { + /// Vendor identifier carried by the sample-entry child box. + pub vendor: u32, + /// Decoder version carried by the sample-entry child box. + pub decoder_version: u8, + /// H.263 level carried by the sample-entry child box. + pub h263_level: u8, + /// H.263 profile carried by the sample-entry child box. + pub h263_profile: u8, +} + +impl FieldHooks for Damr {} + +impl ImmutableBox for Damr { + fn box_type(&self) -> FourCc { + DAMR + } +} + +impl MutableBox for Damr {} + +impl FieldValueRead for Damr { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "Vendor" => Ok(FieldValue::Unsigned(u64::from(self.vendor))), + "DecoderVersion" => Ok(FieldValue::Unsigned(u64::from(self.decoder_version))), + "ModeSet" => Ok(FieldValue::Unsigned(u64::from(self.mode_set))), + "ModeChangePeriod" => Ok(FieldValue::Unsigned(u64::from(self.mode_change_period))), + "FramesPerSample" => Ok(FieldValue::Unsigned(u64::from(self.frames_per_sample))), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Damr { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("Vendor", FieldValue::Unsigned(value)) => { + self.vendor = u32::try_from(value) + .map_err(|_| invalid_value(field_name, "value does not fit in u32"))?; + Ok(()) + } + ("DecoderVersion", FieldValue::Unsigned(value)) => { + self.decoder_version = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("ModeSet", FieldValue::Unsigned(value)) => { + self.mode_set = u16_from_unsigned(field_name, value)?; + Ok(()) + } + ("ModeChangePeriod", FieldValue::Unsigned(value)) => { + self.mode_change_period = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("FramesPerSample", FieldValue::Unsigned(value)) => { + self.frames_per_sample = u8_from_unsigned(field_name, value)?; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Damr { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("Vendor", 0, with_bit_width(32)), + codec_field!("DecoderVersion", 1, with_bit_width(8)), + codec_field!("ModeSet", 2, with_bit_width(16), as_hex()), + codec_field!("ModeChangePeriod", 3, with_bit_width(8)), + codec_field!("FramesPerSample", 4, with_bit_width(8)), + ]); +} + +macro_rules! impl_voice_decoder_config_box { + ($type_name:ident, $box_type:ident) => { + impl FieldHooks for $type_name {} + + impl ImmutableBox for $type_name { + fn box_type(&self) -> FourCc { + $box_type + } + } + + impl MutableBox for $type_name {} + + impl FieldValueRead for $type_name { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "Vendor" => Ok(FieldValue::Unsigned(u64::from(self.vendor))), + "DecoderVersion" => Ok(FieldValue::Unsigned(u64::from(self.decoder_version))), + "FramesPerSample" => { + Ok(FieldValue::Unsigned(u64::from(self.frames_per_sample))) + } + _ => Err(missing_field(field_name)), + } + } + } + + impl FieldValueWrite for $type_name { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("Vendor", FieldValue::Unsigned(value)) => { + self.vendor = u32::try_from(value) + .map_err(|_| invalid_value(field_name, "value does not fit in u32"))?; + Ok(()) + } + ("DecoderVersion", FieldValue::Unsigned(value)) => { + self.decoder_version = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("FramesPerSample", FieldValue::Unsigned(value)) => { + self.frames_per_sample = u8_from_unsigned(field_name, value)?; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } + } + + impl CodecBox for $type_name { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("Vendor", 0, with_bit_width(32)), + codec_field!("DecoderVersion", 1, with_bit_width(8)), + codec_field!("FramesPerSample", 2, with_bit_width(8)), + ]); + } + }; +} + +impl_voice_decoder_config_box!(Dqcp, DQCP); +impl_voice_decoder_config_box!(Devc, DEVC); +impl_voice_decoder_config_box!(Dsmv, DSMV); + +impl FieldHooks for D263 {} + +impl ImmutableBox for D263 { + fn box_type(&self) -> FourCc { + D263_BOX + } +} + +impl MutableBox for D263 {} + +impl FieldValueRead for D263 { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "Vendor" => Ok(FieldValue::Unsigned(u64::from(self.vendor))), + "DecoderVersion" => Ok(FieldValue::Unsigned(u64::from(self.decoder_version))), + "H263Level" => Ok(FieldValue::Unsigned(u64::from(self.h263_level))), + "H263Profile" => Ok(FieldValue::Unsigned(u64::from(self.h263_profile))), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for D263 { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("Vendor", FieldValue::Unsigned(value)) => { + self.vendor = u32::try_from(value) + .map_err(|_| invalid_value(field_name, "value does not fit in u32"))?; + Ok(()) + } + ("DecoderVersion", FieldValue::Unsigned(value)) => { + self.decoder_version = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("H263Level", FieldValue::Unsigned(value)) => { + self.h263_level = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("H263Profile", FieldValue::Unsigned(value)) => { + self.h263_profile = u8_from_unsigned(field_name, value)?; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for D263 { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("Vendor", 0, with_bit_width(32)), + codec_field!("DecoderVersion", 1, with_bit_width(8)), + codec_field!("H263Level", 2, with_bit_width(8)), + codec_field!("H263Profile", 3, with_bit_width(8)), + ]); +} + impl Default for Udta3gppString { fn default() -> Self { Self { @@ -196,4 +456,9 @@ pub fn register_boxes(registry: &mut BoxRegistry) { registry.register_contextual_any::(CPRT, is_under_udta); registry.register_contextual_any::(GNRE, is_under_udta); + registry.register::(DAMR); + registry.register::(DQCP); + registry.register::(DEVC); + registry.register::(DSMV); + registry.register::(D263_BOX); } diff --git a/src/boxes/vp.rs b/src/boxes/vp.rs index 20d8dd6..0e27dfe 100644 --- a/src/boxes/vp.rs +++ b/src/boxes/vp.rs @@ -1,4 +1,4 @@ -//! VP8/VP9 sample-entry and codec-configuration box definitions. +//! VP8/VP9/VP10 sample-entry and codec-configuration box definitions. use super::iso14496_12::VisualSampleEntry; use crate::boxes::BoxRegistry; @@ -191,5 +191,6 @@ impl CodecBox for VpCodecConfiguration { pub fn register_boxes(registry: &mut BoxRegistry) { registry.register_any::(FourCc::from_bytes(*b"vp08")); registry.register_any::(FourCc::from_bytes(*b"vp09")); + registry.register_any::(FourCc::from_bytes(*b"vp10")); registry.register::(FourCc::from_bytes(*b"vpcC")); } diff --git a/src/cli/decrypt.rs b/src/cli/decrypt.rs index 8d95429..65bf6d6 100644 --- a/src/cli/decrypt.rs +++ b/src/cli/decrypt.rs @@ -2,13 +2,13 @@ use std::error::Error; use std::fmt; -use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; +use super::write_error_line; use crate::decrypt::{ DecryptError, DecryptOptions, DecryptProgress, DecryptProgressPhase, ParseDecryptionKeyError, - decrypt_file, decrypt_file_with_progress, + decrypt_file_with_optional_progress_and_fragments_info_path, }; /// Runs the decrypt subcommand with `args`, writing progress and failures to `stderr`. @@ -23,7 +23,7 @@ where 1 } Err(error) => { - let _ = writeln!(stderr, "Error: {error}"); + let _ = write_error_line(stderr, &error, error.diagnostic_context()); 1 } } @@ -116,6 +116,17 @@ impl From for DecryptCliError { } } +impl DecryptCliError { + fn diagnostic_context(&self) -> Option<(&'static str, &'static str)> { + match self { + Self::Io(..) => Some(("io", "io")), + Self::Decrypt(error) => Some((error.stage(), error.category())), + Self::ParseKey(..) | Self::InvalidArgument(..) => Some(("request", "input")), + Self::UsageRequested => None, + } + } +} + struct ParsedArgs { show_progress: bool, key_specs: Vec, @@ -134,14 +145,23 @@ where options.add_key_spec(key_spec)?; } - if let Some(path) = &parsed.fragments_info { - options.set_fragments_info_bytes(fs::read(path)?); - } - if parsed.show_progress { - decrypt_file_with_cli_progress(&parsed.input, &parsed.output, &options, stderr) + decrypt_file_with_cli_progress( + &parsed.input, + &parsed.output, + parsed.fragments_info.as_deref(), + &options, + stderr, + ) } else { - decrypt_file(&parsed.input, &parsed.output, &options).map_err(Into::into) + decrypt_file_with_optional_progress_and_fragments_info_path( + &parsed.input, + &parsed.output, + parsed.fragments_info.as_deref(), + &options, + None::, + ) + .map_err(Into::into) } } @@ -215,6 +235,7 @@ fn parse_args(args: &[String]) -> Result { fn decrypt_file_with_cli_progress( input: &Path, output: &Path, + fragments_info: Option<&Path>, options: &DecryptOptions, stderr: &mut E, ) -> Result<(), DecryptCliError> @@ -222,13 +243,19 @@ where E: Write, { let mut progress_write_error = None; - decrypt_file_with_progress(input, output, options, |snapshot| { - if progress_write_error.is_none() - && let Err(error) = write_progress_snapshot(stderr, snapshot) - { - progress_write_error = Some(error); - } - })?; + decrypt_file_with_optional_progress_and_fragments_info_path( + input, + output, + fragments_info, + options, + Some(|snapshot| { + if progress_write_error.is_none() + && let Err(error) = write_progress_snapshot(stderr, snapshot) + { + progress_write_error = Some(error); + } + }), + )?; if let Some(error) = progress_write_error { return Err(DecryptCliError::Io(error)); diff --git a/src/cli/divide.rs b/src/cli/divide.rs index d06c295..264649f 100644 --- a/src/cli/divide.rs +++ b/src/cli/divide.rs @@ -6,13 +6,16 @@ use std::fmt; use std::fs::{self, File}; use std::io::{self, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use super::{write_error_line, write_warning_lines}; use crate::FourCc; use crate::boxes::iso14496_12::{Tfhd, Tkhd}; use crate::extract::{ExtractError, extract_boxes_with_payload}; use crate::header::{BoxInfo, HeaderError}; use crate::probe::{ - DetailedProbeInfo, DetailedTrackInfo, ProbeError, TrackCodecFamily, probe_detailed, + DetailedProbeInfo, DetailedTrackInfo, FragmentedTrackWarningDiagnostics, ProbeError, + TrackCodecFamily, fragmented_track_warning_diagnostics, probe_detailed, }; use crate::walk::BoxPath; use crate::writer::{Writer, WriterError}; @@ -27,6 +30,36 @@ const SIDX: FourCc = FourCc::from_bytes(*b"sidx"); const TRAK: FourCc = FourCc::from_bytes(*b"trak"); const TKHD: FourCc = FourCc::from_bytes(*b"tkhd"); const TFHD: FourCc = FourCc::from_bytes(*b"tfhd"); +const AVC1: FourCc = FourCc::from_bytes(*b"avc1"); +const HEV1: FourCc = FourCc::from_bytes(*b"hev1"); +const HVC1: FourCc = FourCc::from_bytes(*b"hvc1"); +const DVHE: FourCc = FourCc::from_bytes(*b"dvhe"); +const DVH1: FourCc = FourCc::from_bytes(*b"dvh1"); +const AV01: FourCc = FourCc::from_bytes(*b"av01"); +const VP08: FourCc = FourCc::from_bytes(*b"vp08"); +const VP09: FourCc = FourCc::from_bytes(*b"vp09"); +const MP4A: FourCc = FourCc::from_bytes(*b"mp4a"); +const OPUS: FourCc = FourCc::from_bytes(*b"Opus"); +const AC_3: FourCc = FourCc::from_bytes(*b"ac-3"); +const EC_3: FourCc = FourCc::from_bytes(*b"ec-3"); +const AC_4: FourCc = FourCc::from_bytes(*b"ac-4"); +const ALAC: FourCc = FourCc::from_bytes(*b"alac"); +const DTSC: FourCc = FourCc::from_bytes(*b"dtsc"); +const DTSE: FourCc = FourCc::from_bytes(*b"dtse"); +const DTSH: FourCc = FourCc::from_bytes(*b"dtsh"); +const DTSL: FourCc = FourCc::from_bytes(*b"dtsl"); +const DTSM: FourCc = FourCc::from_bytes(*b"dtsm"); +const DTS_MINUS: FourCc = FourCc::from_bytes(*b"dts-"); +const DTSX: FourCc = FourCc::from_bytes(*b"dtsx"); +const DTSY: FourCc = FourCc::from_bytes(*b"dtsy"); +const FLAC: FourCc = FourCc::from_bytes(*b"fLaC"); +const IAMF: FourCc = FourCc::from_bytes(*b"iamf"); +const MHA1: FourCc = FourCc::from_bytes(*b"mha1"); +const MHA2: FourCc = FourCc::from_bytes(*b"mha2"); +const MHM1: FourCc = FourCc::from_bytes(*b"mhm1"); +const MHM2: FourCc = FourCc::from_bytes(*b"mhm2"); +const IPCM: FourCc = FourCc::from_bytes(*b"ipcm"); +const FPCM: FourCc = FourCc::from_bytes(*b"fpcm"); const VIDEO_DIR: &str = "video"; const AUDIO_DIR: &str = "audio"; @@ -34,6 +67,121 @@ const VIDEO_ENC_DIR: &str = "video_enc"; const AUDIO_ENC_DIR: &str = "audio_enc"; const INIT_FILE_NAME: &str = "init.mp4"; const PLAYLIST_FILE_NAME: &str = "playlist.m3u8"; +const MANIFEST_FILE_NAME: &str = "manifest.mpd"; +const HLS_PLAYLIST_EXTENSION: &str = ".m3u8"; +const DASH_MANIFEST_EXTENSION: &str = ".mpd"; +const DEFAULT_DASH_MIN_BUFFER_TIME_MICROS: u64 = 2_000_000; +const DEFAULT_DASH_MINIMUM_UPDATE_PERIOD_MICROS: u64 = 5_000_000; + +/// Output-manifest families supported by the additive divide surface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DivideManifestSelection { + Hls, + Dash, + Both, +} + +/// DASH manifest modes supported by the additive divide surface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DashManifestMode { + Static, + Dynamic, +} + +/// DASH manifest layout families supported by the additive divide surface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DashManifestLayout { + Template, + List, +} + +/// DASH manifest profile signaling supported by the additive divide surface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DashManifestProfile { + Main, + Live, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HlsPlaylistType { + Vod, + Event, + Live, +} + +/// Additive divide output controls. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DivideOutputOptions { + pub manifest_selection: DivideManifestSelection, + pub dash_manifest_mode: DashManifestMode, + pub dash_manifest_layout: DashManifestLayout, + pub dash_manifest_profile: DashManifestProfile, + pub default_language: Option, + pub hls_base_url: Option, + pub hls_playlist_type: Option, + pub hls_start_time_offset_micros: Option, + pub hls_program_date_time: bool, + pub hls_master_playlist_name: Option, + pub hls_media_playlist_name: Option, + pub dash_period_id: Option, + pub dash_period_start_micros: Option, + pub dash_base_urls: Vec, + pub dash_manifest_name: Option, + pub dash_location: Option, + pub dash_min_buffer_time_micros: Option, + pub dash_minimum_update_period_micros: Option, + pub dash_suggested_presentation_delay_micros: Option, + pub dash_time_shift_buffer_depth_micros: Option, + pub dash_availability_start_time: Option, + pub dash_publish_time: Option, + pub dash_utc_timing_scheme: Option, + pub dash_utc_timing_value: Option, + pub dash_session_load_path: Option, + pub dash_session_save_path: Option, + dash_session_next_segment_indices: BTreeMap, + manifest_selection_explicit: bool, + dash_manifest_mode_explicit: bool, + dash_manifest_layout_explicit: bool, + dash_manifest_profile_explicit: bool, +} + +impl Default for DivideOutputOptions { + fn default() -> Self { + Self { + manifest_selection: DivideManifestSelection::Both, + dash_manifest_mode: DashManifestMode::Static, + dash_manifest_layout: DashManifestLayout::Template, + dash_manifest_profile: DashManifestProfile::Main, + default_language: None, + hls_base_url: None, + hls_playlist_type: None, + hls_start_time_offset_micros: None, + hls_program_date_time: false, + hls_master_playlist_name: None, + hls_media_playlist_name: None, + dash_period_id: None, + dash_period_start_micros: None, + dash_base_urls: Vec::new(), + dash_manifest_name: None, + dash_location: None, + dash_min_buffer_time_micros: None, + dash_minimum_update_period_micros: None, + dash_suggested_presentation_delay_micros: None, + dash_time_shift_buffer_depth_micros: None, + dash_availability_start_time: None, + dash_publish_time: None, + dash_utc_timing_scheme: None, + dash_utc_timing_value: None, + dash_session_load_path: None, + dash_session_save_path: None, + dash_session_next_segment_indices: BTreeMap::new(), + manifest_selection_explicit: false, + dash_manifest_mode_explicit: false, + dash_manifest_layout_explicit: false, + dash_manifest_profile_explicit: false, + } + } +} /// Runs the divide subcommand with `args`, writing files under `OUTPUT_DIR`. pub fn run(args: &[String], stderr: &mut E) -> i32 @@ -51,14 +199,14 @@ where W: Write, E: Write, { - match run_inner(args, stdout) { + match run_inner(args, stdout, stderr) { Ok(()) => 0, Err(DivideError::UsageRequested) => { let _ = write_usage(stderr); 1 } Err(error) => { - let _ = writeln!(stderr, "Error: {error}"); + let _ = write_error_line(stderr, &error, error.diagnostic_context()); 1 } } @@ -69,7 +217,10 @@ pub fn write_usage(writer: &mut W) -> io::Result<()> where W: Write, { - writeln!(writer, "USAGE: mp4forge divide INPUT.mp4 OUTPUT_DIR")?; + writeln!( + writer, + "USAGE: mp4forge divide [OPTIONS] INPUT.mp4 OUTPUT_DIR" + )?; writeln!(writer, " mp4forge divide -validate INPUT.mp4")?; writeln!(writer)?; writeln!(writer, "OPTIONS:")?; @@ -77,44 +228,152 @@ where writer, " -validate Validate the fragmented divide layout without writing output files" )?; + writeln!( + writer, + " -warnings Emit warning-grade diagnostics to stderr after a successful run" + )?; + writeln!( + writer, + " -manifest Manifest families to write (default: both)" + )?; + writeln!( + writer, + " -default-language Prefer this audio language in HLS defaults and DASH main-role signaling" + )?; + writeln!( + writer, + " -hls-base-url Prefix HLS playlist, init, and media segment URIs" + )?; + writeln!( + writer, + " -hls-playlist-type HLS playlist style (default: vod)" + )?; + writeln!( + writer, + " -hls-start-time-offset Add EXT-X-START with a signed seconds offset" + )?; + writeln!( + writer, + " -hls-program-date-time Add EXT-X-PROGRAM-DATE-TIME to HLS media playlists" + )?; + writeln!( + writer, + " -hls-master-playlist-name Override the root HLS master playlist file name" + )?; + writeln!( + writer, + " -hls-media-playlist-name Override per-track HLS media playlist file names" + )?; + writeln!( + writer, + " -dash-mode DASH manifest mode (default: static)" + )?; + writeln!( + writer, + " -dash-layout DASH manifest layout (default: template)" + )?; + writeln!( + writer, + " -dash-profile DASH profile signaling (default: main)" + )?; + writeln!( + writer, + " -dash-base-url Add one DASH BaseURL element (repeatable)" + )?; + writeln!( + writer, + " -dash-manifest-name Override the root DASH manifest file name" + )?; + writeln!( + writer, + " -dash-session-load Reload saved DASH session controls and next-period continuity" + )?; + writeln!( + writer, + " -dash-session-save Save DASH session controls and next-period continuity" + )?; writeln!(writer)?; writeln!( writer, - "Currently supports fragmented inputs with up to one AVC video track and one MP4A audio track," + "Successful output writes the selected retained HLS playlist tree and/or additive MPD manifest." )?; writeln!( writer, - "including encrypted wrappers that preserve those original sample-entry formats." + "DASH metadata such as Period ids, timing descriptors, and dynamic refresh attributes use built-in defaults." + )?; + writeln!(writer)?; + writeln!( + writer, + "Currently supports fragmented inputs with up to one video track from AVC, HEVC, Dolby Vision on HEVC, AV1, VP8, or VP9" + )?; + writeln!( + writer, + "and one or more audio tracks from MP4A-based audio, Opus, AC-3, E-AC-3, AC-4, ALAC, DTS-family entries, FLAC, IAMF, MPEG-H, or PCM," + )?; + writeln!( + writer, + "including encrypted wrappers that preserve those original sample-entry formats. Subtitle and text tracks remain unsupported." ) } #[derive(Debug)] struct ParsedDivideArgs<'a> { validate_only: bool, + emit_warnings: bool, input_path: &'a Path, output_dir: Option<&'a Path>, + output_options: DivideOutputOptions, } -fn run_inner(args: &[String], stdout: &mut W) -> Result<(), DivideError> +fn run_inner(args: &[String], stdout: &mut W, stderr: &mut E) -> Result<(), DivideError> where W: Write, + E: Write, { let parsed = parse_args(args)?; + let output_options = + resolve_divide_output_options(parsed.output_options, parsed.validate_only)?; let mut input = File::open(parsed.input_path)?; + let (warning_plans, warning_lines) = if parsed.emit_warnings { + let plans = validate_divide_track_plans(&mut input)?; + let warnings = collect_fragmented_warning_lines(&mut input, &plans, &output_options)?; + input.seek(SeekFrom::Start(0))?; + (Some(plans), Some(warnings)) + } else { + (None, None) + }; + if parsed.validate_only { - let report = validate_divide_reader(&mut input)?; + let report = if let Some(plans) = warning_plans.as_ref() { + build_divide_validation_report(plans) + } else { + validate_divide_reader(&mut input)? + }; write_validation_report(stdout, &report)?; + if let Some(warnings) = warning_lines.as_ref() { + write_warning_lines(stderr, warnings)?; + } return Ok(()); } - divide_reader( + input.seek(SeekFrom::Start(0))?; + divide_reader_with_options( &mut input, parsed.output_dir.ok_or(DivideError::UsageRequested)?, - ) + output_options.clone(), + )?; + + if let Some(warnings) = warning_lines.as_ref() { + write_warning_lines(stderr, warnings)?; + } + + Ok(()) } fn parse_args(args: &[String]) -> Result, DivideError> { let mut validate_only = false; + let mut emit_warnings = false; + let mut output_options = DivideOutputOptions::default(); let mut positional = Vec::new(); let mut index = 0usize; while index < args.len() { @@ -123,6 +382,208 @@ fn parse_args(args: &[String]) -> Result, DivideError> { validate_only = true; index += 1; } + "-warnings" | "--warnings" => { + emit_warnings = true; + index += 1; + } + "-manifest" | "--manifest" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(invalid_input( + "missing value for divide option `-manifest`".to_string(), + )); + }; + output_options.manifest_selection = match value.as_str() { + "hls" => DivideManifestSelection::Hls, + "dash" => DivideManifestSelection::Dash, + "both" => DivideManifestSelection::Both, + other => { + return Err(invalid_input(format!( + "unsupported divide manifest selection: {other}" + ))); + } + }; + output_options.manifest_selection_explicit = true; + index += 1; + } + "-default-language" | "--default-language" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(invalid_input( + "missing value for divide option `-default-language`".to_string(), + )); + }; + output_options.default_language = Some(parse_divide_language_tag(value)?); + index += 1; + } + "-hls-base-url" | "--hls-base-url" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(invalid_input( + "missing value for divide option `-hls-base-url`".to_string(), + )); + }; + output_options.hls_base_url = Some(value.clone()); + index += 1; + } + "-hls-playlist-type" | "--hls-playlist-type" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(invalid_input( + "missing value for divide option `-hls-playlist-type`".to_string(), + )); + }; + output_options.hls_playlist_type = Some(match value.as_str() { + "vod" => HlsPlaylistType::Vod, + "event" => HlsPlaylistType::Event, + "live" => HlsPlaylistType::Live, + other => { + return Err(invalid_input(format!( + "unsupported divide HLS playlist type: {other}" + ))); + } + }); + index += 1; + } + "-hls-start-time-offset" | "--hls-start-time-offset" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(invalid_input( + "missing value for divide option `-hls-start-time-offset`".to_string(), + )); + }; + output_options.hls_start_time_offset_micros = + Some(parse_hls_start_time_offset_micros(value)?); + index += 1; + } + "-hls-program-date-time" | "--hls-program-date-time" => { + output_options.hls_program_date_time = true; + index += 1; + } + "-hls-master-playlist-name" | "--hls-master-playlist-name" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(invalid_input( + "missing value for divide option `-hls-master-playlist-name`".to_string(), + )); + }; + output_options.hls_master_playlist_name = Some(value.clone()); + index += 1; + } + "-hls-media-playlist-name" | "--hls-media-playlist-name" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(invalid_input( + "missing value for divide option `-hls-media-playlist-name`".to_string(), + )); + }; + output_options.hls_media_playlist_name = Some(value.clone()); + index += 1; + } + "-dash-mode" | "--dash-mode" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(invalid_input( + "missing value for divide option `-dash-mode`".to_string(), + )); + }; + output_options.dash_manifest_mode = match value.as_str() { + "static" => DashManifestMode::Static, + "dynamic" => DashManifestMode::Dynamic, + other => { + return Err(invalid_input(format!( + "unsupported divide DASH mode: {other}" + ))); + } + }; + output_options.dash_manifest_mode_explicit = true; + index += 1; + } + "-dash-layout" | "--dash-layout" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(invalid_input( + "missing value for divide option `-dash-layout`".to_string(), + )); + }; + output_options.dash_manifest_layout = match value.as_str() { + "template" => DashManifestLayout::Template, + "list" => DashManifestLayout::List, + other => { + return Err(invalid_input(format!( + "unsupported divide DASH layout: {other}" + ))); + } + }; + output_options.dash_manifest_layout_explicit = true; + index += 1; + } + "-dash-profile" | "--dash-profile" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(invalid_input( + "missing value for divide option `-dash-profile`".to_string(), + )); + }; + output_options.dash_manifest_profile = match value.as_str() { + "main" => DashManifestProfile::Main, + "live" => DashManifestProfile::Live, + other => { + return Err(invalid_input(format!( + "unsupported divide DASH profile: {other}" + ))); + } + }; + output_options.dash_manifest_profile_explicit = true; + index += 1; + } + "-dash-base-url" | "--dash-base-url" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(invalid_input( + "missing value for divide option `-dash-base-url`".to_string(), + )); + }; + output_options.dash_base_urls.push(value.clone()); + index += 1; + } + "-dash-manifest-name" | "--dash-manifest-name" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(invalid_input( + "missing value for divide option `-dash-manifest-name`".to_string(), + )); + }; + output_options.dash_manifest_name = Some(value.clone()); + index += 1; + } + "-dash-session-load" | "--dash-session-load" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(invalid_input( + "missing value for divide option `-dash-session-load`".to_string(), + )); + }; + output_options.dash_session_load_path = Some(PathBuf::from(value)); + index += 1; + } + "-dash-session-save" | "--dash-session-save" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(invalid_input( + "missing value for divide option `-dash-session-save`".to_string(), + )); + }; + output_options.dash_session_save_path = Some(PathBuf::from(value)); + index += 1; + } + value if removed_dash_option_message(value).is_some() => { + return Err(invalid_input( + removed_dash_option_message(value) + .expect("checked above") + .to_string(), + )); + } "-h" | "--help" => return Err(DivideError::UsageRequested), value if value.starts_with('-') => { return Err(invalid_input(format!("unknown divide option: {value}"))); @@ -137,35 +598,790 @@ fn parse_args(args: &[String]) -> Result, DivideError> { match (validate_only, positional.as_slice()) { (true, [input_path]) => Ok(ParsedDivideArgs { validate_only, + emit_warnings, input_path, output_dir: None, + output_options, }), (false, [input_path, output_dir]) => Ok(ParsedDivideArgs { validate_only, + emit_warnings, input_path, output_dir: Some(output_dir), + output_options, }), _ => Err(DivideError::UsageRequested), } } +fn removed_dash_option_message(option: &str) -> Option<&'static str> { + match option { + "-dash-period-id" | "--dash-period-id" => Some( + "divide option `-dash-period-id` was removed; mp4forge now derives DASH Period identifiers internally when needed", + ), + "-dash-period-start" | "--dash-period-start" => Some( + "divide option `-dash-period-start` was removed; mp4forge now derives DASH Period start timing internally", + ), + "-dash-location" | "--dash-location" => Some( + "divide option `-dash-location` was removed; mp4forge now omits DASH Location unless the library API sets one explicitly", + ), + "-dash-min-buffer-time" | "--dash-min-buffer-time" => Some( + "divide option `-dash-min-buffer-time` was removed; mp4forge now uses built-in DASH minBufferTime defaults", + ), + "-dash-minimum-update-period" | "--dash-minimum-update-period" => Some( + "divide option `-dash-minimum-update-period` was removed; mp4forge now uses built-in DASH minimumUpdatePeriod defaults", + ), + "-dash-suggested-presentation-delay" | "--dash-suggested-presentation-delay" => Some( + "divide option `-dash-suggested-presentation-delay` was removed; mp4forge now uses built-in DASH suggestedPresentationDelay defaults", + ), + "-dash-time-shift-buffer-depth" | "--dash-time-shift-buffer-depth" => Some( + "divide option `-dash-time-shift-buffer-depth` was removed; mp4forge now uses built-in DASH timeShiftBufferDepth defaults", + ), + "-dash-availability-start-time" | "--dash-availability-start-time" => Some( + "divide option `-dash-availability-start-time` was removed; mp4forge now derives DASH availabilityStartTime internally", + ), + "-dash-publish-time" | "--dash-publish-time" => Some( + "divide option `-dash-publish-time` was removed; mp4forge now derives DASH publishTime internally", + ), + "-dash-utc-timing-scheme" | "--dash-utc-timing-scheme" => Some( + "divide option `-dash-utc-timing-scheme` was removed; mp4forge now omits DASH UTCTiming unless the library API sets it explicitly", + ), + "-dash-utc-timing-value" | "--dash-utc-timing-value" => Some( + "divide option `-dash-utc-timing-value` was removed; mp4forge now omits DASH UTCTiming unless the library API sets it explicitly", + ), + _ => None, + } +} + +#[derive(Default)] +struct DashSessionState { + next_period_id: Option, + next_period_start_micros: Option, + manifest_selection: Option, + dash_manifest_mode: Option, + dash_manifest_layout: Option, + dash_manifest_profile: Option, + dash_base_urls: Vec, + dash_location: Option, + dash_min_buffer_time_micros: Option, + dash_minimum_update_period_micros: Option, + dash_suggested_presentation_delay_micros: Option, + dash_time_shift_buffer_depth_micros: Option, + dash_availability_start_time: Option, + dash_publish_time: Option, + dash_utc_timing_scheme: Option, + dash_utc_timing_value: Option, + next_segment_indices: BTreeMap, +} + +struct DashManifestOutcome { + total_duration_micros: u64, + period_start_micros: u64, +} + +fn resolve_divide_output_options( + mut output_options: DivideOutputOptions, + validate_only: bool, +) -> Result { + validate_divide_output_request_shape(&output_options, validate_only)?; + if let Some(path) = output_options.dash_session_load_path.clone() { + let state = load_dash_session_state(&path)?; + apply_dash_session_state(&mut output_options, &state); + } + validate_divide_output_request_shape(&output_options, validate_only)?; + validate_dash_utc_timing_pair(&output_options)?; + Ok(output_options) +} + +fn apply_dash_session_state(output_options: &mut DivideOutputOptions, state: &DashSessionState) { + if output_options.dash_period_id.is_none() { + output_options.dash_period_id = state.next_period_id.clone(); + } + if output_options.dash_period_start_micros.is_none() { + output_options.dash_period_start_micros = state.next_period_start_micros; + } + if !output_options.manifest_selection_explicit + && let Some(manifest_selection) = state.manifest_selection + { + output_options.manifest_selection = manifest_selection; + } + if !output_options.dash_manifest_mode_explicit + && let Some(dash_manifest_mode) = state.dash_manifest_mode + { + output_options.dash_manifest_mode = dash_manifest_mode; + } + if !output_options.dash_manifest_layout_explicit + && let Some(dash_manifest_layout) = state.dash_manifest_layout + { + output_options.dash_manifest_layout = dash_manifest_layout; + } + if !output_options.dash_manifest_profile_explicit + && let Some(dash_manifest_profile) = state.dash_manifest_profile + { + output_options.dash_manifest_profile = dash_manifest_profile; + } + if output_options.dash_base_urls.is_empty() { + output_options.dash_base_urls = state.dash_base_urls.clone(); + } + if output_options.dash_location.is_none() { + output_options.dash_location = state.dash_location.clone(); + } + if output_options.dash_min_buffer_time_micros.is_none() { + output_options.dash_min_buffer_time_micros = state.dash_min_buffer_time_micros; + } + if output_options.dash_minimum_update_period_micros.is_none() { + output_options.dash_minimum_update_period_micros = state.dash_minimum_update_period_micros; + } + if output_options + .dash_suggested_presentation_delay_micros + .is_none() + { + output_options.dash_suggested_presentation_delay_micros = + state.dash_suggested_presentation_delay_micros; + } + if output_options.dash_time_shift_buffer_depth_micros.is_none() { + output_options.dash_time_shift_buffer_depth_micros = + state.dash_time_shift_buffer_depth_micros; + } + if output_options.dash_availability_start_time.is_none() { + output_options.dash_availability_start_time = state.dash_availability_start_time.clone(); + } + if output_options.dash_publish_time.is_none() { + output_options.dash_publish_time = state.dash_publish_time.clone(); + } + if output_options.dash_utc_timing_scheme.is_none() { + output_options.dash_utc_timing_scheme = state.dash_utc_timing_scheme.clone(); + } + if output_options.dash_utc_timing_value.is_none() { + output_options.dash_utc_timing_value = state.dash_utc_timing_value.clone(); + } + if output_options.dash_session_next_segment_indices.is_empty() { + output_options.dash_session_next_segment_indices = state.next_segment_indices.clone(); + } +} + +fn validate_dash_utc_timing_pair(output_options: &DivideOutputOptions) -> Result<(), DivideError> { + match ( + output_options.dash_utc_timing_scheme.as_ref(), + output_options.dash_utc_timing_value.as_ref(), + ) { + (Some(_), None) | (None, Some(_)) => Err(invalid_input( + "divide DASH UTCTiming requires both `-dash-utc-timing-scheme` and `-dash-utc-timing-value`".to_string(), + )), + _ => Ok(()), + } +} + +fn validate_divide_output_request_shape( + output_options: &DivideOutputOptions, + validate_only: bool, +) -> Result<(), DivideError> { + validate_divide_output_name_constraints(output_options)?; + validate_hls_manifest_selection(output_options)?; + validate_dash_manifest_selection(output_options)?; + validate_dash_mode_constraints(output_options)?; + validate_dash_session_constraints(output_options, validate_only)?; + Ok(()) +} + +fn validate_divide_output_name_constraints( + output_options: &DivideOutputOptions, +) -> Result<(), DivideError> { + if let Some(name) = output_options.hls_master_playlist_name.as_deref() { + validate_divide_output_file_name( + name, + "-hls-master-playlist-name", + HLS_PLAYLIST_EXTENSION, + )?; + } + if let Some(name) = output_options.hls_media_playlist_name.as_deref() { + validate_divide_output_file_name(name, "-hls-media-playlist-name", HLS_PLAYLIST_EXTENSION)?; + } + if let Some(name) = output_options.dash_manifest_name.as_deref() { + validate_divide_output_file_name(name, "-dash-manifest-name", DASH_MANIFEST_EXTENSION)?; + } + Ok(()) +} + +fn validate_divide_output_file_name( + value: &str, + option: &str, + required_extension: &str, +) -> Result<(), DivideError> { + if value.is_empty() + || Path::new(value).components().count() != 1 + || !value.ends_with(required_extension) + { + return Err(invalid_input(format!( + "divide option `{option}` requires a plain `{required_extension}` file name: `{value}`" + ))); + } + Ok(()) +} + +fn validate_hls_manifest_selection( + output_options: &DivideOutputOptions, +) -> Result<(), DivideError> { + if output_options.manifest_selection != DivideManifestSelection::Dash { + return Ok(()); + } + + let unsupported_option = [ + (output_options.hls_base_url.is_some(), "-hls-base-url"), + ( + output_options.hls_playlist_type.is_some(), + "-hls-playlist-type", + ), + ( + output_options.hls_start_time_offset_micros.is_some(), + "-hls-start-time-offset", + ), + ( + output_options.hls_program_date_time, + "-hls-program-date-time", + ), + ( + output_options.hls_master_playlist_name.is_some(), + "-hls-master-playlist-name", + ), + ( + output_options.hls_media_playlist_name.is_some(), + "-hls-media-playlist-name", + ), + ] + .into_iter() + .find_map(|(present, option)| present.then_some(option)); + + if let Some(option) = unsupported_option { + return Err(invalid_input(format!( + "divide manifest selection `dash` does not support `{option}`; use `-manifest hls` or `-manifest both`" + ))); + } + + Ok(()) +} + +fn validate_dash_manifest_selection( + output_options: &DivideOutputOptions, +) -> Result<(), DivideError> { + if output_options.manifest_selection != DivideManifestSelection::Hls { + return Ok(()); + } + + if output_options.dash_manifest_mode_explicit { + return Err(invalid_input( + "divide manifest selection `hls` does not support `-dash-mode`; use `-manifest dash` or `-manifest both`".to_string(), + )); + } + if output_options.dash_manifest_layout_explicit { + return Err(invalid_input( + "divide manifest selection `hls` does not support `-dash-layout`; use `-manifest dash` or `-manifest both`".to_string(), + )); + } + if output_options.dash_manifest_profile_explicit { + return Err(invalid_input( + "divide manifest selection `hls` does not support `-dash-profile`; use `-manifest dash` or `-manifest both`".to_string(), + )); + } + + let unsupported_option = [ + (output_options.dash_period_id.is_some(), "-dash-period-id"), + ( + output_options.dash_period_start_micros.is_some(), + "-dash-period-start", + ), + (!output_options.dash_base_urls.is_empty(), "-dash-base-url"), + ( + output_options.dash_manifest_name.is_some(), + "-dash-manifest-name", + ), + (output_options.dash_location.is_some(), "-dash-location"), + ( + output_options.dash_min_buffer_time_micros.is_some(), + "-dash-min-buffer-time", + ), + ( + output_options.dash_minimum_update_period_micros.is_some(), + "-dash-minimum-update-period", + ), + ( + output_options + .dash_suggested_presentation_delay_micros + .is_some(), + "-dash-suggested-presentation-delay", + ), + ( + output_options.dash_time_shift_buffer_depth_micros.is_some(), + "-dash-time-shift-buffer-depth", + ), + ( + output_options.dash_availability_start_time.is_some(), + "-dash-availability-start-time", + ), + ( + output_options.dash_publish_time.is_some(), + "-dash-publish-time", + ), + ( + output_options.dash_utc_timing_scheme.is_some(), + "-dash-utc-timing-scheme", + ), + ( + output_options.dash_utc_timing_value.is_some(), + "-dash-utc-timing-value", + ), + ( + output_options.dash_session_load_path.is_some(), + "-dash-session-load", + ), + ( + output_options.dash_session_save_path.is_some(), + "-dash-session-save", + ), + ] + .into_iter() + .find_map(|(present, option)| present.then_some(option)); + + if let Some(option) = unsupported_option { + return Err(invalid_input(format!( + "divide manifest selection `hls` does not support `{option}`; use `-manifest dash` or `-manifest both`" + ))); + } + + Ok(()) +} + +fn validate_dash_mode_constraints(output_options: &DivideOutputOptions) -> Result<(), DivideError> { + if output_options.dash_manifest_mode == DashManifestMode::Dynamic { + return Ok(()); + } + + let dynamic_only_option = [ + ( + output_options.dash_minimum_update_period_micros.is_some(), + "-dash-minimum-update-period", + ), + ( + output_options + .dash_suggested_presentation_delay_micros + .is_some(), + "-dash-suggested-presentation-delay", + ), + ( + output_options.dash_time_shift_buffer_depth_micros.is_some(), + "-dash-time-shift-buffer-depth", + ), + ( + output_options.dash_availability_start_time.is_some(), + "-dash-availability-start-time", + ), + ( + output_options.dash_publish_time.is_some(), + "-dash-publish-time", + ), + ] + .into_iter() + .find_map(|(present, option)| present.then_some(option)); + + if let Some(option) = dynamic_only_option { + return Err(invalid_input(format!( + "divide DASH mode `static` does not support `{option}`; use `-dash-mode dynamic`" + ))); + } + + if output_options.dash_manifest_profile == DashManifestProfile::Live { + return Err(invalid_input( + "divide DASH profile `live` requires `-dash-mode dynamic`".to_string(), + )); + } + + Ok(()) +} + +fn validate_dash_session_constraints( + output_options: &DivideOutputOptions, + validate_only: bool, +) -> Result<(), DivideError> { + if validate_only && output_options.dash_session_load_path.is_some() { + return Err(invalid_input( + "divide validation mode does not support `-dash-session-load`".to_string(), + )); + } + if validate_only && output_options.dash_session_save_path.is_some() { + return Err(invalid_input( + "divide validation mode does not support `-dash-session-save`".to_string(), + )); + } + + if let (Some(load_path), Some(save_path)) = ( + output_options.dash_session_load_path.as_ref(), + output_options.dash_session_save_path.as_ref(), + ) && load_path == save_path + { + return Err(invalid_input(format!( + "divide DASH session load and save paths must differ: `{}`", + load_path.display() + ))); + } + + Ok(()) +} + +fn load_dash_session_state(path: &Path) -> Result { + let contents = fs::read_to_string(path)?; + let mut state = DashSessionState::default(); + for (line_index, raw_line) in contents.lines().enumerate() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + return Err(invalid_input(format!( + "invalid divide session state line {} in `{}`", + line_index + 1, + path.display() + ))); + }; + match key { + "next_period_id" => state.next_period_id = Some(value.to_string()), + "next_period_start_micros" => { + state.next_period_start_micros = + Some(parse_session_micros(value, path, line_index + 1)?) + } + "manifest_selection" => { + state.manifest_selection = Some(parse_dash_session_manifest_selection( + value, + path, + line_index + 1, + )?) + } + "dash_manifest_mode" => { + state.dash_manifest_mode = + Some(parse_dash_session_mode(value, path, line_index + 1)?) + } + "dash_manifest_layout" => { + state.dash_manifest_layout = + Some(parse_dash_session_layout(value, path, line_index + 1)?) + } + "dash_manifest_profile" => { + state.dash_manifest_profile = + Some(parse_dash_session_profile(value, path, line_index + 1)?) + } + "dash_base_url" => state.dash_base_urls.push(value.to_string()), + "dash_location" => state.dash_location = Some(value.to_string()), + "dash_min_buffer_time_micros" => { + state.dash_min_buffer_time_micros = + Some(parse_session_micros(value, path, line_index + 1)?) + } + "dash_minimum_update_period_micros" => { + state.dash_minimum_update_period_micros = + Some(parse_session_micros(value, path, line_index + 1)?) + } + "dash_suggested_presentation_delay_micros" => { + state.dash_suggested_presentation_delay_micros = + Some(parse_session_micros(value, path, line_index + 1)?) + } + "dash_time_shift_buffer_depth_micros" => { + state.dash_time_shift_buffer_depth_micros = + Some(parse_session_micros(value, path, line_index + 1)?) + } + "dash_availability_start_time" => { + state.dash_availability_start_time = Some(value.to_string()) + } + "dash_publish_time" => state.dash_publish_time = Some(value.to_string()), + "dash_utc_timing_scheme" => state.dash_utc_timing_scheme = Some(value.to_string()), + "dash_utc_timing_value" => state.dash_utc_timing_value = Some(value.to_string()), + _ if key.starts_with("next_segment_index_track_") => { + let track_id = key["next_segment_index_track_".len()..] + .parse::() + .map_err(|_| { + invalid_input(format!( + "invalid divide session state line {} in `{}`", + line_index + 1, + path.display() + )) + })?; + let next_segment_index = value.parse::().map_err(|_| { + invalid_input(format!( + "invalid divide session state value on line {} in `{}`", + line_index + 1, + path.display() + )) + })?; + state + .next_segment_indices + .insert(track_id, next_segment_index); + } + _ => {} + } + } + Ok(state) +} + +fn parse_session_micros(value: &str, path: &Path, line_number: usize) -> Result { + value.parse::().map_err(|_| { + invalid_input(format!( + "invalid divide session state value on line {} in `{}`", + line_number, + path.display() + )) + }) +} + +fn parse_hls_start_time_offset_micros(value: &str) -> Result { + let seconds = value.parse::().map_err(|_| { + invalid_input(format!( + "invalid divide HLS start time offset seconds: {value}" + )) + })?; + if !seconds.is_finite() { + return Err(invalid_input(format!( + "invalid divide HLS start time offset seconds: {value}" + ))); + } + let micros = (seconds * 1_000_000.0).round(); + if micros < i64::MIN as f64 || micros > i64::MAX as f64 { + return Err(invalid_input(format!( + "divide HLS start time offset is out of range: {value}" + ))); + } + Ok(micros as i64) +} + +fn parse_dash_session_mode( + value: &str, + path: &Path, + line_number: usize, +) -> Result { + match value { + "static" => Ok(DashManifestMode::Static), + "dynamic" => Ok(DashManifestMode::Dynamic), + _ => Err(invalid_input(format!( + "invalid divide session state value on line {} in `{}`", + line_number, + path.display() + ))), + } +} + +fn parse_dash_session_manifest_selection( + value: &str, + path: &Path, + line_number: usize, +) -> Result { + match value { + "hls" => Ok(DivideManifestSelection::Hls), + "dash" => Ok(DivideManifestSelection::Dash), + "both" => Ok(DivideManifestSelection::Both), + _ => Err(invalid_input(format!( + "invalid divide session state value on line {} in `{}`", + line_number, + path.display() + ))), + } +} + +fn parse_dash_session_layout( + value: &str, + path: &Path, + line_number: usize, +) -> Result { + match value { + "template" => Ok(DashManifestLayout::Template), + "list" => Ok(DashManifestLayout::List), + _ => Err(invalid_input(format!( + "invalid divide session state value on line {} in `{}`", + line_number, + path.display() + ))), + } +} + +fn parse_dash_session_profile( + value: &str, + path: &Path, + line_number: usize, +) -> Result { + match value { + "main" => Ok(DashManifestProfile::Main), + "live" => Ok(DashManifestProfile::Live), + _ => Err(invalid_input(format!( + "invalid divide session state value on line {} in `{}`", + line_number, + path.display() + ))), + } +} + +fn save_dash_session_state(path: &Path, state: &DashSessionState) -> Result<(), DivideError> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent)?; + } + let mut file = File::create(path)?; + if let Some(next_period_id) = state.next_period_id.as_deref() { + writeln!(file, "next_period_id={next_period_id}")?; + } + if let Some(next_period_start_micros) = state.next_period_start_micros { + writeln!(file, "next_period_start_micros={next_period_start_micros}")?; + } + write_optional_state_string( + &mut file, + "manifest_selection", + state.manifest_selection.map(divide_manifest_selection_name), + )?; + write_optional_state_string( + &mut file, + "dash_manifest_mode", + state.dash_manifest_mode.map(dash_manifest_mode_name), + )?; + write_optional_state_string( + &mut file, + "dash_manifest_layout", + state.dash_manifest_layout.map(dash_manifest_layout_name), + )?; + write_optional_state_string( + &mut file, + "dash_manifest_profile", + state.dash_manifest_profile.map(dash_manifest_profile_name), + )?; + for base_url in &state.dash_base_urls { + writeln!(file, "dash_base_url={base_url}")?; + } + write_optional_state_string(&mut file, "dash_location", state.dash_location.as_deref())?; + write_optional_state_u64( + &mut file, + "dash_min_buffer_time_micros", + state.dash_min_buffer_time_micros, + )?; + write_optional_state_u64( + &mut file, + "dash_minimum_update_period_micros", + state.dash_minimum_update_period_micros, + )?; + write_optional_state_u64( + &mut file, + "dash_suggested_presentation_delay_micros", + state.dash_suggested_presentation_delay_micros, + )?; + write_optional_state_u64( + &mut file, + "dash_time_shift_buffer_depth_micros", + state.dash_time_shift_buffer_depth_micros, + )?; + write_optional_state_string( + &mut file, + "dash_availability_start_time", + state.dash_availability_start_time.as_deref(), + )?; + write_optional_state_string( + &mut file, + "dash_publish_time", + state.dash_publish_time.as_deref(), + )?; + write_optional_state_string( + &mut file, + "dash_utc_timing_scheme", + state.dash_utc_timing_scheme.as_deref(), + )?; + write_optional_state_string( + &mut file, + "dash_utc_timing_value", + state.dash_utc_timing_value.as_deref(), + )?; + for (track_id, next_segment_index) in &state.next_segment_indices { + writeln!( + file, + "next_segment_index_track_{track_id}={next_segment_index}" + )?; + } + Ok(()) +} + +fn write_optional_state_string( + file: &mut File, + key: &str, + value: Option<&str>, +) -> Result<(), DivideError> { + if let Some(value) = value { + writeln!(file, "{key}={value}")?; + } + Ok(()) +} + +fn write_optional_state_u64( + file: &mut File, + key: &str, + value: Option, +) -> Result<(), DivideError> { + if let Some(value) = value { + writeln!(file, "{key}={value}")?; + } + Ok(()) +} + +fn dash_manifest_mode_name(mode: DashManifestMode) -> &'static str { + match mode { + DashManifestMode::Static => "static", + DashManifestMode::Dynamic => "dynamic", + } +} + +fn divide_manifest_selection_name(selection: DivideManifestSelection) -> &'static str { + match selection { + DivideManifestSelection::Hls => "hls", + DivideManifestSelection::Dash => "dash", + DivideManifestSelection::Both => "both", + } +} + +fn dash_manifest_layout_name(layout: DashManifestLayout) -> &'static str { + match layout { + DashManifestLayout::Template => "template", + DashManifestLayout::List => "list", + } +} + +fn dash_manifest_profile_name(profile: DashManifestProfile) -> &'static str { + match profile { + DashManifestProfile::Main => "main", + DashManifestProfile::Live => "live", + } +} + /// Splits a fragmented MP4 reader into per-track outputs under `output_dir`. /// -/// The current `divide` surface supports fragmented inputs with at most one AVC video track and -/// one MP4A audio track, including encrypted `encv` and `enca` wrappers when the original format -/// is still `avc1` or `mp4a`. +/// The current `divide` surface supports fragmented inputs with at most one video track from AVC, +/// HEVC, Dolby Vision on HEVC, AV1, VP8, or VP9 and one or more audio tracks from MP4A-based audio, Opus, +/// AC-3, E-AC-3, AC-4, ALAC, DTS-family entries, FLAC, IAMF, MPEG-H, or PCM, including +/// encrypted `encv` and `enca` wrappers when the original format stays within that accepted +/// family set. Subtitle and text tracks remain unsupported in the current divide output model. pub fn divide_reader(reader: &mut R, output_dir: &Path) -> Result<(), DivideError> +where + R: Read + Seek, +{ + divide_reader_with_options(reader, output_dir, DivideOutputOptions::default()) +} + +/// Splits a fragmented MP4 reader into per-track outputs under `output_dir` with additive +/// manifest controls. +pub fn divide_reader_with_options( + reader: &mut R, + output_dir: &Path, + output_options: DivideOutputOptions, +) -> Result<(), DivideError> where R: Read + Seek, { let plans = validate_divide_track_plans(reader)?; - let mut tracks = build_track_outputs(&plans, output_dir)?; + let mut tracks = build_track_outputs(&plans, output_dir, &output_options)?; reader.seek(SeekFrom::Start(0))?; write_init_segments(reader, &mut tracks)?; reader.seek(SeekFrom::Start(0))?; write_media_segments(reader, &mut tracks)?; - write_playlists(output_dir, &tracks)?; + write_output_manifests(output_dir, &tracks, output_options)?; Ok(()) } @@ -214,21 +1430,28 @@ struct TrackLayout { role: DivideTrackRole, kind: TrackKind, codecs: String, + language: Option, audio_channels: Option, + sample_rate: Option, width: Option, height: Option, } struct TrackOutput { + track_id: u32, kind: TrackKind, codecs: String, + language: Option, audio_channels: Option, + sample_rate: Option, width: Option, height: Option, segment_durations: Vec, bandwidth: u64, + relative_dir: String, output_dir: PathBuf, init_writer: Writer, + first_segment_index: usize, next_segment_index: usize, } @@ -249,9 +1472,13 @@ where R: Read + Seek, { let plans = validate_divide_track_plans(reader)?; - Ok(DivideValidationReport { - tracks: plans.into_iter().map(|plan| plan.validation).collect(), - }) + Ok(build_divide_validation_report(&plans)) +} + +fn build_divide_validation_report(plans: &[ValidatedTrackPlan]) -> DivideValidationReport { + DivideValidationReport { + tracks: plans.iter().map(|plan| plan.validation.clone()).collect(), + } } fn validate_divide_track_plans(reader: &mut R) -> Result, DivideError> @@ -266,27 +1493,48 @@ where fn build_track_outputs( plans: &[ValidatedTrackPlan], output_dir: &Path, + output_options: &DivideOutputOptions, ) -> Result, DivideError> { let mut tracks = BTreeMap::new(); + let multiple_audio_tracks = plans + .iter() + .filter(|plan| plan.layout.role == DivideTrackRole::Audio) + .count() + > 1; for plan in plans { - let track_dir = output_dir.join(relative_dir(plan.layout.kind)); + let relative_dir = track_relative_dir( + plan.layout.kind, + plan.validation.track_id, + multiple_audio_tracks, + ); + let track_dir = output_dir.join(&relative_dir); fs::create_dir_all(&track_dir)?; let init_writer = Writer::new(File::create(track_dir.join(INIT_FILE_NAME))?); + let first_segment_index = output_options + .dash_session_next_segment_indices + .get(&plan.validation.track_id) + .copied() + .unwrap_or(0); tracks.insert( plan.validation.track_id, TrackOutput { + track_id: plan.validation.track_id, kind: plan.layout.kind, codecs: plan.layout.codecs.clone(), + language: plan.layout.language.clone(), audio_channels: plan.layout.audio_channels, + sample_rate: plan.layout.sample_rate, width: plan.layout.width, height: plan.layout.height, segment_durations: plan.segment_durations.clone(), bandwidth: 0, + relative_dir, output_dir: track_dir, init_writer, - next_segment_index: 0, + first_segment_index, + next_segment_index: first_segment_index, }, ); } @@ -314,7 +1562,6 @@ fn collect_track_plans( let mut tracks = BTreeMap::new(); let mut selected_video_track_id = None; - let mut selected_audio_track_id = None; for track in &summary.tracks { if !active_track_ids.contains(&track.summary.track_id) { @@ -333,17 +1580,7 @@ fn collect_track_plans( ))); } } - DivideTrackRole::Audio => { - if let Some(existing_track_id) = - selected_audio_track_id.replace(track.summary.track_id) - { - return Err(invalid_input(format!( - "{}; found multiple fragmented audio tracks ({existing_track_id} and {}).", - supported_scope_message(), - track.summary.track_id - ))); - } - } + DivideTrackRole::Audio => {} } let segment_durations = summary @@ -385,9 +1622,29 @@ fn collect_track_plans( Ok(plans) } +fn authoritative_track_format(track: &DetailedTrackInfo) -> Option { + track.original_format.or(track.sample_entry_type) +} + +fn video_track_kind(track: &DetailedTrackInfo) -> TrackKind { + if track.summary.encrypted { + TrackKind::EncryptedVideo + } else { + TrackKind::Video + } +} + +fn audio_track_kind(track: &DetailedTrackInfo) -> TrackKind { + if track.summary.encrypted { + TrackKind::EncryptedAudio + } else { + TrackKind::Audio + } +} + fn track_layout(track: &DetailedTrackInfo) -> Result { - match track.codec_family { - TrackCodecFamily::Avc => { + match authoritative_track_format(track) { + Some(AVC1) => { let avc = track.summary.avc.as_ref().ok_or_else(|| { invalid_input(format!( "track {} is missing the AVC decoder configuration needed for divide playlist signaling.", @@ -396,43 +1653,57 @@ fn track_layout(track: &DetailedTrackInfo) -> Result { })?; Ok(TrackLayout { role: DivideTrackRole::Video, - kind: if track.summary.encrypted { - TrackKind::EncryptedVideo - } else { - TrackKind::Video - }, + kind: video_track_kind(track), codecs: format!( "avc1.{:02x}{:02x}{:02x}", avc.profile, avc.profile_compatibility, avc.level ), + language: normalized_track_language(track), audio_channels: None, + sample_rate: None, width: track.display_width.or(Some(avc.width)), height: track.display_height.or(Some(avc.height)), }) } - TrackCodecFamily::Mp4Audio => { - let mp4a = track.summary.mp4a.as_ref().ok_or_else(|| { - invalid_input(format!( - "track {} is missing the MP4A decoder configuration needed for divide playlist signaling.", - track.summary.track_id - )) - })?; - Ok(TrackLayout { - role: DivideTrackRole::Audio, - kind: if track.summary.encrypted { - TrackKind::EncryptedAudio - } else { - TrackKind::Audio - }, - codecs: mp4a_codec_string(mp4a.object_type_indication, mp4a.audio_object_type), - audio_channels: track - .channel_count - .or(Some(mp4a.channel_count)) - .filter(|value| *value != 0), - width: None, - height: None, - }) - } + Some(HEV1 | HVC1 | DVHE | DVH1 | AV01 | VP08 | VP09) => Ok(TrackLayout { + role: DivideTrackRole::Video, + kind: video_track_kind(track), + codecs: track_codec_label(track), + language: normalized_track_language(track), + audio_channels: None, + sample_rate: None, + width: track.display_width, + height: track.display_height, + }), + Some(MP4A) => Ok(TrackLayout { + role: DivideTrackRole::Audio, + kind: audio_track_kind(track), + codecs: track.summary.mp4a.as_ref().map_or_else( + || track_codec_label(track), + |mp4a| mp4a_codec_string(mp4a.object_type_indication, mp4a.audio_object_type), + ), + language: normalized_track_language(track), + audio_channels: track + .channel_count + .or_else(|| track.summary.mp4a.as_ref().map(|mp4a| mp4a.channel_count)) + .filter(|value| *value != 0), + sample_rate: track.sample_rate.filter(|value| *value != 0), + width: None, + height: None, + }), + Some( + OPUS | AC_3 | EC_3 | AC_4 | ALAC | DTSC | DTSE | DTSH | DTSL | DTSM | DTS_MINUS | DTSX + | DTSY | FLAC | IAMF | MHA1 | MHA2 | MHM1 | MHM2 | IPCM | FPCM, + ) => Ok(TrackLayout { + role: DivideTrackRole::Audio, + kind: audio_track_kind(track), + codecs: track_codec_label(track), + language: normalized_track_language(track), + audio_channels: track.channel_count.filter(|value| *value != 0), + sample_rate: track.sample_rate.filter(|value| *value != 0), + width: None, + height: None, + }), _ => Err(invalid_input(format!( "track {} uses unsupported codec `{}`; {}", track.summary.track_id, @@ -583,27 +1854,135 @@ where Ok(()) } -fn write_playlists( +fn write_output_manifests( + output_dir: &Path, + tracks: &BTreeMap, + output_options: DivideOutputOptions, +) -> Result<(), DivideError> { + let dash_outcome = match output_options.manifest_selection { + DivideManifestSelection::Hls => { + write_hls_playlists(output_dir, tracks, &output_options)?; + None + } + DivideManifestSelection::Dash => { + Some(write_dash_manifest(output_dir, tracks, &output_options)?) + } + DivideManifestSelection::Both => { + write_hls_playlists(output_dir, tracks, &output_options)?; + Some(write_dash_manifest(output_dir, tracks, &output_options)?) + } + }; + if let (Some(path), Some(outcome)) = ( + output_options.dash_session_save_path.as_deref(), + dash_outcome.as_ref(), + ) { + let state = build_dash_session_state(&output_options, outcome, tracks); + save_dash_session_state(path, &state)?; + } + Ok(()) +} + +fn build_dash_session_state( + output_options: &DivideOutputOptions, + outcome: &DashManifestOutcome, + tracks: &BTreeMap, +) -> DashSessionState { + DashSessionState { + next_period_id: next_dash_period_id(output_options.dash_period_id.as_deref()), + next_period_start_micros: Some( + outcome + .period_start_micros + .saturating_add(outcome.total_duration_micros), + ), + manifest_selection: Some(output_options.manifest_selection), + dash_manifest_mode: Some(output_options.dash_manifest_mode), + dash_manifest_layout: Some(output_options.dash_manifest_layout), + dash_manifest_profile: Some(output_options.dash_manifest_profile), + dash_base_urls: output_options.dash_base_urls.clone(), + dash_location: output_options.dash_location.clone(), + dash_min_buffer_time_micros: output_options.dash_min_buffer_time_micros, + dash_minimum_update_period_micros: output_options.dash_minimum_update_period_micros, + dash_suggested_presentation_delay_micros: output_options + .dash_suggested_presentation_delay_micros, + dash_time_shift_buffer_depth_micros: output_options.dash_time_shift_buffer_depth_micros, + dash_availability_start_time: output_options.dash_availability_start_time.clone(), + dash_publish_time: output_options.dash_publish_time.clone(), + dash_utc_timing_scheme: output_options.dash_utc_timing_scheme.clone(), + dash_utc_timing_value: output_options.dash_utc_timing_value.clone(), + next_segment_indices: tracks + .iter() + .map(|(track_id, track)| (*track_id, track.next_segment_index)) + .collect(), + } +} + +fn next_dash_period_id(current: Option<&str>) -> Option { + let current = current?; + let suffix_start = current + .char_indices() + .rev() + .take_while(|(_, ch)| ch.is_ascii_digit()) + .last() + .map(|(index, _)| index)?; + let (prefix, digits) = current.split_at(suffix_start); + let width = digits.len(); + let value = digits.parse::().ok()?.saturating_add(1); + Some(format!("{prefix}{value:0width$}")) +} + +fn write_hls_playlists( output_dir: &Path, tracks: &BTreeMap, + output_options: &DivideOutputOptions, ) -> Result<(), DivideError> { - let audio = tracks + let hls_master_playlist_name = effective_hls_master_playlist_name(output_options); + let hls_media_playlist_name = effective_hls_media_playlist_name(output_options); + let audio_tracks = tracks .values() - .find(|track| matches!(track.kind, TrackKind::Audio | TrackKind::EncryptedAudio)); + .filter(|track| matches!(track.kind, TrackKind::Audio | TrackKind::EncryptedAudio)) + .collect::>(); + let default_audio_track_id = + select_default_audio_track_id(&audio_tracks, output_options.default_language.as_deref()); let video = tracks .values() .find(|track| matches!(track.kind, TrackKind::Video | TrackKind::EncryptedVideo)); + let hls_program_date_time_base = output_options.hls_program_date_time.then(SystemTime::now); if let Some(video) = video { - let mut master = File::create(output_dir.join(PLAYLIST_FILE_NAME))?; + let mut master = File::create(output_dir.join(hls_master_playlist_name))?; writeln!(master, "#EXTM3U")?; - if let Some(audio) = audio { + let multiple_audio_tracks = audio_tracks.len() > 1; + for audio in &audio_tracks { + let media_playlist_uri = hls_uri( + output_options.hls_base_url.as_deref(), + &format!("{}/{}", audio.relative_dir, hls_media_playlist_name), + ); write!( master, - "#EXT-X-MEDIA:TYPE=AUDIO,URI=\"{}/{}\",GROUP-ID=\"audio\",NAME=\"audio\",AUTOSELECT=YES", - relative_dir(audio.kind), - PLAYLIST_FILE_NAME + "#EXT-X-MEDIA:TYPE=AUDIO,URI=\"{}\",GROUP-ID=\"audio\",NAME=\"{}\",AUTOSELECT=YES", + media_playlist_uri, + if multiple_audio_tracks { + format!("audio-{}", audio.track_id) + } else { + "audio".to_string() + } )?; + if multiple_audio_tracks { + write!( + master, + ",DEFAULT={}", + if Some(audio.track_id) == default_audio_track_id { + "YES" + } else { + "NO" + } + )?; + } + if let Some(language) = audio.language.as_deref() + && !language.eq_ignore_ascii_case("und") + { + write!(master, ",LANGUAGE=\"{}\"", language)?; + } if let Some(channels) = audio.audio_channels { write!(master, ",CHANNELS=\"{channels}\"")?; } @@ -614,25 +1993,30 @@ fn write_playlists( master, "#EXT-X-STREAM-INF:BANDWIDTH={},CODECS=\"{}\"", video.bandwidth, - master_playlist_codecs(video, audio) + master_playlist_codecs(video, &audio_tracks) )?; if let (Some(width), Some(height)) = (video.width, video.height) { write!(master, ",RESOLUTION={}x{}", width, height)?; } - if audio.is_some() { + if !audio_tracks.is_empty() { write!(master, ",AUDIO=\"audio\"")?; } writeln!(master)?; writeln!( master, - "{}/{}", - relative_dir(video.kind), - PLAYLIST_FILE_NAME + "{}", + hls_uri( + output_options.hls_base_url.as_deref(), + &format!("{}/{}", video.relative_dir, hls_media_playlist_name), + ) )?; } + let hls_playlist_type = output_options + .hls_playlist_type + .unwrap_or(HlsPlaylistType::Vod); for track in tracks.values() { - let mut media = File::create(track.output_dir.join(PLAYLIST_FILE_NAME))?; + let mut media = File::create(track.output_dir.join(hls_media_playlist_name))?; writeln!(media, "#EXTM3U")?; writeln!(media, "#EXT-X-VERSION:7")?; let max_duration = track @@ -644,38 +2028,424 @@ fn write_playlists( "#EXT-X-TARGETDURATION:{}", max_duration.ceil() as u64 )?; - writeln!(media, "#EXT-X-PLAYLIST-TYPE:VOD")?; - writeln!(media, "#EXT-X-MAP:URI=\"{}\"", INIT_FILE_NAME)?; + match hls_playlist_type { + HlsPlaylistType::Vod => writeln!(media, "#EXT-X-PLAYLIST-TYPE:VOD")?, + HlsPlaylistType::Event => writeln!(media, "#EXT-X-PLAYLIST-TYPE:EVENT")?, + HlsPlaylistType::Live => {} + } + if track.first_segment_index != 0 { + writeln!(media, "#EXT-X-MEDIA-SEQUENCE:{}", track.first_segment_index)?; + } + if let Some(start_time_offset_micros) = output_options.hls_start_time_offset_micros { + writeln!( + media, + "#EXT-X-START:TIME-OFFSET={}", + hls_time_offset_attr(start_time_offset_micros) + )?; + } + writeln!( + media, + "#EXT-X-MAP:URI=\"{}\"", + hls_track_media_uri( + output_options.hls_base_url.as_deref(), + &track.relative_dir, + INIT_FILE_NAME, + ) + )?; + let mut next_program_date_time = hls_program_date_time_base; for (index, duration) in track.segment_durations.iter().enumerate() { + if let Some(program_date_time) = next_program_date_time { + writeln!( + media, + "#EXT-X-PROGRAM-DATE-TIME:{}", + format_hls_program_date_time(program_date_time)? + )?; + next_program_date_time = + Some(program_date_time + Duration::from_micros(seconds_to_micros(*duration))); + } writeln!(media, "#EXTINF:{duration:.6},")?; - writeln!(media, "{}", segment_file_name(index))?; + writeln!( + media, + "{}", + hls_track_media_uri( + output_options.hls_base_url.as_deref(), + &track.relative_dir, + &segment_file_name(track.first_segment_index.saturating_add(index)) + ) + )?; + } + if hls_playlist_type == HlsPlaylistType::Vod { + writeln!(media, "#EXT-X-ENDLIST")?; + } + } + Ok(()) +} + +fn write_dash_manifest( + output_dir: &Path, + tracks: &BTreeMap, + output_options: &DivideOutputOptions, +) -> Result { + let dash_manifest_name = effective_dash_manifest_name(output_options); + let audio_tracks = tracks + .values() + .filter(|track| matches!(track.kind, TrackKind::Audio | TrackKind::EncryptedAudio)) + .collect::>(); + let preferred_audio_track_id = select_preferred_language_audio_track_id( + &audio_tracks, + output_options.default_language.as_deref(), + ); + let total_duration = tracks + .values() + .map(|track| track.segment_durations.iter().sum::()) + .fold(0.0_f64, f64::max); + let min_buffer_time = tracks + .values() + .flat_map(|track| track.segment_durations.iter().copied()) + .fold(0.0_f64, f64::max); + let total_duration_micros = seconds_to_micros(total_duration); + let min_buffer_time_micros = output_options + .dash_min_buffer_time_micros + .unwrap_or_else(|| default_dash_min_buffer_time_micros(min_buffer_time)); + let minimum_update_period_micros = output_options + .dash_minimum_update_period_micros + .unwrap_or(DEFAULT_DASH_MINIMUM_UPDATE_PERIOD_MICROS); + let suggested_presentation_delay_micros = output_options + .dash_suggested_presentation_delay_micros + .unwrap_or(0); + let period_start_micros = output_options.dash_period_start_micros.unwrap_or(0); + let profile = dash_profile_urn(output_options.dash_manifest_profile); + let auto_publish_time = format_dash_utc_timestamp(SystemTime::now())?; + let availability_start_time = output_options + .dash_availability_start_time + .as_deref() + .unwrap_or(auto_publish_time.as_str()); + let publish_time = output_options + .dash_publish_time + .as_deref() + .unwrap_or(auto_publish_time.as_str()); + + let mut manifest = File::create(output_dir.join(dash_manifest_name))?; + writeln!(manifest, "")?; + match output_options.dash_manifest_mode { + DashManifestMode::Static => { + writeln!( + manifest, + "", + profile, + dash_duration_attr_from_micros(total_duration_micros), + dash_duration_attr_from_micros(min_buffer_time_micros) + )?; + } + DashManifestMode::Dynamic => { + write!( + manifest, + " 0 { + write!( + manifest, + " suggestedPresentationDelay=\"{}\"", + dash_duration_attr_from_micros(suggested_presentation_delay_micros) + )?; + } + if let Some(time_shift_buffer_depth_micros) = + output_options.dash_time_shift_buffer_depth_micros + { + write!( + manifest, + " timeShiftBufferDepth=\"{}\"", + dash_duration_attr_from_micros(time_shift_buffer_depth_micros) + )?; + } + writeln!(manifest, ">")?; + } + } + if let Some(location) = output_options.dash_location.as_deref() { + writeln!( + manifest, + " {}", + dash_escape_attr(location) + )?; + } + for base_url in &output_options.dash_base_urls { + writeln!( + manifest, + " {}", + dash_escape_attr(base_url) + )?; + } + if let (Some(utc_timing_scheme), Some(utc_timing_value)) = ( + output_options.dash_utc_timing_scheme.as_deref(), + output_options.dash_utc_timing_value.as_deref(), + ) { + writeln!( + manifest, + " ", + dash_escape_attr(utc_timing_scheme), + dash_escape_attr(utc_timing_value) + )?; + } + match output_options.dash_manifest_mode { + DashManifestMode::Static => { + write!(manifest, " ", + dash_duration_attr_from_micros(total_duration_micros) + )?; + } + DashManifestMode::Dynamic => { + write!(manifest, " ", + dash_duration_attr_from_micros(period_start_micros) + )?; + writeln!(manifest)?; + } + } + for (track_id, track) in tracks { + match track.kind { + TrackKind::Video | TrackKind::EncryptedVideo => { + write_dash_representation( + &mut manifest, + *track_id, + track, + "video", + "video/mp4", + output_options.dash_manifest_layout, + preferred_audio_track_id, + )?; + } + TrackKind::Audio | TrackKind::EncryptedAudio => { + write_dash_representation( + &mut manifest, + *track_id, + track, + "audio", + "audio/mp4", + output_options.dash_manifest_layout, + preferred_audio_track_id, + )?; + } } - writeln!(media, "#EXT-X-ENDLIST")?; } + writeln!(manifest, " ")?; + writeln!(manifest, "")?; + Ok(DashManifestOutcome { + total_duration_micros, + period_start_micros, + }) +} + +fn write_dash_representation( + writer: &mut W, + track_id: u32, + track: &TrackOutput, + content_type: &str, + mime_type: &str, + dash_manifest_layout: DashManifestLayout, + preferred_audio_track_id: Option, +) -> Result<(), DivideError> +where + W: Write, +{ + write!( + writer, + " ")?; + if content_type == "audio" && preferred_audio_track_id == Some(track_id) { + writeln!( + writer, + " " + )?; + } + write!( + writer, + " ")?; + match dash_manifest_layout { + DashManifestLayout::Template => { + writeln!( + writer, + " ", + track.relative_dir, INIT_FILE_NAME, track.relative_dir, track.first_segment_index + )?; + writeln!(writer, " ")?; + for duration in &track.segment_durations { + writeln!( + writer, + " ", + ((*duration * 1_000_000.0).round() as u64) + )?; + } + writeln!(writer, " ")?; + writeln!(writer, " ")?; + } + DashManifestLayout::List => { + writeln!(writer, " ")?; + writeln!( + writer, + " ", + track.relative_dir, INIT_FILE_NAME + )?; + for index in 0..track.segment_durations.len() { + writeln!( + writer, + " ", + track.relative_dir, + segment_file_name(track.first_segment_index.saturating_add(index)) + )?; + } + writeln!(writer, " ")?; + } + } + writeln!(writer, " ")?; + writeln!(writer, " ")?; Ok(()) } -fn relative_dir(kind: TrackKind) -> &'static str { +fn track_relative_dir(kind: TrackKind, track_id: u32, multiple_audio_tracks: bool) -> String { match kind { - TrackKind::Video => VIDEO_DIR, - TrackKind::Audio => AUDIO_DIR, - TrackKind::EncryptedVideo => VIDEO_ENC_DIR, - TrackKind::EncryptedAudio => AUDIO_ENC_DIR, + TrackKind::Video => VIDEO_DIR.to_string(), + TrackKind::Audio if multiple_audio_tracks => format!("{AUDIO_DIR}_{track_id}"), + TrackKind::Audio => AUDIO_DIR.to_string(), + TrackKind::EncryptedVideo => VIDEO_ENC_DIR.to_string(), + TrackKind::EncryptedAudio if multiple_audio_tracks => { + format!("{AUDIO_ENC_DIR}_{track_id}") + } + TrackKind::EncryptedAudio => AUDIO_ENC_DIR.to_string(), } } +fn normalized_track_language(track: &DetailedTrackInfo) -> Option { + track + .language + .as_deref() + .filter(|language| !language.is_empty()) + .filter(|language| { + language + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-') + }) + .map(ToOwned::to_owned) +} + fn segment_file_name(index: usize) -> String { format!("{index}.mp4") } -fn master_playlist_codecs(video: &TrackOutput, audio: Option<&TrackOutput>) -> String { - match audio { - Some(audio) => format!("{},{}", video.codecs, audio.codecs), - None => video.codecs.clone(), +fn master_playlist_codecs(video: &TrackOutput, audio_tracks: &[&TrackOutput]) -> String { + if audio_tracks.is_empty() { + return video.codecs.clone(); + } + + let mut codecs = Vec::with_capacity(1 + audio_tracks.len()); + codecs.push(video.codecs.clone()); + for track in audio_tracks { + if !codecs.iter().any(|codec| codec == &track.codecs) { + codecs.push(track.codecs.clone()); + } + } + codecs.join(",") +} + +fn seconds_to_micros(seconds: f64) -> u64 { + (seconds.max(0.0) * 1_000_000.0).round() as u64 +} + +fn hls_uri(base_url: Option<&str>, relative_uri: &str) -> String { + match base_url { + Some(base_url) => format!("{base_url}{relative_uri}"), + None => relative_uri.to_string(), + } +} + +fn hls_track_media_uri(base_url: Option<&str>, relative_dir: &str, local_name: &str) -> String { + match base_url { + Some(base_url) => format!("{base_url}{relative_dir}/{local_name}"), + None => local_name.to_string(), } } +fn hls_time_offset_attr(micros: i64) -> String { + format!("{:.6}", micros as f64 / 1_000_000.0) +} + +fn dash_profile_urn(profile: DashManifestProfile) -> &'static str { + match profile { + DashManifestProfile::Main => "urn:mpeg:dash:profile:isoff-main:2011", + DashManifestProfile::Live => "urn:mpeg:dash:profile:isoff-live:2011", + } +} + +fn dash_duration_attr_from_micros(micros: u64) -> String { + dash_duration_attr(micros as f64 / 1_000_000.0) +} + +fn dash_duration_attr(seconds: f64) -> String { + let mut value = format!("{seconds:.6}"); + while value.contains('.') && value.ends_with('0') { + value.pop(); + } + if value.ends_with('.') { + value.pop(); + } + format!("PT{value}S") +} + +fn dash_escape_attr(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + ch => escaped.push(ch), + } + } + escaped +} + fn mp4a_codec_string(object_type_indication: u8, audio_object_type: u8) -> String { if object_type_indication == 0 { "mp4a".to_string() @@ -707,6 +2477,325 @@ where Ok(()) } +fn collect_divide_plan_warnings( + plans: &[ValidatedTrackPlan], + output_options: &DivideOutputOptions, +) -> Vec { + let mut warnings = Vec::new(); + if !plans + .iter() + .any(|plan| plan.validation.role == DivideTrackRole::Video) + { + warnings.push( + "divide output is audio-only; no fragmented video track was selected".to_string(), + ); + } + + let multiple_audio_tracks = plans + .iter() + .filter(|plan| plan.validation.role == DivideTrackRole::Audio) + .count() + > 1; + + for plan in plans { + let track_id = plan.validation.track_id; + if plan.segment_durations.is_empty() { + warnings.push(format!("track {track_id} has no fragmented media segments")); + } + + let zero_duration_segments = plan + .segment_durations + .iter() + .filter(|duration| **duration <= 0.0) + .count(); + if zero_duration_segments > 0 { + warnings.push(format!( + "track {track_id} has {zero_duration_segments} zero-duration fragmented segment(s)" + )); + } + + let duration_changes = count_segment_duration_changes(&plan.segment_durations); + if duration_changes > 0 { + warnings.push(format!( + "track {track_id} changes segment duration {duration_changes} time(s)" + )); + if let Some((min_duration, max_duration)) = + duration_span(plan.segment_durations.iter().copied()) + { + warnings.push(format!( + "track {track_id} fragmented segment duration spans {} to {}", + format_warning_seconds(min_duration), + format_warning_seconds(max_duration) + )); + } + } + + if let Some(next_segment_index) = output_options + .dash_session_next_segment_indices + .get(&track_id) + .copied() + .filter(|next_segment_index| *next_segment_index > 0) + { + warnings.push(format!( + "track {track_id} resumes fragmented segment numbering at {next_segment_index}" + )); + } + + if multiple_audio_tracks + && plan.validation.role == DivideTrackRole::Audio + && plan + .layout + .language + .as_deref() + .is_none_or(|language| language.eq_ignore_ascii_case("und")) + { + warnings.push(format!( + "audio track {track_id} has no normalized language code for alternate-playlist signaling" + )); + } + } + + warnings +} + +fn collect_fragmented_warning_lines( + reader: &mut R, + plans: &[ValidatedTrackPlan], + output_options: &DivideOutputOptions, +) -> Result, DivideError> +where + R: Read + Seek, +{ + let mut warnings = collect_divide_plan_warnings(plans, output_options); + reader.seek(SeekFrom::Start(0))?; + let summary = probe_detailed(reader)?; + reader.seek(SeekFrom::Start(0))?; + let track_warning_diagnostics = fragmented_track_warning_diagnostics(reader)?; + warnings.extend(collect_fragmented_probe_warnings( + &summary, + &track_warning_diagnostics, + )); + dedupe_warning_lines(&mut warnings); + Ok(warnings) +} + +fn collect_fragmented_probe_warnings( + summary: &DetailedProbeInfo, + track_warning_diagnostics: &BTreeMap, +) -> Vec { + let mut warnings = Vec::new(); + for track in &summary.tracks { + let track_id = track.summary.track_id; + let mut average_duration_changes = 0usize; + let mut previous_average = None::<(u32, u32)>; + let mut average_duration_min = None::; + let mut average_duration_max = None::; + let mut empty_segments = 0usize; + let mut zero_duration_segment_sample_count = 0u64; + let mut decode_gap_count = 0usize; + let mut largest_decode_gap = 0_u64; + let mut decode_regression_count = 0usize; + let mut largest_decode_regression = 0_u64; + let mut previous_end_decode_time = None::; + + for segment in summary + .segments + .iter() + .filter(|segment| segment.track_id == track_id) + { + if segment.sample_count == 0 { + empty_segments += 1; + } else { + if segment.duration == 0 { + zero_duration_segment_sample_count += u64::from(segment.sample_count); + } + let current_average = (segment.duration, segment.sample_count); + if let Some((previous_duration, previous_sample_count)) = previous_average + && u128::from(previous_duration) * u128::from(segment.sample_count) + != u128::from(segment.duration) * u128::from(previous_sample_count) + { + average_duration_changes += 1; + } + let average_duration_seconds = if track.summary.timescale == 0 { + 0.0 + } else { + f64::from(segment.duration) + / f64::from(segment.sample_count) + / f64::from(track.summary.timescale) + }; + average_duration_min = Some( + average_duration_min.map_or(average_duration_seconds, |value| { + value.min(average_duration_seconds) + }), + ); + average_duration_max = Some( + average_duration_max.map_or(average_duration_seconds, |value| { + value.max(average_duration_seconds) + }), + ); + previous_average = Some(current_average); + } + + if let Some(previous_end_decode_time) = previous_end_decode_time { + if segment.base_media_decode_time > previous_end_decode_time { + decode_gap_count += 1; + largest_decode_gap = largest_decode_gap.max( + segment + .base_media_decode_time + .saturating_sub(previous_end_decode_time), + ); + } else if segment.base_media_decode_time < previous_end_decode_time { + decode_regression_count += 1; + largest_decode_regression = largest_decode_regression.max( + previous_end_decode_time.saturating_sub(segment.base_media_decode_time), + ); + } + } + previous_end_decode_time = Some( + segment + .base_media_decode_time + .saturating_add(u64::from(segment.duration)), + ); + } + + if zero_duration_segment_sample_count != 0 { + warnings.push(format!( + "track {track_id} carries {zero_duration_segment_sample_count} sample(s) inside zero-duration fragmented segment(s)" + )); + } + if let Some(track_diagnostics) = track_warning_diagnostics.get(&track_id) { + if track_diagnostics.zero_duration_sample_count != 0 { + warnings.push(format!( + "track {track_id} carries {} zero-duration fragmented sample(s)", + track_diagnostics.zero_duration_sample_count + )); + } + if track_diagnostics.sample_duration_change_count != 0 { + warnings.push(format!( + "track {track_id} changes authored fragmented sample duration {} time(s)", + track_diagnostics.sample_duration_change_count + )); + if let (Some(min_duration), Some(max_duration)) = ( + track_diagnostics.min_non_zero_sample_duration, + track_diagnostics.max_non_zero_sample_duration, + ) { + warnings.push(format!( + "track {track_id} authored fragmented sample duration spans {} to {}", + format_warning_track_delta( + u64::from(min_duration), + track.summary.timescale + ), + format_warning_track_delta( + u64::from(max_duration), + track.summary.timescale + ) + )); + } + } + } + if empty_segments != 0 { + warnings.push(format!( + "track {track_id} has {empty_segments} fragmented segment(s) with no samples" + )); + } + if average_duration_changes != 0 { + warnings.push(format!( + "track {track_id} changes average fragmented sample duration {average_duration_changes} time(s)" + )); + if let (Some(min_duration), Some(max_duration)) = + (average_duration_min, average_duration_max) + { + warnings.push(format!( + "track {track_id} fragmented average sample duration spans {} to {}", + format_warning_seconds(min_duration), + format_warning_seconds(max_duration) + )); + } + } + if decode_gap_count != 0 { + warnings.push(format!( + "track {track_id} has {decode_gap_count} fragmented decode-timeline gap(s)" + )); + warnings.push(format!( + "track {track_id} has a largest fragmented decode-timeline gap of {}", + format_warning_track_delta(largest_decode_gap, track.summary.timescale) + )); + } + if decode_regression_count != 0 { + warnings.push(format!( + "track {track_id} has {decode_regression_count} fragmented decode-timeline regression(s)" + )); + warnings.push(format!( + "track {track_id} has a largest fragmented decode-timeline regression of {}", + format_warning_track_delta(largest_decode_regression, track.summary.timescale) + )); + } + } + warnings +} + +fn dedupe_warning_lines(warnings: &mut Vec) { + let mut seen = BTreeSet::new(); + warnings.retain(|warning| seen.insert(warning.clone())); +} + +fn duration_span(durations: I) -> Option<(f64, f64)> +where + I: IntoIterator, +{ + let mut values = durations.into_iter(); + let first = values.next()?; + let mut min_value = first; + let mut max_value = first; + for value in values { + min_value = min_value.min(value); + max_value = max_value.max(value); + } + (min_value < max_value).then_some((min_value, max_value)) +} + +fn format_warning_seconds(seconds: f64) -> String { + format!("{seconds:.6}s") +} + +fn format_warning_track_delta(ticks: u64, timescale: u32) -> String { + if timescale == 0 { + return format!("{ticks} tick(s)"); + } + + format!( + "{:.6}s ({} tick(s))", + ticks as f64 / f64::from(timescale), + ticks + ) +} + +#[cfg(feature = "mux")] +pub(crate) fn collect_fragmented_file_warnings( + reader: &mut R, +) -> Result, DivideError> +where + R: Read + Seek, +{ + let plans = validate_divide_track_plans(reader)?; + collect_fragmented_warning_lines(reader, &plans, &DivideOutputOptions::default()) +} + +fn count_segment_duration_changes(segment_durations: &[f64]) -> usize { + let mut previous = None::; + let mut changes = 0usize; + for duration in segment_durations { + let current = seconds_to_micros((*duration).max(0.0)); + if let Some(previous) = previous + && previous != current + { + changes += 1; + } + previous = Some(current); + } + changes +} + fn validation_role_label(role: DivideTrackRole) -> &'static str { match role { DivideTrackRole::Video => "video", @@ -759,13 +2848,126 @@ fn track_codec_label(track: &DetailedTrackInfo) -> String { } fn supported_scope_message() -> &'static str { - "divide currently supports fragmented inputs with at most one AVC video track and one MP4A audio track" + "divide currently supports fragmented inputs with at most one video track from AVC, HEVC, Dolby Vision on HEVC, AV1, VP8, or VP9 and one or more audio tracks from MP4A-based audio, Opus, AC-3, E-AC-3, AC-4, ALAC, DTS-family entries, FLAC, IAMF, MPEG-H, or PCM; subtitle and text tracks remain unsupported" +} + +fn default_dash_min_buffer_time_micros(max_segment_duration_seconds: f64) -> u64 { + seconds_to_micros(max_segment_duration_seconds).max(DEFAULT_DASH_MIN_BUFFER_TIME_MICROS) +} + +fn format_hls_program_date_time(time: SystemTime) -> Result { + let duration = time + .duration_since(UNIX_EPOCH) + .map_err(|_| invalid_input("system clock is earlier than the Unix epoch".to_string()))?; + let total_seconds = duration.as_secs(); + let milliseconds = duration.subsec_millis(); + let seconds_of_day = total_seconds % 86_400; + let days_since_epoch = total_seconds / 86_400; + let (year, month, day) = civil_from_days(days_since_epoch); + let hour = seconds_of_day / 3_600; + let minute = (seconds_of_day % 3_600) / 60; + let second = seconds_of_day % 60; + Ok(format!( + "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{milliseconds:03}Z" + )) +} + +fn format_dash_utc_timestamp(time: SystemTime) -> Result { + let duration = time + .duration_since(UNIX_EPOCH) + .map_err(|_| invalid_input("system clock is earlier than the Unix epoch".to_string()))?; + let total_seconds = duration.as_secs(); + let seconds_of_day = total_seconds % 86_400; + let days_since_epoch = total_seconds / 86_400; + let (year, month, day) = civil_from_days(days_since_epoch); + let hour = seconds_of_day / 3_600; + let minute = (seconds_of_day % 3_600) / 60; + let second = seconds_of_day % 60; + Ok(format!( + "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z" + )) +} + +fn civil_from_days(days_since_epoch: u64) -> (i32, u32, u32) { + let z = i128::from(days_since_epoch) + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = mp + if mp < 10 { 3 } else { -9 }; + let year = y + if month <= 2 { 1 } else { 0 }; + ( + i32::try_from(year).expect("civil year fits in i32"), + u32::try_from(month).expect("civil month fits in u32"), + u32::try_from(day).expect("civil day fits in u32"), + ) } fn invalid_input(message: String) -> DivideError { DivideError::Io(io::Error::new(io::ErrorKind::InvalidInput, message)) } +fn parse_divide_language_tag(value: &str) -> Result { + if value.is_empty() + || !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-') + { + return Err(invalid_input(format!( + "unsupported divide default language tag: {value}" + ))); + } + Ok(value.to_ascii_lowercase()) +} + +fn effective_hls_master_playlist_name(output_options: &DivideOutputOptions) -> &str { + output_options + .hls_master_playlist_name + .as_deref() + .unwrap_or(PLAYLIST_FILE_NAME) +} + +fn effective_hls_media_playlist_name(output_options: &DivideOutputOptions) -> &str { + output_options + .hls_media_playlist_name + .as_deref() + .unwrap_or(PLAYLIST_FILE_NAME) +} + +fn effective_dash_manifest_name(output_options: &DivideOutputOptions) -> &str { + output_options + .dash_manifest_name + .as_deref() + .unwrap_or(MANIFEST_FILE_NAME) +} + +fn select_default_audio_track_id( + audio_tracks: &[&TrackOutput], + preferred_language: Option<&str>, +) -> Option { + select_preferred_language_audio_track_id(audio_tracks, preferred_language) + .or_else(|| audio_tracks.first().map(|track| track.track_id)) +} + +fn select_preferred_language_audio_track_id( + audio_tracks: &[&TrackOutput], + preferred_language: Option<&str>, +) -> Option { + let preferred_language = preferred_language?; + audio_tracks + .iter() + .find(|track| { + track.language.as_deref().is_some_and(|language| { + !language.eq_ignore_ascii_case("und") + && language.eq_ignore_ascii_case(preferred_language) + }) + }) + .map(|track| track.track_id) +} + fn trak_track_id(reader: &mut R, trak: &BoxInfo) -> Result where R: Read + Seek, @@ -847,6 +3049,45 @@ pub enum DivideError { UsageRequested, } +impl DivideError { + /// Stable coarse category label for user-facing divide diagnostics. + pub fn category(&self) -> &'static str { + match self { + Self::Io(error) if error.kind() == io::ErrorKind::InvalidInput => "input", + Self::Io(_) => "io", + Self::Header(_) | Self::Extract(_) | Self::Probe(_) => "input", + Self::Writer(_) => "writer", + Self::MissingTrackId + | Self::UnknownTrack(_) + | Self::UnexpectedMdat + | Self::NoSupportedTracks => "layout", + Self::NumericOverflow => "internal", + Self::UsageRequested => "request", + } + } + + /// Stable coarse stage label for user-facing divide diagnostics. + pub fn stage(&self) -> &'static str { + match self { + Self::Io(error) if error.kind() == io::ErrorKind::InvalidInput => "request", + Self::Io(_) => "io", + Self::Header(_) | Self::Extract(_) | Self::Probe(_) => "inspect", + Self::Writer(_) => "write", + Self::MissingTrackId | Self::UnknownTrack(_) | Self::UnexpectedMdat => "segment", + Self::NoSupportedTracks => "request", + Self::NumericOverflow => "plan", + Self::UsageRequested => "request", + } + } + + fn diagnostic_context(&self) -> Option<(&'static str, &'static str)> { + match self { + Self::UsageRequested => None, + _ => Some((self.stage(), self.category())), + } + } +} + impl fmt::Display for DivideError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/src/cli/dump.rs b/src/cli/dump.rs index 250bb3a..31fb089 100644 --- a/src/cli/dump.rs +++ b/src/cli/dump.rs @@ -226,14 +226,6 @@ where writer, " -path Dump only matched parsed subtrees (repeatable)" )?; - writeln!( - writer, - " -mdat Deprecated shorthand for -full mdat" - )?; - writeln!( - writer, - " -free Deprecated shorthand for -full free,skip" - )?; writeln!(writer, " -offset Show box offsets")?; writeln!( writer, @@ -637,15 +629,6 @@ where options.show_all = true; index += 1; } - "-mdat" | "--mdat" => { - options.full_box_types.insert(MDAT); - index += 1; - } - "-free" | "--free" => { - options.full_box_types.insert(FREE); - options.full_box_types.insert(SKIP); - index += 1; - } "-offset" | "--offset" => { options.show_offset = true; index += 1; diff --git a/src/cli/inspect.rs b/src/cli/inspect.rs new file mode 100644 index 0000000..3fcc6fa --- /dev/null +++ b/src/cli/inspect.rs @@ -0,0 +1,256 @@ +//! Direct-ingest inspection command support. + +use std::fmt; +use std::io::{self, Write}; +use std::path::PathBuf; + +use super::{write_error_line, write_warning_lines}; +use crate::mux::MuxError; +use crate::mux::inspect::{ + DirectIngestPacketReport, DirectIngestReport, DirectIngestReportFormat, + collect_packet_report_warnings, collect_track_report_warnings, inspect_direct_ingest_packets, + inspect_direct_ingest_path, write_packet_report, write_report, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InspectView { + Tracks, + Packets, +} + +/// Runs the direct-ingest inspection subcommand with `args`, writing output to `stdout`. +pub fn run(args: &[String], stdout: &mut W, stderr: &mut E) -> i32 +where + W: Write, + E: Write, +{ + match run_inner(args, stdout, stderr) { + Ok(()) => 0, + Err(InspectCliError::UsageRequested) => { + let _ = write_usage(stderr); + 1 + } + Err(error) => { + let _ = write_error_line(stderr, &error, error.diagnostic_context()); + 1 + } + } +} + +/// Writes the direct-ingest inspection subcommand usage text. +pub fn write_usage(writer: &mut W) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "USAGE: mp4forge inspect [OPTIONS] INPUT")?; + writeln!(writer)?; + writeln!(writer, "OPTIONS:")?; + writeln!( + writer, + " -format Output format (default: json)" + )?; + writeln!( + writer, + " -view Inspection view (default: tracks)" + )?; + writeln!( + writer, + " -warnings Emit warning-grade diagnostics to stderr after a successful report" + )?; + Ok(()) +} + +/// Builds one direct-ingest inspection report for `input_path`. +pub fn build_report(input_path: &PathBuf) -> Result { + inspect_direct_ingest_path(input_path).map_err(InspectCliError::Mux) +} + +/// Builds one packet-focused direct-ingest inspection report for `input_path`. +pub fn build_packet_report( + input_path: &PathBuf, +) -> Result { + inspect_direct_ingest_packets(input_path).map_err(InspectCliError::Mux) +} + +/// Writes one direct-ingest inspection report in the requested structured format. +pub fn write_inspection_report( + writer: &mut W, + report: &DirectIngestReport, + format: DirectIngestReportFormat, +) -> io::Result<()> +where + W: Write, +{ + write_report(writer, report, format) +} + +/// Writes one packet-focused direct-ingest inspection report in the requested structured format. +pub fn write_packet_inspection_report( + writer: &mut W, + report: &DirectIngestPacketReport, + format: DirectIngestReportFormat, +) -> io::Result<()> +where + W: Write, +{ + write_packet_report(writer, report, format) +} + +#[derive(Debug)] +pub enum InspectCliError { + UsageRequested, + InvalidArgument(String), + Mux(MuxError), + Io(io::Error), +} + +impl fmt::Display for InspectCliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UsageRequested => write!(f, "usage requested"), + Self::InvalidArgument(message) => write!(f, "{message}"), + Self::Mux(error) => write!(f, "{error}"), + Self::Io(error) => write!(f, "{error}"), + } + } +} + +impl std::error::Error for InspectCliError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Mux(error) => Some(error), + Self::Io(error) => Some(error), + _ => None, + } + } +} + +impl From for InspectCliError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl InspectCliError { + fn diagnostic_context(&self) -> Option<(&'static str, &'static str)> { + match self { + Self::UsageRequested => None, + Self::InvalidArgument(..) => Some(("request", "input")), + Self::Mux(error) => Some((error.stage(), error.category())), + Self::Io(..) => Some(("io", "io")), + } + } +} + +fn run_inner(args: &[String], stdout: &mut W, stderr: &mut E) -> Result<(), InspectCliError> +where + W: Write, + E: Write, +{ + let (input_path, format, view, emit_warnings) = parse_args(args)?; + validate_view_format(view, format)?; + match view { + InspectView::Tracks => { + let report = build_report(&input_path)?; + write_inspection_report(stdout, &report, format)?; + if emit_warnings { + write_warning_lines(stderr, &collect_track_report_warnings(&report))?; + } + } + InspectView::Packets => { + let report = build_packet_report(&input_path)?; + write_packet_inspection_report(stdout, &report, format)?; + if emit_warnings { + write_warning_lines(stderr, &collect_packet_report_warnings(&report))?; + } + } + } + Ok(()) +} + +fn parse_args( + args: &[String], +) -> Result<(PathBuf, DirectIngestReportFormat, InspectView, bool), InspectCliError> { + let mut format = DirectIngestReportFormat::Json; + let mut view = InspectView::Tracks; + let mut emit_warnings = false; + let mut input_path = None::; + let mut index = 0usize; + while index < args.len() { + match args[index].as_str() { + "-h" | "--help" | "-help" => return Err(InspectCliError::UsageRequested), + "-warnings" | "--warnings" => { + emit_warnings = true; + } + "-format" | "--format" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(InspectCliError::InvalidArgument( + "missing value for `-format`".to_string(), + )); + }; + format = match value.as_str() { + "json" => DirectIngestReportFormat::Json, + "yaml" => DirectIngestReportFormat::Yaml, + "nhml" => DirectIngestReportFormat::Nhml, + "nhnt" => DirectIngestReportFormat::Nhnt, + other => { + return Err(InspectCliError::InvalidArgument(format!( + "unsupported inspect format: {other}" + ))); + } + }; + } + "-view" | "--view" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(InspectCliError::InvalidArgument( + "missing value for `-view`".to_string(), + )); + }; + view = match value.as_str() { + "tracks" => InspectView::Tracks, + "packets" => InspectView::Packets, + other => { + return Err(InspectCliError::InvalidArgument(format!( + "unsupported inspect view: {other}" + ))); + } + }; + } + value if value.starts_with('-') => { + return Err(InspectCliError::InvalidArgument(format!( + "unsupported inspect option: {value}" + ))); + } + value => { + if input_path.is_some() { + return Err(InspectCliError::InvalidArgument( + "inspect accepts exactly one input path".to_string(), + )); + } + input_path = Some(PathBuf::from(value)); + } + } + index += 1; + } + let Some(input_path) = input_path else { + return Err(InspectCliError::UsageRequested); + }; + Ok((input_path, format, view, emit_warnings)) +} + +fn validate_view_format( + view: InspectView, + format: DirectIngestReportFormat, +) -> Result<(), InspectCliError> { + match (view, format) { + (InspectView::Tracks, DirectIngestReportFormat::Nhnt) => Err( + InspectCliError::InvalidArgument("NHNT output requires `-view packets`".to_string()), + ), + (InspectView::Packets, DirectIngestReportFormat::Nhml) => Err( + InspectCliError::InvalidArgument("NHML output requires `-view tracks`".to_string()), + ), + _ => Ok(()), + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 044de62..14f60d3 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,6 +1,9 @@ //! Reusable command-line routing and formatters. +use std::fmt; use std::io::{self, Write}; +#[cfg(any(feature = "mux", test))] +use std::path::Path; #[cfg(feature = "decrypt")] pub mod decrypt; @@ -8,10 +11,53 @@ pub mod divide; pub mod dump; pub mod edit; pub mod extract; +#[cfg(feature = "mux")] +pub mod inspect; +#[cfg(feature = "mux")] +pub mod mux; pub mod probe; pub mod pssh; pub mod util; +pub(crate) fn write_error_line( + writer: &mut W, + error: &E, + diagnostics: Option<(&'static str, &'static str)>, +) -> io::Result<()> +where + W: Write, + E: fmt::Display + ?Sized, +{ + match diagnostics { + Some((stage, category)) => { + writeln!(writer, "Error [stage={stage} category={category}]: {error}") + } + None => writeln!(writer, "Error: {error}"), + } +} + +pub(crate) fn write_warning_lines(writer: &mut W, warnings: &[String]) -> io::Result<()> +where + W: Write, +{ + for warning in warnings { + writeln!(writer, "Warning: {warning}")?; + } + Ok(()) +} + +#[cfg(any(feature = "mux", test))] +pub(crate) fn format_post_run_diagnostics_unavailable( + subject: &str, + path: &Path, + error: &E, +) -> String +where + E: fmt::Display + ?Sized, +{ + format!("{subject} unavailable for {}: {error}", path.display()) +} + /// Dispatches the top-level command-line arguments to the matching command handler. pub fn dispatch(args: &[String], stdout: &mut W, stderr: &mut E) -> i32 where @@ -34,6 +80,10 @@ where "dump" => dump::run(&args[1..], stdout, stderr), "edit" => edit::run(&args[1..], stderr), "extract" => extract::run(&args[1..], stdout, stderr), + #[cfg(feature = "mux")] + "inspect" => inspect::run(&args[1..], stdout, stderr), + #[cfg(feature = "mux")] + "mux" => mux::run(&args[1..], stderr), "psshdump" => pssh::run(&args[1..], stdout, stderr), "probe" => probe::run(&args[1..], stdout, stderr), _ => { @@ -63,7 +113,40 @@ where writeln!(writer, " dump display the MP4 box tree")?; writeln!(writer, " edit rewrite selected boxes")?; writeln!(writer, " extract extract raw boxes by type or path")?; + #[cfg(feature = "mux")] + writeln!( + writer, + " inspect inspect one direct-ingest input without writing an MP4" + )?; + #[cfg(feature = "mux")] + writeln!( + writer, + " mux merge one video track plus audio tracks into one MP4" + )?; writeln!(writer, " psshdump summarize pssh boxes")?; writeln!(writer, " probe summarize an MP4 file")?; Ok(()) } + +#[cfg(test)] +mod tests { + use std::io; + use std::path::Path; + + use super::format_post_run_diagnostics_unavailable; + + #[test] + fn post_run_diagnostics_unavailable_formatter_is_stable() { + let error = io::Error::other("permission denied"); + let message = format_post_run_diagnostics_unavailable( + "fragmented output diagnostics", + Path::new("out.mp4"), + &error, + ); + + assert_eq!( + message, + "fragmented output diagnostics unavailable for out.mp4: permission denied" + ); + } +} diff --git a/src/cli/mux.rs b/src/cli/mux.rs new file mode 100644 index 0000000..f6bd459 --- /dev/null +++ b/src/cli/mux.rs @@ -0,0 +1,556 @@ +//! Mux command support. + +use std::error::Error; +use std::fmt; +use std::fs::File; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use super::{format_post_run_diagnostics_unavailable, write_error_line, write_warning_lines}; +use crate::cli::divide::collect_fragmented_file_warnings; +use crate::mux::{ + MuxDestinationMode, MuxDurationMode, MuxError, MuxOutputLayout, MuxRequest, MuxTrackSpec, + mux_fragmented_to_paths, mux_into_path, mux_to_path, +}; + +/// Runs the mux subcommand with `args`, writing failures to `stderr`. +pub fn run(args: &[String], stderr: &mut E) -> i32 +where + E: Write, +{ + match run_inner(args, stderr) { + Ok(()) => 0, + Err(MuxCliError::UsageRequested) => { + let _ = write_usage(stderr); + 1 + } + Err(error) => { + let _ = write_error_line(stderr, &error, error.diagnostic_context()); + 1 + } + } +} + +/// Writes the mux subcommand usage text. +pub fn write_usage(writer: &mut W) -> io::Result<()> +where + W: Write, +{ + writeln!( + writer, + "USAGE: mp4forge mux --track [--track ...] [--layout ] [--segment_duration | --fragment_duration ] [--out | --init_out --media_out ] [DEST]" + )?; + writeln!(writer)?; + writeln!(writer, "OPTIONS:")?; + writeln!( + writer, + " --track Add one mux input using the path-first track-spec grammar" + )?; + writeln!(writer, " Path only: PATH")?; + writeln!( + writer, + " Select one MP4 track when needed with: PATH#video, PATH#audio, PATH#audio:N, PATH#text, PATH#text:N, PATH#track:ID" + )?; + writeln!( + writer, + " Current path-only auto-detection covers MP4, VobSub, supported AVI audio streams plus H.263/JPEG/PNG/MPEG-4 Part 2/H.264/AVC1 video streams, supported MPEG-PS MPEG audio streams plus LPCM audio plus MPEG-4 Part 2/H.264/H.265/VVC video streams, supported MPEG-TS MPEG audio streams plus AAC LATM/MHAS plus AC-3/E-AC-3/AC-4/DTS/TrueHD audio plus MPEG-2/AV1/AVS3/MPEG-4 Part 2/H.264/H.265/VVC video streams, AAC ADTS, AAC LATM, MP3, AC-3, E-AC-3, AC-4, AMR, AMR-WB, QCP voice audio, DTS-family core audio, Dolby TrueHD, leading-sync MHAS MPEG-H, IAMF, H.263 elementary video, MPEG-2 elementary video, MPEG-4 Part 2 elementary video, H.264 Annex B, H.265 Annex B, VVC Annex B, raw AV1 OBU, raw AV1 Annex B, IVF AV1/VP8/VP9/VP10, JPEG still images, PNG still images, WAVE/AIFF/AIFC PCM, native FLAC, Ogg FLAC, Ogg Opus, Ogg Vorbis, Ogg Speex, Ogg Theora, and CAF ALAC" + )?; + writeln!( + writer, + " Broader DTS-family sample-entry variants remain supported through MP4 track import" + )?; + writeln!( + writer, + " --segment_duration Set one target segment duration for supported fragmented jobs" + )?; + writeln!( + writer, + " --fragment_duration Set one target fragment duration for supported fragmented jobs" + )?; + writeln!( + writer, + " --layout Choose the output container layout; defaults to flat" + )?; + writeln!( + writer, + " --out Force one newly created output destination at PATH" + )?; + writeln!( + writer, + " --init_out Write fragmented initialization boxes to PATH" + )?; + writeln!( + writer, + " --media_out Write fragmented index and media fragments to PATH" + )?; + writeln!( + writer, + " -warnings Emit warning-grade diagnostics to stderr after a successful run" + )?; + writeln!(writer)?; + writeln!( + writer, + "Flat mux jobs may carry multiple video tracks as separate tracks plus one or more audio and text/subtitle tracks. One positional DEST path follows the update-or-create destination flow: if DEST is an existing MP4, its current tracks are preserved and the requested tracks are imported into it; otherwise DEST is treated as the newly created output file. `--out PATH` is the explicit force-new path. `--init_out PATH --media_out PATH` writes a fragmented job as separate outputs. Flat output rejects duration modes. Fragmented output currently requires exactly one duration mode and supports at most one video track per mux output. Path-only MP4 inputs import all supported tracks unless you add one selector suffix." + ) +} + +#[derive(Debug)] +enum MuxCliError { + Mux(MuxError), + InvalidArgument(String), + UsageRequested, +} + +impl fmt::Display for MuxCliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Mux(error) => error.fmt(f), + Self::InvalidArgument(message) => f.write_str(message), + Self::UsageRequested => f.write_str("usage requested"), + } + } +} + +impl Error for MuxCliError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Mux(error) => Some(error), + Self::InvalidArgument(..) | Self::UsageRequested => None, + } + } +} + +impl From for MuxCliError { + fn from(value: MuxError) -> Self { + Self::Mux(value) + } +} + +impl MuxCliError { + fn diagnostic_context(&self) -> Option<(&'static str, &'static str)> { + match self { + Self::Mux(error) => Some((error.stage(), error.category())), + Self::InvalidArgument(..) => Some(("request", "input")), + Self::UsageRequested => None, + } + } +} + +struct ParsedMuxArgs { + request: MuxRequest, + target: MuxCliTarget, + emit_warnings: bool, +} + +enum MuxCliTarget { + Destination(PathBuf), + Out(PathBuf), + Split { + init_path: PathBuf, + media_path: PathBuf, + }, +} + +fn run_inner(args: &[String], stderr: &mut E) -> Result<(), MuxCliError> +where + E: Write, +{ + let parsed = parse_args(args)?; + let output_path = parsed.target.primary_output_path().to_path_buf(); + let output_layout = parsed.request.output_layout(); + let warnings_target_supported = matches!( + &parsed.target, + MuxCliTarget::Out(_) | MuxCliTarget::Destination(_) + ); + match &parsed.target { + MuxCliTarget::Destination(destination_path) => { + mux_into_path(&parsed.request, destination_path)? + } + MuxCliTarget::Out(output_path) => mux_to_path(&parsed.request, output_path)?, + MuxCliTarget::Split { + init_path, + media_path, + } => mux_fragmented_to_paths(&parsed.request, init_path, media_path)?, + } + if parsed.emit_warnings + && matches!(output_layout, MuxOutputLayout::Fragmented) + && warnings_target_supported + { + emit_fragmented_mux_warnings(&output_path, stderr); + } + Ok(()) +} + +fn parse_args(args: &[String]) -> Result { + let mut tracks = Vec::new(); + let mut output_layout = MuxOutputLayout::Flat; + let mut destination_mode = MuxDestinationMode::UpdateOrCreateDestination; + let mut duration_mode = None::; + let mut out_path = None::; + let mut init_out_path = None::; + let mut media_out_path = None::; + let mut emit_warnings = false; + let mut positional = Vec::new(); + let mut index = 0usize; + + while index < args.len() { + match args[index].as_str() { + "-h" | "--help" => return Err(MuxCliError::UsageRequested), + "-warnings" | "--warnings" => { + emit_warnings = true; + index += 1; + } + "--track" => { + let Some(value) = args.get(index + 1) else { + return Err(MuxCliError::InvalidArgument( + "missing value for --track".to_string(), + )); + }; + tracks.push(MuxTrackSpec::from_str(value).map_err(MuxCliError::from)?); + index += 2; + } + "--segment_duration" => { + let Some(value) = args.get(index + 1) else { + return Err(MuxCliError::InvalidArgument( + "missing value for --segment_duration".to_string(), + )); + }; + set_duration_mode( + &mut duration_mode, + MuxDurationMode::Segment { + seconds: parse_seconds("--segment_duration", value)?, + }, + )?; + index += 2; + } + "--fragment_duration" => { + let Some(value) = args.get(index + 1) else { + return Err(MuxCliError::InvalidArgument( + "missing value for --fragment_duration".to_string(), + )); + }; + set_duration_mode( + &mut duration_mode, + MuxDurationMode::Fragment { + seconds: parse_seconds("--fragment_duration", value)?, + }, + )?; + index += 2; + } + "--layout" => { + let Some(value) = args.get(index + 1) else { + return Err(MuxCliError::InvalidArgument( + "missing value for --layout".to_string(), + )); + }; + output_layout = parse_layout(value)?; + index += 2; + } + "--out" => { + let Some(value) = args.get(index + 1) else { + return Err(MuxCliError::InvalidArgument( + "missing value for --out".to_string(), + )); + }; + if out_path.is_some() { + return Err(MuxCliError::InvalidArgument( + "--out may only be supplied once".to_string(), + )); + } + out_path = Some(PathBuf::from(value)); + destination_mode = MuxDestinationMode::CreateNew; + index += 2; + } + "--init_out" => { + let Some(value) = args.get(index + 1) else { + return Err(MuxCliError::InvalidArgument( + "missing value for --init_out".to_string(), + )); + }; + if init_out_path.is_some() { + return Err(MuxCliError::InvalidArgument( + "--init_out may only be supplied once".to_string(), + )); + } + init_out_path = Some(PathBuf::from(value)); + destination_mode = MuxDestinationMode::CreateNew; + index += 2; + } + "--media_out" => { + let Some(value) = args.get(index + 1) else { + return Err(MuxCliError::InvalidArgument( + "missing value for --media_out".to_string(), + )); + }; + if media_out_path.is_some() { + return Err(MuxCliError::InvalidArgument( + "--media_out may only be supplied once".to_string(), + )); + } + media_out_path = Some(PathBuf::from(value)); + destination_mode = MuxDestinationMode::CreateNew; + index += 2; + } + value if value.starts_with('-') => { + return Err(MuxCliError::InvalidArgument(format!( + "unknown mux option: {value}" + ))); + } + value => { + positional.push(PathBuf::from(value)); + index += 1; + } + } + } + + if tracks.is_empty() { + return Err(MuxCliError::UsageRequested); + } + let target = match (out_path, init_out_path, media_out_path, positional.len()) { + (Some(path), None, None, 0) => MuxCliTarget::Out(path), + (Some(_), None, None, _) => { + return Err(MuxCliError::InvalidArgument( + "--out may not be used together with a positional DEST path".to_string(), + )); + } + (Some(_), _, _, 0) => { + return Err(MuxCliError::InvalidArgument( + "--out may not be used together with separate outputs".to_string(), + )); + } + (Some(_), _, _, _) => { + return Err(MuxCliError::InvalidArgument( + "--out may not be used together with separate outputs or a positional DEST path".to_string(), + )); + } + (None, Some(init_path), Some(media_path), 0) => MuxCliTarget::Split { + init_path, + media_path, + }, + (None, Some(_), None, _) => { + return Err(MuxCliError::InvalidArgument( + "--init_out requires --media_out".to_string(), + )); + } + (None, None, Some(_), _) => { + return Err(MuxCliError::InvalidArgument( + "--media_out requires --init_out".to_string(), + )); + } + (None, Some(_), Some(_), _) => { + return Err(MuxCliError::InvalidArgument( + "separate fragmented outputs may not be used with a positional DEST path" + .to_string(), + )); + } + (None, None, None, 1) => MuxCliTarget::Destination(positional.remove(0)), + (None, None, None, _) => return Err(MuxCliError::UsageRequested), + }; + + let mut request = MuxRequest::new(tracks) + .with_output_layout(output_layout) + .with_destination_mode(destination_mode); + if let Some(duration_mode) = duration_mode { + request = request.with_duration_mode(duration_mode); + } + + validate_mux_cli_request_shape(&request, &target)?; + + Ok(ParsedMuxArgs { + request, + target, + emit_warnings, + }) +} + +fn set_duration_mode( + current: &mut Option, + next: MuxDurationMode, +) -> Result<(), MuxCliError> { + if let Some(existing) = current { + return Err(MuxCliError::InvalidArgument(format!( + "--{} and --{} may not be used together", + existing.label(), + next.label() + ))); + } + *current = Some(next); + Ok(()) +} + +fn parse_seconds(option: &str, value: &str) -> Result { + value.parse::().map_err(|_| { + MuxCliError::InvalidArgument(format!( + "invalid value for {option}: expected a floating-point duration in seconds" + )) + }) +} + +fn parse_layout(value: &str) -> Result { + match value { + "flat" => Ok(MuxOutputLayout::Flat), + "fragmented" => Ok(MuxOutputLayout::Fragmented), + _ => Err(MuxCliError::InvalidArgument( + "invalid value for --layout: expected `flat` or `fragmented`".to_string(), + )), + } +} + +fn validate_mux_cli_request_shape( + request: &MuxRequest, + target: &MuxCliTarget, +) -> Result<(), MuxCliError> { + let output_path = match target { + MuxCliTarget::Destination(path) | MuxCliTarget::Out(path) => path.as_path(), + MuxCliTarget::Split { media_path, .. } => media_path.as_path(), + }; + + if matches!(target, MuxCliTarget::Split { .. }) + && !matches!(request.output_layout(), MuxOutputLayout::Fragmented) + { + return Err(MuxError::InvalidOutputLayout { + layout: request.output_layout().label(), + message: "separate fragmented output requires fragmented layout".to_string(), + } + .into()); + } + + if let MuxCliTarget::Split { + init_path, + media_path, + } = target + && absolute_cli_path(init_path)? == absolute_cli_path(media_path)? + { + return Err(MuxError::InvalidDestinationMode { + mode: MuxDestinationMode::CreateNew.label(), + message: "separate fragmented output paths must be distinct".to_string(), + } + .into()); + } + + match (request.output_layout(), request.duration_mode()) { + (MuxOutputLayout::Flat, Some(duration_mode)) => { + return Err(MuxError::InvalidOutputLayout { + layout: request.output_layout().label(), + message: format!( + "flat output does not support `--{}`; use `--layout fragmented` instead", + duration_mode.label() + ), + } + .into()); + } + (MuxOutputLayout::Fragmented, None) => { + return Err(MuxError::InvalidOutputLayout { + layout: request.output_layout().label(), + message: "fragmented output requires exactly one of `--segment_duration` or `--fragment_duration`".to_string(), + } + .into()); + } + _ => {} + } + + if matches!(target, MuxCliTarget::Destination(_)) + && matches!(request.output_layout(), MuxOutputLayout::Fragmented) + && is_existing_mp4_destination(output_path) + { + return Err(MuxError::InvalidDestinationMode { + mode: request.destination_mode().label(), + message: "the current destination-path mux mode only supports flat output; use `--out PATH` for create-new fragmented output".to_string(), + } + .into()); + } + + let video_count = request + .tracks() + .iter() + .filter(|track| { + matches!( + track, + MuxTrackSpec::Path { + selector: Some(crate::mux::MuxMp4TrackSelector::Video), + .. + } + ) + }) + .count(); + if matches!(request.output_layout(), MuxOutputLayout::Fragmented) && video_count > 1 { + return Err(MuxError::MultipleVideoTracks { count: video_count }.into()); + } + + let output_absolute = absolute_cli_path(output_path)?; + for track in request.tracks() { + let input_absolute = absolute_cli_path(track.input_path())?; + if input_absolute == output_absolute { + return Err(MuxError::OutputPathConflict { + output: output_absolute, + input: input_absolute, + } + .into()); + } + if let MuxCliTarget::Split { init_path, .. } = target { + let init_absolute = absolute_cli_path(init_path)?; + if input_absolute == init_absolute { + return Err(MuxError::OutputPathConflict { + output: init_absolute, + input: input_absolute, + } + .into()); + } + } + } + + Ok(()) +} + +fn absolute_cli_path(path: &Path) -> Result { + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + Ok(std::env::current_dir() + .map_err(MuxError::from) + .map_err(MuxCliError::from)? + .join(path)) +} + +fn is_existing_mp4_destination(path: &Path) -> bool { + let Ok(mut file) = std::fs::File::open(path) else { + return false; + }; + let mut prefix = [0_u8; 16]; + let Ok(read) = file.read(&mut prefix) else { + return false; + }; + read >= 8 && &prefix[4..8] == b"ftyp" +} + +impl MuxCliTarget { + fn primary_output_path(&self) -> &Path { + match self { + Self::Destination(path) | Self::Out(path) => path.as_path(), + Self::Split { media_path, .. } => media_path.as_path(), + } + } +} + +fn emit_fragmented_mux_warnings(output_path: &Path, stderr: &mut E) +where + E: Write, +{ + let warnings = match File::open(output_path) { + Ok(mut file) => match collect_fragmented_file_warnings(&mut file) { + Ok(warnings) => warnings, + Err(error) => vec![format_post_run_diagnostics_unavailable( + "fragmented output diagnostics", + output_path, + &error, + )], + }, + Err(error) => vec![format_post_run_diagnostics_unavailable( + "fragmented output diagnostics", + output_path, + &error, + )], + }; + let _ = write_warning_lines(stderr, &warnings); +} diff --git a/src/codec.rs b/src/codec.rs index e02331e..64840e6 100644 --- a/src/codec.rs +++ b/src/codec.rs @@ -7,17 +7,17 @@ use std::fmt; use std::future::Future; use std::io::{self, Read, Seek, SeekFrom, Write}; -#[cfg(feature = "async")] -use std::io::Cursor; #[cfg(feature = "async")] use std::pin::Pin; #[cfg(feature = "async")] -use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; use crate::FourCc; #[cfg(feature = "async")] use crate::async_io::{AsyncReadSeek, AsyncWriteSeek}; +#[cfg(feature = "async")] +use crate::bitio::{AsyncBitReader, AsyncBitWriter}; use crate::bitio::{BitReader, BitWriter}; use crate::boxes::{BoxLookupContext, BoxRegistry}; @@ -57,6 +57,15 @@ where Ok(data) } +pub(crate) fn read_exact_array_untrusted(reader: &mut R) -> io::Result<[u8; N]> +where + R: Read + ?Sized, +{ + let mut data = [0_u8; N]; + reader.read_exact(&mut data)?; + Ok(data) +} + #[cfg(feature = "async")] pub(crate) async fn read_exact_vec_untrusted_async( reader: &mut R, @@ -77,8 +86,23 @@ where Ok(data) } +#[cfg(feature = "async")] +pub(crate) async fn read_exact_array_untrusted_async( + reader: &mut R, +) -> io::Result<[u8; N]> +where + R: AsyncReadSeek + ?Sized, +{ + let mut data = [0_u8; N]; + reader.read_exact(&mut data).await?; + Ok(data) +} + /// Box-specific overrides used by the generic descriptor codec. -pub trait FieldHooks { +pub trait FieldHooks +where + Self: Sync, +{ /// Returns a dynamic bit width for `name` when the descriptor requests one. fn field_size(&self, _name: &'static str) -> Option { None @@ -770,7 +794,10 @@ pub trait FieldValueWrite { } /// Compile-time descriptor contract for a concrete box type. -pub trait CodecBox: MutableBox + FieldValueRead + FieldValueWrite { +pub trait CodecBox: MutableBox + FieldValueRead + FieldValueWrite +where + Self: Send, +{ /// Static descriptor table for the box payload. const FIELD_TABLE: FieldTable; /// Supported versions for the box type. An empty slice means any version is accepted. @@ -820,7 +847,10 @@ pub trait CodecBox: MutableBox + FieldValueRead + FieldValueWrite { } /// Object-safe view of the descriptor-backed box surface. -pub trait CodecDescription: MutableBox + FieldValueRead + FieldValueWrite { +pub trait CodecDescription: MutableBox + FieldValueRead + FieldValueWrite +where + Self: Send, +{ /// Returns the runtime field table for the box. fn field_table(&self) -> FieldTable; @@ -1255,11 +1285,25 @@ where if let Some(written) = src.custom_marshal_async(writer).await? { return Ok(written); } + if let Some(written) = try_sync_custom_marshal_fallback_async(writer, src).await? { + return Ok(written); + } + let fields = src + .field_table() + .resolve_active(src, erase_sync_hooks(hooks))?; + let mut encoder = AsyncEncoder::new(writer, src.box_type()); + for field in fields { + encoder.encode_field(src, field).await?; + } + + if !encoder.written_bits.is_multiple_of(8) { + return Err(CodecError::InvalidBoxAlignment { + box_type: src.box_type(), + bit_count: encoder.written_bits, + }); + } - let mut payload = Vec::new(); - let written = marshal_codec(&mut payload, src, erase_sync_hooks(hooks))?; - writer.write_all(&payload).await?; - Ok(written) + Ok(encoder.written_bits / 8) } #[cfg(feature = "async")] @@ -1274,11 +1318,40 @@ where if let Some(written) = src.custom_marshal_async(writer).await? { return Ok(written); } + if let Some(written) = try_sync_custom_marshal_fallback_async(writer, src).await? { + return Ok(written); + } + let fields = src.field_table().resolve_active(src, hooks)?; + let mut encoder = AsyncEncoder::new(writer, src.box_type()); + for field in fields { + encoder.encode_field(src, field).await?; + } + + if !encoder.written_bits.is_multiple_of(8) { + return Err(CodecError::InvalidBoxAlignment { + box_type: src.box_type(), + bit_count: encoder.written_bits, + }); + } + + Ok(encoder.written_bits / 8) +} - let mut payload = Vec::new(); - let written = marshal_codec(&mut payload, src, hooks)?; - writer.write_all(&payload).await?; - Ok(written) +#[cfg(feature = "async")] +async fn try_sync_custom_marshal_fallback_async( + writer: &mut W, + src: &dyn CodecDescription, +) -> Result, CodecError> +where + W: AsyncWriteSeek, +{ + let mut bytes = Vec::new(); + let Some(written) = src.custom_marshal(&mut bytes)? else { + return Ok(None); + }; + use tokio::io::AsyncWriteExt; + writer.write_all(&bytes).await?; + Ok(Some(written)) } /// Decodes a concrete box payload from `reader`. @@ -1420,17 +1493,10 @@ pub async fn unmarshal_any_with_context_async( where R: AsyncReadSeek, { - let payload = read_exact_vec_untrusted_async( - reader, - usize::try_from(payload_size).map_err(|_| io::Error::from(io::ErrorKind::OutOfMemory))?, - ) - .await?; - let mut boxed = registry .new_box_with_context(box_type, context) .ok_or(CodecError::UnknownBoxType { box_type })?; - let mut payload_reader = Cursor::new(payload); - let read = unmarshal_dyn(&mut payload_reader, payload_size, boxed.as_mut(), hooks)?; + let read = unmarshal_dyn_async(reader, payload_size, boxed.as_mut(), hooks).await?; Ok((boxed, read)) } @@ -1491,50 +1557,20 @@ where Ok(read) } else { reader.seek(SeekFrom::Start(start)).await?; - let payload = read_exact_vec_untrusted_async( - reader, - usize::try_from(payload_size) - .map_err(|_| io::Error::from(io::ErrorKind::OutOfMemory))?, - ) - .await?; - let mut payload_reader = Cursor::new(payload); - let result = if let Some(read) = dst.custom_unmarshal(&mut payload_reader, payload_size)? { - Ok(read) - } else { - let mut decoder = Decoder::new(&mut payload_reader, payload_size, dst.box_type()); - decoder - .decode_box(dst, erase_sync_hooks(hooks)) - .map(|read_bits| read_bits / 8) - }; - - let consumed = Seek::stream_position(&mut payload_reader)?; + let mut decoder = AsyncDecoder::new(reader, payload_size, dst.box_type()); + let result = decoder + .decode_box(dst, erase_sync_hooks(hooks)) + .await + .map(|read_bits| read_bits / 8); match result { - Ok(read_bytes) => { - let next = start.checked_add(consumed).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "async payload offset overflowed u64", - ) - })?; - reader.seek(SeekFrom::Start(next)).await?; - Ok(read_bytes) - } + Ok(read_bytes) => Ok(read_bytes), Err(error @ CodecError::UnsupportedVersion { .. }) => { reader.seek(SeekFrom::Start(start)).await?; dst.set_version(original_version); dst.set_flags(original_flags); Err(error) } - Err(error) => { - let next = start.checked_add(consumed).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "async payload offset overflowed u64", - ) - })?; - reader.seek(SeekFrom::Start(next)).await?; - Err(error) - } + Err(error) => Err(error), } }; @@ -1569,53 +1605,21 @@ where let result = if let Some(read) = dst.custom_unmarshal_async(reader, payload_size).await? { Ok(read) } else { - // The first async codec landing keeps the existing descriptor logic intact by decoding from - // an in-memory cursor after the async pre-decode hook has inspected the payload. reader.seek(SeekFrom::Start(start)).await?; - let payload = read_exact_vec_untrusted_async( - reader, - usize::try_from(payload_size) - .map_err(|_| io::Error::from(io::ErrorKind::OutOfMemory))?, - ) - .await?; - let mut payload_reader = Cursor::new(payload); - let result = if let Some(read) = dst.custom_unmarshal(&mut payload_reader, payload_size)? { - Ok(read) - } else { - let mut decoder = Decoder::new(&mut payload_reader, payload_size, dst.box_type()); - decoder - .decode_box(dst, hooks) - .map(|read_bits| read_bits / 8) - }; - - let consumed = Seek::stream_position(&mut payload_reader)?; + let mut decoder = AsyncDecoder::new(reader, payload_size, dst.box_type()); + let result = decoder + .decode_box(dst, hooks) + .await + .map(|read_bits| read_bits / 8); match result { - Ok(read_bytes) => { - let next = start.checked_add(consumed).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "async payload offset overflowed u64", - ) - })?; - reader.seek(SeekFrom::Start(next)).await?; - Ok(read_bytes) - } + Ok(read_bytes) => Ok(read_bytes), Err(error @ CodecError::UnsupportedVersion { .. }) => { reader.seek(SeekFrom::Start(start)).await?; dst.set_version(original_version); dst.set_flags(original_flags); Err(error) } - Err(error) => { - let next = start.checked_add(consumed).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "async payload offset overflowed u64", - ) - })?; - reader.seek(SeekFrom::Start(next)).await?; - Err(error) - } + Err(error) => Err(error), } }; @@ -1911,146 +1915,129 @@ impl Encoder { } } -struct Decoder<'a, R> { - reader: BitReader<&'a mut R>, - box_type: FourCc, - payload_size: u64, - read_bits: u64, +#[cfg(feature = "async")] +struct AsyncEncoder<'a, W> { + writer: AsyncBitWriter<&'a mut W>, + written_bits: u64, } -impl<'a, R: Read + Seek> Decoder<'a, R> { - fn new(reader: &'a mut R, payload_size: u64, box_type: FourCc) -> Self { +#[cfg(feature = "async")] +impl<'a, W: AsyncWriteSeek> AsyncEncoder<'a, W> { + fn new(writer: &'a mut W, _box_type: FourCc) -> Self { Self { - reader: BitReader::new(reader), - box_type, - payload_size, - read_bits: 0, - } - } - - fn decode_box( - &mut self, - dst: &mut dyn CodecDescription, - hooks: Option<&dyn FieldHooks>, - ) -> Result { - for descriptor in dst.field_table().ordered() { - if let Some(field) = descriptor.resolve(dst, hooks)? { - self.decode_field(dst, field, hooks)?; - } - } - - if !self.read_bits.is_multiple_of(8) { - return Err(CodecError::InvalidBoxAlignment { - box_type: self.box_type, - bit_count: self.read_bits, - }); - } - - if self.read_bits > self.payload_size.saturating_mul(8) { - return Err(CodecError::Overrun { - box_type: self.box_type, - payload_size: self.payload_size, - bit_count: self.read_bits, - }); + writer: AsyncBitWriter::new(writer), + written_bits: 0, } - - Ok(self.read_bits) } - fn decode_field( + async fn encode_field( &mut self, - dst: &mut dyn CodecDescription, + src: &dyn CodecDescription, field: ResolvedField<'_>, - hooks: Option<&dyn FieldHooks>, ) -> Result<(), CodecError> { - if let Some(constant) = field.descriptor.constant { - self.verify_constant(field, constant)?; - return Ok(()); + if let Some(value) = constant_field_value(field)? { + return Box::pin(self.encode_value(field, value)).await; } match field.descriptor.role { FieldRole::Version => { let bit_width = require_bit_width(field)?; - let version = self.read_unsigned(field.name(), bit_width)?; - let version = u8::try_from(version).map_err(|_| CodecError::NumericOverflow { - field_name: field.name(), - bit_width, - })?; - dst.set_version(version); - if !CodecDescription::is_supported_version(dst, version) { - return Err(CodecError::UnsupportedVersion { - box_type: dst.box_type(), - version, - }); - } + self.write_unsigned(field.name(), u64::from(src.version()), bit_width) + .await?; } FieldRole::Flags => { let bit_width = require_bit_width(field)?; - let flags = self.read_unsigned(field.name(), bit_width)?; - let flags = u32::try_from(flags).map_err(|_| CodecError::NumericOverflow { - field_name: field.name(), - bit_width, - })?; - dst.set_flags(flags); + self.write_unsigned(field.name(), u64::from(src.flags()), bit_width) + .await?; } FieldRole::Data => { - let value = self.read_value(field, select_hooks(dst, hooks))?; - dst.set_field_value(field.name(), value)?; + let value = src.field_value(field.name())?; + self.encode_value(field, value).await?; } } Ok(()) } - fn read_value( + async fn encode_value( &mut self, field: ResolvedField<'_>, - hooks: &dyn FieldHooks, - ) -> Result { + value: FieldValue, + ) -> Result<(), CodecError> { match field.descriptor.kind { FieldKind::Unsigned => { if field.descriptor.varint { - return Ok(FieldValue::Unsigned(self.read_uvarint(field.name())?)); + let value = expect_unsigned(field.name(), &value)?; + self.write_uvarint(field.name(), value).await?; + return Ok(()); } let width = require_bit_width(field)?; - if field_is_scalar(field) { - Ok(FieldValue::Unsigned( - self.read_unsigned(field.name(), width)?, - )) - } else { - let count = self.element_count(field, width)?; - let mut values = Vec::with_capacity(untrusted_prealloc_hint(count)); - for _ in 0..count { - values.push(self.read_unsigned(field.name(), width)?); + match value { + FieldValue::Unsigned(value) => { + self.require_scalar_length(field)?; + self.write_unsigned(field.name(), value, width).await?; + } + FieldValue::UnsignedArray(values) => { + self.require_length(field, values.len())?; + for value in values { + self.write_unsigned(field.name(), value, width).await?; + } + } + other => { + return Err(FieldValueError::UnexpectedType { + field_name: field.name(), + expected: "unsigned integer", + actual: other.kind_name(), + } + .into()); } - Ok(FieldValue::UnsignedArray(values)) } } FieldKind::Signed => { let width = require_bit_width(field)?; - if field_is_scalar(field) { - Ok(FieldValue::Signed(self.read_signed(field.name(), width)?)) - } else { - let count = self.element_count(field, width)?; - let mut values = Vec::with_capacity(untrusted_prealloc_hint(count)); - for _ in 0..count { - values.push(self.read_signed(field.name(), width)?); + match value { + FieldValue::Signed(value) => { + self.require_scalar_length(field)?; + self.write_signed(field.name(), value, width).await?; + } + FieldValue::SignedArray(values) => { + self.require_length(field, values.len())?; + for value in values { + self.write_signed(field.name(), value, width).await?; + } + } + other => { + return Err(FieldValueError::UnexpectedType { + field_name: field.name(), + expected: "signed integer", + actual: other.kind_name(), + } + .into()); } - Ok(FieldValue::SignedArray(values)) } } FieldKind::Boolean => { let width = require_bit_width(field)?; - if field_is_scalar(field) { - Ok(FieldValue::Boolean(self.read_boolean(field.name(), width)?)) - } else { - let count = self.element_count(field, width)?; - let mut values = Vec::with_capacity(untrusted_prealloc_hint(count)); - for _ in 0..count { - values.push(self.read_boolean(field.name(), width)?); + match value { + FieldValue::Boolean(value) => { + self.require_scalar_length(field)?; + self.write_boolean(field.name(), value, width).await?; + } + FieldValue::BooleanArray(values) => { + self.require_length(field, values.len())?; + for value in values { + self.write_boolean(field.name(), value, width).await?; + } + } + other => { + return Err(FieldValueError::UnexpectedType { + field_name: field.name(), + expected: "boolean", + actual: other.kind_name(), + } + .into()); } - Ok(FieldValue::BooleanArray(values)) } } FieldKind::Bytes => { @@ -2061,24 +2048,796 @@ impl<'a, R: Read + Seek> Decoder<'a, R> { bit_width: width, }); } - let count = self.element_count(field, width)?; - Ok(FieldValue::Bytes(self.read_exact_bytes(count)?)) + + let bytes = expect_bytes(field.name(), &value)?; + self.require_length(field, bytes.len())?; + self.write_bytes(bytes).await?; } FieldKind::String(mode) => { - Ok(FieldValue::String(self.read_string(field, mode, hooks)?)) + let string = expect_string(field.name(), &value)?; + self.write_string(field, string, mode).await?; } } + + Ok(()) + } + + fn require_scalar_length(&self, field: ResolvedField<'_>) -> Result<(), CodecError> { + match field.length { + ResolvedFieldLength::Unbounded | ResolvedFieldLength::Fixed(1) => Ok(()), + ResolvedFieldLength::Fixed(expected) => Err(CodecError::InvalidLength { + field_name: field.name(), + expected: expected as usize, + actual: 1, + }), + } } - fn verify_constant( - &mut self, + fn require_length( + &self, + field: ResolvedField<'_>, + actual_len: usize, + ) -> Result<(), CodecError> { + if let ResolvedFieldLength::Fixed(expected) = field.length + && actual_len != expected as usize + { + return Err(CodecError::InvalidLength { + field_name: field.name(), + expected: expected as usize, + actual: actual_len, + }); + } + + Ok(()) + } + + async fn write_unsigned( + &mut self, + field_name: &'static str, + value: u64, + bit_width: u32, + ) -> Result<(), CodecError> { + validate_unsigned_width(field_name, value, bit_width)?; + self.writer + .write_bits(&value.to_be_bytes(), bit_width as usize) + .await?; + self.written_bits += u64::from(bit_width); + Ok(()) + } + + async fn write_signed( + &mut self, + field_name: &'static str, + value: i64, + bit_width: u32, + ) -> Result<(), CodecError> { + let encoded = encode_signed(field_name, value, bit_width)?; + self.writer + .write_bits(&encoded.to_be_bytes(), bit_width as usize) + .await?; + self.written_bits += u64::from(bit_width); + Ok(()) + } + + async fn write_boolean( + &mut self, + field_name: &'static str, + value: bool, + bit_width: u32, + ) -> Result<(), CodecError> { + validate_width(field_name, bit_width)?; + let bits = if value { + if bit_width == 64 { + u64::MAX + } else { + (1_u64 << bit_width) - 1 + } + } else { + 0 + }; + + self.writer + .write_bits(&bits.to_be_bytes(), bit_width as usize) + .await?; + self.written_bits += u64::from(bit_width); + Ok(()) + } + + async fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), CodecError> { + for byte in bytes { + self.writer.write_bits(&[*byte], 8).await?; + self.written_bits += 8; + } + Ok(()) + } + + async fn write_string( + &mut self, + field: ResolvedField<'_>, + value: &str, + mode: StringFieldMode, + ) -> Result<(), CodecError> { + match (mode, field.length) { + (StringFieldMode::RawBox, ResolvedFieldLength::Fixed(expected)) => { + if value.len() != expected as usize { + return Err(CodecError::InvalidLength { + field_name: field.name(), + expected: expected as usize, + actual: value.len(), + }); + } + } + (StringFieldMode::RawBox, ResolvedFieldLength::Unbounded) => {} + (_, ResolvedFieldLength::Unbounded) => {} + (_, ResolvedFieldLength::Fixed(expected)) => { + let actual = value.len() + 1; + if actual != expected as usize { + return Err(CodecError::InvalidLength { + field_name: field.name(), + expected: expected as usize, + actual, + }); + } + } + } + + self.write_bytes(value.as_bytes()).await?; + if !matches!(mode, StringFieldMode::RawBox) { + self.write_bytes(&[0]).await?; + } + Ok(()) + } + + async fn write_uvarint( + &mut self, + field_name: &'static str, + value: u64, + ) -> Result<(), CodecError> { + if value > 0x0fff_ffff { + return Err(CodecError::VarintOverflow { field_name, value }); + } + + for shift in [21_u32, 14, 7] { + let octet = (((value >> shift) as u8) & 0x7f) | 0x80; + self.write_bytes(&[octet]).await?; + } + self.write_bytes(&[(value as u8) & 0x7f]).await?; + Ok(()) + } +} + +struct Decoder<'a, R> { + reader: BitReader<&'a mut R>, + box_type: FourCc, + payload_size: u64, + read_bits: u64, +} + +impl<'a, R: Read + Seek> Decoder<'a, R> { + fn new(reader: &'a mut R, payload_size: u64, box_type: FourCc) -> Self { + Self { + reader: BitReader::new(reader), + box_type, + payload_size, + read_bits: 0, + } + } + + fn decode_box( + &mut self, + dst: &mut dyn CodecDescription, + hooks: Option<&dyn FieldHooks>, + ) -> Result { + for descriptor in dst.field_table().ordered() { + if let Some(field) = descriptor.resolve(dst, hooks)? { + self.decode_field(dst, field, hooks)?; + } + } + + if !self.read_bits.is_multiple_of(8) { + return Err(CodecError::InvalidBoxAlignment { + box_type: self.box_type, + bit_count: self.read_bits, + }); + } + + if self.read_bits > self.payload_size.saturating_mul(8) { + return Err(CodecError::Overrun { + box_type: self.box_type, + payload_size: self.payload_size, + bit_count: self.read_bits, + }); + } + + Ok(self.read_bits) + } + + fn decode_field( + &mut self, + dst: &mut dyn CodecDescription, + field: ResolvedField<'_>, + hooks: Option<&dyn FieldHooks>, + ) -> Result<(), CodecError> { + if let Some(constant) = field.descriptor.constant { + self.verify_constant(field, constant)?; + return Ok(()); + } + + match field.descriptor.role { + FieldRole::Version => { + let bit_width = require_bit_width(field)?; + let version = self.read_unsigned(field.name(), bit_width)?; + let version = u8::try_from(version).map_err(|_| CodecError::NumericOverflow { + field_name: field.name(), + bit_width, + })?; + dst.set_version(version); + if !CodecDescription::is_supported_version(dst, version) { + return Err(CodecError::UnsupportedVersion { + box_type: dst.box_type(), + version, + }); + } + } + FieldRole::Flags => { + let bit_width = require_bit_width(field)?; + let flags = self.read_unsigned(field.name(), bit_width)?; + let flags = u32::try_from(flags).map_err(|_| CodecError::NumericOverflow { + field_name: field.name(), + bit_width, + })?; + dst.set_flags(flags); + } + FieldRole::Data => { + let value = self.read_value(field, select_hooks(dst, hooks))?; + dst.set_field_value(field.name(), value)?; + } + } + + Ok(()) + } + + fn read_value( + &mut self, + field: ResolvedField<'_>, + hooks: &dyn FieldHooks, + ) -> Result { + match field.descriptor.kind { + FieldKind::Unsigned => { + if field.descriptor.varint { + return Ok(FieldValue::Unsigned(self.read_uvarint(field.name())?)); + } + + let width = require_bit_width(field)?; + if field_is_scalar(field) { + Ok(FieldValue::Unsigned( + self.read_unsigned(field.name(), width)?, + )) + } else { + let count = self.element_count(field, width)?; + let mut values = Vec::with_capacity(untrusted_prealloc_hint(count)); + for _ in 0..count { + values.push(self.read_unsigned(field.name(), width)?); + } + Ok(FieldValue::UnsignedArray(values)) + } + } + FieldKind::Signed => { + let width = require_bit_width(field)?; + if field_is_scalar(field) { + Ok(FieldValue::Signed(self.read_signed(field.name(), width)?)) + } else { + let count = self.element_count(field, width)?; + let mut values = Vec::with_capacity(untrusted_prealloc_hint(count)); + for _ in 0..count { + values.push(self.read_signed(field.name(), width)?); + } + Ok(FieldValue::SignedArray(values)) + } + } + FieldKind::Boolean => { + let width = require_bit_width(field)?; + if field_is_scalar(field) { + Ok(FieldValue::Boolean(self.read_boolean(field.name(), width)?)) + } else { + let count = self.element_count(field, width)?; + let mut values = Vec::with_capacity(untrusted_prealloc_hint(count)); + for _ in 0..count { + values.push(self.read_boolean(field.name(), width)?); + } + Ok(FieldValue::BooleanArray(values)) + } + } + FieldKind::Bytes => { + let width = require_bit_width(field)?; + if width != 8 { + return Err(CodecError::InvalidBitWidth { + field_name: field.name(), + bit_width: width, + }); + } + let count = self.element_count(field, width)?; + Ok(FieldValue::Bytes(self.read_exact_bytes(count)?)) + } + FieldKind::String(mode) => { + Ok(FieldValue::String(self.read_string(field, mode, hooks)?)) + } + } + } + + fn verify_constant( + &mut self, + field: ResolvedField<'_>, + constant: &'static str, + ) -> Result<(), CodecError> { + match field.descriptor.kind { + FieldKind::Unsigned => { + if field.descriptor.varint { + let value = self.read_uvarint(field.name())?; + let expected = parse_unsigned_constant(field.name(), constant)?; + if value != expected { + return Err(CodecError::ConstantMismatch { + field_name: field.name(), + constant, + }); + } + } else { + let bit_width = require_bit_width(field)?; + let value = self.read_unsigned(field.name(), bit_width)?; + let expected = parse_unsigned_constant(field.name(), constant)?; + if value != expected { + return Err(CodecError::ConstantMismatch { + field_name: field.name(), + constant, + }); + } + } + } + FieldKind::Signed => { + let bit_width = require_bit_width(field)?; + let value = self.read_signed(field.name(), bit_width)?; + let expected = parse_signed_constant(field.name(), constant)?; + if value != expected { + return Err(CodecError::ConstantMismatch { + field_name: field.name(), + constant, + }); + } + } + FieldKind::Boolean => { + let bit_width = require_bit_width(field)?; + let value = self.read_boolean(field.name(), bit_width)?; + let expected = parse_unsigned_constant(field.name(), constant)? != 0; + if value != expected { + return Err(CodecError::ConstantMismatch { + field_name: field.name(), + constant, + }); + } + } + FieldKind::Bytes | FieldKind::String(_) => { + return Err(CodecError::InvalidConstant { + field_name: field.name(), + constant, + }); + } + } + + Ok(()) + } + + fn element_count(&self, field: ResolvedField<'_>, bit_width: u32) -> Result { + match field.length { + ResolvedFieldLength::Fixed(length) => Ok(length as usize), + ResolvedFieldLength::Unbounded => { + let remaining_bits = self.remaining_bits(); + if !remaining_bits.is_multiple_of(u64::from(bit_width)) { + return Err(CodecError::InvalidUnboundedLength { + field_name: field.name(), + bit_width, + remaining_bits, + }); + } + Ok((remaining_bits / u64::from(bit_width)) as usize) + } + } + } + + fn read_unsigned( + &mut self, + field_name: &'static str, + bit_width: u32, + ) -> Result { + validate_width(field_name, bit_width)?; + let data = self.reader.read_bits(bit_width as usize)?; + self.read_bits += u64::from(bit_width); + + let mut value = 0_u64; + for byte in data { + value = (value << 8) | u64::from(byte); + } + Ok(value) + } + + fn read_signed(&mut self, field_name: &'static str, bit_width: u32) -> Result { + let value = self.read_unsigned(field_name, bit_width)?; + if bit_width == 64 { + return Ok(value as i64); + } + + let sign_mask = 1_u64 << (bit_width - 1); + if value & sign_mask == 0 { + return Ok(value as i64); + } + + let extended = value | (!0_u64 << bit_width); + Ok(extended as i64) + } + + fn read_boolean( + &mut self, + field_name: &'static str, + bit_width: u32, + ) -> Result { + Ok(self.read_unsigned(field_name, bit_width)? != 0) + } + + fn read_exact_bytes(&mut self, count: usize) -> Result, CodecError> { + // Box lengths come from the bitstream, so avoid trusting them for large + // upfront allocations before we have actually read the bytes. + read_exact_vec_untrusted(&mut self.reader, count) + .inspect(|_| { + self.read_bits += (count as u64) * 8; + }) + .map_err(CodecError::Io) + } + + fn read_string( + &mut self, + field: ResolvedField<'_>, + mode: StringFieldMode, + hooks: &dyn FieldHooks, + ) -> Result { + let width = require_bit_width(field)?; + if width != 8 { + return Err(CodecError::InvalidBitWidth { + field_name: field.name(), + bit_width: width, + }); + } + + let bytes = match mode { + StringFieldMode::RawBox => { + let count = match field.length { + ResolvedFieldLength::Fixed(length) => length as usize, + ResolvedFieldLength::Unbounded => { + let remaining_bits = self.remaining_bits(); + if !remaining_bits.is_multiple_of(8) { + return Err(CodecError::InvalidUnboundedLength { + field_name: field.name(), + bit_width: 8, + remaining_bits, + }); + } + (remaining_bits / 8) as usize + } + }; + self.read_exact_bytes(count)? + } + StringFieldMode::NullTerminated => { + self.read_c_string(field.name(), string_budget(field.length), hooks)? + } + StringFieldMode::PascalCompatible => { + if let Some(string) = + self.try_read_pascal_string(field.name(), string_budget(field.length), hooks)? + { + string.into_bytes() + } else { + self.read_c_string(field.name(), string_budget(field.length), hooks)? + } + } + }; + + String::from_utf8(bytes).map_err(|_| CodecError::InvalidUtf8 { + field_name: field.name(), + }) + } + + fn read_c_string( + &mut self, + field_name: &'static str, + budget: Option, + hooks: &dyn FieldHooks, + ) -> Result, CodecError> { + let mut bytes = Vec::new(); + let mut terminated = false; + + loop { + if self.remaining_bits() == 0 { + break; + } + + if let Some(limit) = budget + && bytes.len() >= limit + { + break; + } + + let octet = self.reader.read_bits(8)?; + self.read_bits += 8; + if octet[0] == 0 { + terminated = true; + break; + } + + bytes.push(octet[0]); + } + + if budget.is_none() + && terminated + && hooks + .consume_remaining_bytes_after_string(field_name) + .unwrap_or(false) + { + // Unbounded C-style strings occupy the rest of the payload even when + // the visible text ends earlier, so consume any trailing padding. + while self.remaining_bits() >= 8 { + self.reader.read_bits(8)?; + self.read_bits += 8; + } + } + + Ok(bytes) + } + + fn try_read_pascal_string( + &mut self, + field_name: &'static str, + budget: Option, + hooks: &dyn FieldHooks, + ) -> Result, CodecError> { + let remaining_bytes = self.remaining_bits() / 8; + if remaining_bytes < 2 { + return Ok(None); + } + + if let Some(limit) = budget + && limit < 2 + { + return Ok(None); + } + + let start = self.reader.stream_position()?; + + let mut length = [0_u8; 1]; + self.reader.read_exact(&mut length)?; + let payload_len = length[0] as usize; + + if let Some(limit) = budget + && payload_len + 1 > limit + { + self.reader.seek(SeekFrom::Start(start))?; + return Ok(None); + } + + if payload_len as u64 > remaining_bytes - 1 { + self.reader.seek(SeekFrom::Start(start))?; + return Ok(None); + } + + let mut payload = vec![0_u8; payload_len]; + self.reader.read_exact(&mut payload)?; + + let remaining_after_payload = remaining_bytes - payload_len as u64 - 1; + let is_pascal = hooks + .is_pascal_string(field_name, &payload, remaining_after_payload) + .unwrap_or(false); + + if !is_pascal { + self.reader.seek(SeekFrom::Start(start))?; + return Ok(None); + } + + self.read_bits += ((payload_len + 1) * 8) as u64; + let string = + String::from_utf8(payload).map_err(|_| CodecError::InvalidUtf8 { field_name })?; + Ok(Some(string)) + } + + fn read_uvarint(&mut self, _field_name: &'static str) -> Result { + let mut value = 0_u64; + loop { + let octet = self.reader.read_bits(8)?; + self.read_bits += 8; + + value = (value << 7) | u64::from(octet[0] & 0x7f); + if octet[0] & 0x80 == 0 { + return Ok(value); + } + } + } + + fn remaining_bits(&self) -> u64 { + self.payload_size + .saturating_mul(8) + .saturating_sub(self.read_bits) + } +} + +#[cfg(feature = "async")] +struct AsyncDecoder<'a, R> { + reader: AsyncBitReader<&'a mut R>, + box_type: FourCc, + payload_size: u64, + read_bits: u64, +} + +#[cfg(feature = "async")] +impl<'a, R: AsyncReadSeek> AsyncDecoder<'a, R> { + fn new(reader: &'a mut R, payload_size: u64, box_type: FourCc) -> Self { + Self { + reader: AsyncBitReader::new(reader), + box_type, + payload_size, + read_bits: 0, + } + } + + async fn decode_box( + &mut self, + dst: &mut dyn CodecDescription, + hooks: Option<&dyn FieldHooks>, + ) -> Result { + for descriptor in dst.field_table().ordered() { + if let Some(field) = descriptor.resolve(dst, hooks)? { + self.decode_field(dst, field, hooks).await?; + } + } + + if !self.read_bits.is_multiple_of(8) { + return Err(CodecError::InvalidBoxAlignment { + box_type: self.box_type, + bit_count: self.read_bits, + }); + } + + if self.read_bits > self.payload_size.saturating_mul(8) { + return Err(CodecError::Overrun { + box_type: self.box_type, + payload_size: self.payload_size, + bit_count: self.read_bits, + }); + } + + Ok(self.read_bits) + } + + async fn decode_field( + &mut self, + dst: &mut dyn CodecDescription, + field: ResolvedField<'_>, + hooks: Option<&dyn FieldHooks>, + ) -> Result<(), CodecError> { + if let Some(constant) = field.descriptor.constant { + self.verify_constant(field, constant).await?; + return Ok(()); + } + + match field.descriptor.role { + FieldRole::Version => { + let bit_width = require_bit_width(field)?; + let version = self.read_unsigned(field.name(), bit_width).await?; + let version = u8::try_from(version).map_err(|_| CodecError::NumericOverflow { + field_name: field.name(), + bit_width, + })?; + dst.set_version(version); + if !CodecDescription::is_supported_version(dst, version) { + return Err(CodecError::UnsupportedVersion { + box_type: dst.box_type(), + version, + }); + } + } + FieldRole::Flags => { + let bit_width = require_bit_width(field)?; + let flags = self.read_unsigned(field.name(), bit_width).await?; + let flags = u32::try_from(flags).map_err(|_| CodecError::NumericOverflow { + field_name: field.name(), + bit_width, + })?; + dst.set_flags(flags); + } + FieldRole::Data => { + let value = self.read_value(field, select_hooks(dst, hooks)).await?; + dst.set_field_value(field.name(), value)?; + } + } + + Ok(()) + } + + async fn read_value( + &mut self, + field: ResolvedField<'_>, + hooks: &dyn FieldHooks, + ) -> Result { + match field.descriptor.kind { + FieldKind::Unsigned => { + if field.descriptor.varint { + return Ok(FieldValue::Unsigned(self.read_uvarint(field.name()).await?)); + } + + let width = require_bit_width(field)?; + if field_is_scalar(field) { + Ok(FieldValue::Unsigned( + self.read_unsigned(field.name(), width).await?, + )) + } else { + let count = self.element_count(field, width)?; + let mut values = Vec::with_capacity(untrusted_prealloc_hint(count)); + for _ in 0..count { + values.push(self.read_unsigned(field.name(), width).await?); + } + Ok(FieldValue::UnsignedArray(values)) + } + } + FieldKind::Signed => { + let width = require_bit_width(field)?; + if field_is_scalar(field) { + Ok(FieldValue::Signed( + self.read_signed(field.name(), width).await?, + )) + } else { + let count = self.element_count(field, width)?; + let mut values = Vec::with_capacity(untrusted_prealloc_hint(count)); + for _ in 0..count { + values.push(self.read_signed(field.name(), width).await?); + } + Ok(FieldValue::SignedArray(values)) + } + } + FieldKind::Boolean => { + let width = require_bit_width(field)?; + if field_is_scalar(field) { + Ok(FieldValue::Boolean( + self.read_boolean(field.name(), width).await?, + )) + } else { + let count = self.element_count(field, width)?; + let mut values = Vec::with_capacity(untrusted_prealloc_hint(count)); + for _ in 0..count { + values.push(self.read_boolean(field.name(), width).await?); + } + Ok(FieldValue::BooleanArray(values)) + } + } + FieldKind::Bytes => { + let width = require_bit_width(field)?; + if width != 8 { + return Err(CodecError::InvalidBitWidth { + field_name: field.name(), + bit_width: width, + }); + } + let count = self.element_count(field, width)?; + Ok(FieldValue::Bytes(self.read_exact_bytes(count).await?)) + } + FieldKind::String(mode) => Ok(FieldValue::String( + self.read_string(field, mode, hooks).await?, + )), + } + } + + async fn verify_constant( + &mut self, field: ResolvedField<'_>, constant: &'static str, ) -> Result<(), CodecError> { match field.descriptor.kind { FieldKind::Unsigned => { if field.descriptor.varint { - let value = self.read_uvarint(field.name())?; + let value = self.read_uvarint(field.name()).await?; let expected = parse_unsigned_constant(field.name(), constant)?; if value != expected { return Err(CodecError::ConstantMismatch { @@ -2088,7 +2847,7 @@ impl<'a, R: Read + Seek> Decoder<'a, R> { } } else { let bit_width = require_bit_width(field)?; - let value = self.read_unsigned(field.name(), bit_width)?; + let value = self.read_unsigned(field.name(), bit_width).await?; let expected = parse_unsigned_constant(field.name(), constant)?; if value != expected { return Err(CodecError::ConstantMismatch { @@ -2100,7 +2859,7 @@ impl<'a, R: Read + Seek> Decoder<'a, R> { } FieldKind::Signed => { let bit_width = require_bit_width(field)?; - let value = self.read_signed(field.name(), bit_width)?; + let value = self.read_signed(field.name(), bit_width).await?; let expected = parse_signed_constant(field.name(), constant)?; if value != expected { return Err(CodecError::ConstantMismatch { @@ -2111,7 +2870,7 @@ impl<'a, R: Read + Seek> Decoder<'a, R> { } FieldKind::Boolean => { let bit_width = require_bit_width(field)?; - let value = self.read_boolean(field.name(), bit_width)?; + let value = self.read_boolean(field.name(), bit_width).await?; let expected = parse_unsigned_constant(field.name(), constant)? != 0; if value != expected { return Err(CodecError::ConstantMismatch { @@ -2148,13 +2907,13 @@ impl<'a, R: Read + Seek> Decoder<'a, R> { } } - fn read_unsigned( + async fn read_unsigned( &mut self, field_name: &'static str, bit_width: u32, ) -> Result { validate_width(field_name, bit_width)?; - let data = self.reader.read_bits(bit_width as usize)?; + let data = self.reader.read_bits(bit_width as usize).await?; self.read_bits += u64::from(bit_width); let mut value = 0_u64; @@ -2164,8 +2923,12 @@ impl<'a, R: Read + Seek> Decoder<'a, R> { Ok(value) } - fn read_signed(&mut self, field_name: &'static str, bit_width: u32) -> Result { - let value = self.read_unsigned(field_name, bit_width)?; + async fn read_signed( + &mut self, + field_name: &'static str, + bit_width: u32, + ) -> Result { + let value = self.read_unsigned(field_name, bit_width).await?; if bit_width == 64 { return Ok(value as i64); } @@ -2179,25 +2942,29 @@ impl<'a, R: Read + Seek> Decoder<'a, R> { Ok(extended as i64) } - fn read_boolean( + async fn read_boolean( &mut self, field_name: &'static str, bit_width: u32, ) -> Result { - Ok(self.read_unsigned(field_name, bit_width)? != 0) - } - - fn read_exact_bytes(&mut self, count: usize) -> Result, CodecError> { - // Box lengths come from the bitstream, so avoid trusting them for large - // upfront allocations before we have actually read the bytes. - read_exact_vec_untrusted(&mut self.reader, count) - .inspect(|_| { - self.read_bits += (count as u64) * 8; - }) - .map_err(CodecError::Io) + Ok(self.read_unsigned(field_name, bit_width).await? != 0) + } + + async fn read_exact_bytes(&mut self, count: usize) -> Result, CodecError> { + let mut data = Vec::with_capacity(untrusted_prealloc_hint(count)); + let mut chunk = [0_u8; 4096]; + let mut remaining = count; + while remaining != 0 { + let to_read = remaining.min(chunk.len()); + self.reader.read_exact(&mut chunk[..to_read]).await?; + data.extend_from_slice(&chunk[..to_read]); + remaining -= to_read; + } + self.read_bits += (count as u64) * 8; + Ok(data) } - fn read_string( + async fn read_string( &mut self, field: ResolvedField<'_>, mode: StringFieldMode, @@ -2227,18 +2994,21 @@ impl<'a, R: Read + Seek> Decoder<'a, R> { (remaining_bits / 8) as usize } }; - self.read_exact_bytes(count)? + self.read_exact_bytes(count).await? } StringFieldMode::NullTerminated => { - self.read_c_string(field.name(), string_budget(field.length), hooks)? + self.read_c_string(field.name(), string_budget(field.length), hooks) + .await? } StringFieldMode::PascalCompatible => { - if let Some(string) = - self.try_read_pascal_string(field.name(), string_budget(field.length), hooks)? + if let Some(string) = self + .try_read_pascal_string(field.name(), string_budget(field.length), hooks) + .await? { string.into_bytes() } else { - self.read_c_string(field.name(), string_budget(field.length), hooks)? + self.read_c_string(field.name(), string_budget(field.length), hooks) + .await? } } }; @@ -2248,7 +3018,7 @@ impl<'a, R: Read + Seek> Decoder<'a, R> { }) } - fn read_c_string( + async fn read_c_string( &mut self, field_name: &'static str, budget: Option, @@ -2268,7 +3038,7 @@ impl<'a, R: Read + Seek> Decoder<'a, R> { break; } - let octet = self.reader.read_bits(8)?; + let octet = self.reader.read_bits(8).await?; self.read_bits += 8; if octet[0] == 0 { terminated = true; @@ -2284,10 +3054,8 @@ impl<'a, R: Read + Seek> Decoder<'a, R> { .consume_remaining_bytes_after_string(field_name) .unwrap_or(false) { - // Unbounded C-style strings occupy the rest of the payload even when - // the visible text ends earlier, so consume any trailing padding. while self.remaining_bits() >= 8 { - self.reader.read_bits(8)?; + self.reader.read_bits(8).await?; self.read_bits += 8; } } @@ -2295,7 +3063,7 @@ impl<'a, R: Read + Seek> Decoder<'a, R> { Ok(bytes) } - fn try_read_pascal_string( + async fn try_read_pascal_string( &mut self, field_name: &'static str, budget: Option, @@ -2312,26 +3080,26 @@ impl<'a, R: Read + Seek> Decoder<'a, R> { return Ok(None); } - let start = self.reader.stream_position()?; + let start = self.reader.stream_position().await?; let mut length = [0_u8; 1]; - self.reader.read_exact(&mut length)?; + self.reader.read_exact(&mut length).await?; let payload_len = length[0] as usize; if let Some(limit) = budget && payload_len + 1 > limit { - self.reader.seek(SeekFrom::Start(start))?; + self.reader.seek(SeekFrom::Start(start)).await?; return Ok(None); } if payload_len as u64 > remaining_bytes - 1 { - self.reader.seek(SeekFrom::Start(start))?; + self.reader.seek(SeekFrom::Start(start)).await?; return Ok(None); } let mut payload = vec![0_u8; payload_len]; - self.reader.read_exact(&mut payload)?; + self.reader.read_exact(&mut payload).await?; let remaining_after_payload = remaining_bytes - payload_len as u64 - 1; let is_pascal = hooks @@ -2339,7 +3107,7 @@ impl<'a, R: Read + Seek> Decoder<'a, R> { .unwrap_or(false); if !is_pascal { - self.reader.seek(SeekFrom::Start(start))?; + self.reader.seek(SeekFrom::Start(start)).await?; return Ok(None); } @@ -2349,10 +3117,10 @@ impl<'a, R: Read + Seek> Decoder<'a, R> { Ok(Some(string)) } - fn read_uvarint(&mut self, _field_name: &'static str) -> Result { + async fn read_uvarint(&mut self, _field_name: &'static str) -> Result { let mut value = 0_u64; loop { - let octet = self.reader.read_bits(8)?; + let octet = self.reader.read_bits(8).await?; self.read_bits += 8; value = (value << 7) | u64::from(octet[0] & 0x7f); diff --git a/src/decrypt.rs b/src/decrypt.rs index 11c38c1..dcef72c 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -7,21 +7,30 @@ //! decrypt entry points stay on the synchronous path, while the additive async surface later //! composes on top for file-backed decrypt workflows. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::error::Error; use std::fmt; use std::fs; -use std::io::Cursor; -use std::io::Seek; -use std::path::Path; +use std::io::{self, Cursor, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +#[cfg(feature = "async")] +use std::pin::Pin; +#[cfg(feature = "async")] +use std::task::{Context, Poll}; use aes::Aes128; use aes::cipher::{Block, BlockDecrypt, BlockEncrypt, KeyInit}; #[cfg(feature = "async")] use tokio::fs as tokio_fs; +#[cfg(feature = "async")] +use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeek, AsyncSeekExt, AsyncWriteExt, ReadBuf}; use crate::BoxInfo; use crate::FourCc; +#[cfg(feature = "async")] +use crate::async_io::{ + AsyncReadForward, AsyncReadSeek, AsyncWrite, AsyncWriteForward, AsyncWriteSeek, +}; use crate::boxes::isma_cryp::{Isfm, Islt}; use crate::boxes::iso14496_12::{ Co64, Frma, Ftyp, Mfro, Mpod, Saio, Saiz, Sbgp, Schm, Sgpd, Sidx, Stco, Stsc, Stsd, Stsz, @@ -39,12 +48,20 @@ use crate::boxes::oma_dcf::{ OHDR_ENCRYPTION_METHOD_NULL, OHDR_PADDING_SCHEME_NONE, OHDR_PADDING_SCHEME_RFC_2630, Odaf, Odda, Odhe, Ohdr, }; +use crate::codec::ReadSeek as SyncReadSeek; use crate::codec::{ImmutableBox, MutableBox, marshal, unmarshal}; use crate::encryption::{ - ResolveSampleEncryptionError, ResolvedSampleEncryptionSample, SampleEncryptionContext, - resolve_sample_encryption, + ResolveSampleEncryptionError, ResolvedSampleEncryptionSample, ResolvedSampleEncryptionSource, + SampleEncryptionContext, resolve_sample_encryption, }; use crate::extract::{ExtractError, extract_box, extract_box_as, extract_box_payload_bytes}; +#[cfg(feature = "async")] +use crate::extract::{extract_box_as_async, extract_box_async}; +use crate::queue::{ + DecryptorReuseCache, DecryptorReuseKey, OrderedWorkQueue, QueueAuxiliaryInfoSpan, + QueueRangeWorkItem, QueueWorkItem, RangeQueueParser, RangeQueueParserStage, RawOffsetQueue, + RawOffsetQueueError, +}; use crate::sidx::{ TopLevelSidxPlan, TopLevelSidxPlanAction, TopLevelSidxPlanOptions, apply_top_level_sidx_plan_bytes, plan_top_level_sidx_update_bytes, @@ -511,6 +528,26 @@ impl From for DecryptError { } } +impl DecryptError { + /// Stable coarse category label for additive decrypt diagnostics. + pub fn category(&self) -> &'static str { + match self { + Self::Io(_) => "io", + Self::Rewrite(error) => error.category(), + Self::MissingFragmentsInfo | Self::InvalidInput { .. } => "input", + } + } + + /// Stable coarse stage label for additive decrypt diagnostics. + pub fn stage(&self) -> &'static str { + match self { + Self::Io(_) => "io", + Self::Rewrite(error) => error.stage(), + Self::MissingFragmentsInfo | Self::InvalidInput { .. } => "request", + } + } +} + /// Errors raised by the native Common Encryption sample-transform core. #[derive(Clone, Debug, PartialEq, Eq)] pub enum CommonEncryptionDecryptError { @@ -605,6 +642,25 @@ impl fmt::Display for CommonEncryptionDecryptError { impl Error for CommonEncryptionDecryptError {} +impl CommonEncryptionDecryptError { + /// Stable coarse category label for additive decrypt diagnostics. + pub fn category(&self) -> &'static str { + match self { + Self::UnsupportedNativeSchemeType { .. } => "unsupported", + Self::MissingDecryptionKey { .. } => "key", + Self::MissingInitializationVector { .. } + | Self::InvalidInitializationVectorSize { .. } + | Self::InvalidProtectedRegion { .. } + | Self::ProtectedByteCountOverflow { .. } => "crypto", + } + } + + /// Stable coarse stage label for additive decrypt diagnostics. + pub fn stage(&self) -> &'static str { + "process" + } +} + /// Errors raised while rewriting decrypted MP4 output for the native Common Encryption path. #[derive(Debug)] pub enum DecryptRewriteError { @@ -668,6 +724,31 @@ impl fmt::Display for DecryptRewriteError { } } +impl DecryptRewriteError { + /// Stable coarse category label for additive decrypt diagnostics. + pub fn category(&self) -> &'static str { + match self { + Self::Extract(_) => "input", + Self::Resolve(_) => "layout", + Self::Decrypt(error) => error.category(), + Self::InvalidLayout { .. } | Self::SampleDataRangeNotFound { .. } => "layout", + Self::UnsupportedTrackSchemeType { .. } => "unsupported", + } + } + + /// Stable coarse stage label for additive decrypt diagnostics. + pub fn stage(&self) -> &'static str { + match self { + Self::Extract(_) => "inspect", + Self::Resolve(_) => "plan", + Self::Decrypt(error) => error.stage(), + Self::InvalidLayout { .. } => "rewrite", + Self::UnsupportedTrackSchemeType { .. } => "inspect", + Self::SampleDataRangeNotFound { .. } => "process", + } + } +} + impl Error for DecryptRewriteError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { @@ -739,6 +820,16 @@ pub fn decrypt_common_encryption_sample( content_key: [u8; 16], sample: &ResolvedSampleEncryptionSample<'_>, encrypted_sample: &[u8], +) -> Result, CommonEncryptionDecryptError> { + let aes = Aes128::new(&content_key.into()); + decrypt_common_encryption_sample_with_cipher(scheme, &aes, sample, encrypted_sample) +} + +fn decrypt_common_encryption_sample_with_cipher( + scheme: NativeCommonEncryptionScheme, + aes: &Aes128, + sample: &ResolvedSampleEncryptionSample<'_>, + encrypted_sample: &[u8], ) -> Result, CommonEncryptionDecryptError> { if !sample.is_protected { return Ok(encrypted_sample.to_vec()); @@ -747,7 +838,7 @@ pub fn decrypt_common_encryption_sample( let iv = effective_initialization_vector(scheme, sample)?; let mut transformer = SampleTransformer::new( scheme, - Aes128::new(&content_key.into()), + aes, iv, sample.crypt_byte_block, sample.skip_byte_block, @@ -961,9 +1052,10 @@ pub fn decrypt_file( output_path: impl AsRef, options: &DecryptOptions, ) -> Result<(), DecryptError> { - decrypt_file_with_optional_progress( + decrypt_file_with_optional_progress_and_fragments_info_path( input_path.as_ref(), output_path.as_ref(), + None, options, None::, ) @@ -980,9 +1072,10 @@ pub fn decrypt_file_with_progress( where F: FnMut(DecryptProgress), { - decrypt_file_with_optional_progress( + decrypt_file_with_optional_progress_and_fragments_info_path( input_path.as_ref(), output_path.as_ref(), + None, options, Some(progress), ) @@ -991,11 +1084,11 @@ where /// Decrypts one encrypted file path into a clear output file through the additive Tokio-based /// async library surface. /// -/// The async decrypt rollout stays file-backed for now. Pure in-memory decrypt entry points remain -/// on the synchronous path because the native transform core itself does not perform asynchronous -/// I/O. The supported file-backed layouts are the same as the synchronous path, including -/// top-level OMA DCF atom files and the currently supported protected-sample-entry OMA DCF movie -/// layout. +/// The async decrypt rollout stays file-backed for now and uses a seekable incremental reader or +/// writer path instead of whole-input file slurps. Pure in-memory decrypt entry points remain on +/// the synchronous path, while the async companions target supported seekable Tokio file handles. +/// The supported file-backed layouts are the same as the synchronous path, including top-level OMA +/// DCF atom files and the currently supported protected-sample-entry OMA DCF movie layout. #[cfg(feature = "async")] #[cfg_attr(docsrs, doc(cfg(feature = "async")))] pub async fn decrypt_file_async( @@ -1238,6 +1331,467 @@ struct ProgressReporter { callback: Option, } +struct SyncStreamDecryptPlan { + execution: SyncStreamDecryptExecution, +} + +enum SyncStreamDecryptExecution { + RootRewrite(RootRewriteStreamPlan), + CommonEncryption(CommonEncryptionStreamPlan), + Movie(MovieRewriteStreamPlan), +} + +struct RootRewriteStreamPlan { + root_boxes: Vec, + replacements: BTreeMap>, +} + +struct CommonEncryptionStreamPlan { + root_boxes: Vec, + moov_replacement: Option<(u64, Vec)>, + moof_replacements: BTreeMap>, + extra_root_replacements: BTreeMap>, + mdat_edits: BTreeMap>, +} + +type CommonEncryptionStreamRewrites = ( + BTreeMap>, + BTreeMap>, +); + +struct MovieRewriteStreamPlan { + root_boxes: Vec, + root_replacements: BTreeMap>, + clear_mdat_header: Vec, + sample_edits: Vec, +} + +#[derive(Clone)] +enum MovieSampleProcessKind { + Copy, + Marlin { + key: [u8; 16], + }, + Oma { + odaf: Odaf, + ohdr: Ohdr, + key: [u8; 16], + }, + Iaec { + isfm: Isfm, + islt: Option, + key: [u8; 16], + }, +} + +#[derive(Clone)] +struct MovieSampleEdit { + absolute_offset: u64, + sample_size: u32, + process: MovieSampleProcessKind, +} + +impl QueueWorkItem for MovieSampleEdit { + fn queue_order_key(&self) -> u64 { + self.absolute_offset + } +} + +impl QueueRangeWorkItem for MovieSampleEdit { + fn queue_range_start(&self) -> u64 { + self.absolute_offset + } + + fn queue_range_size(&self) -> u64 { + u64::from(self.sample_size) + } +} + +struct CommonEncryptionSampleEdit { + absolute_offset: u64, + sample_size: u32, + track_id: u32, + scheme_type: FourCc, + content_key: [u8; 16], + auxiliary_info_span: Option, + sample: OwnedResolvedSampleEncryptionSample, +} + +impl QueueWorkItem for CommonEncryptionSampleEdit { + fn queue_order_key(&self) -> u64 { + self.auxiliary_info_span + .map_or(self.absolute_offset, |span| { + span.absolute_offset.min(self.absolute_offset) + }) + } + + fn auxiliary_info_span(&self) -> Option { + self.auxiliary_info_span + } +} + +impl QueueRangeWorkItem for CommonEncryptionSampleEdit { + fn queue_range_start(&self) -> u64 { + self.absolute_offset + } + + fn queue_range_size(&self) -> u64 { + u64::from(self.sample_size) + } +} + +#[derive(Clone)] +struct OwnedResolvedSampleEncryptionSample { + sample_index: u32, + metadata_source: ResolvedSampleEncryptionSource, + is_protected: bool, + crypt_byte_block: u8, + skip_byte_block: u8, + per_sample_iv_size: Option, + initialization_vector: Vec, + constant_iv: Option>, + kid: [u8; 16], + subsamples: Vec, + auxiliary_info_size: u32, +} + +impl OwnedResolvedSampleEncryptionSample { + fn from_resolved(sample: &ResolvedSampleEncryptionSample<'_>) -> Self { + Self { + sample_index: sample.sample_index, + metadata_source: sample.metadata_source, + is_protected: sample.is_protected, + crypt_byte_block: sample.crypt_byte_block, + skip_byte_block: sample.skip_byte_block, + per_sample_iv_size: sample.per_sample_iv_size, + initialization_vector: sample.initialization_vector.to_vec(), + constant_iv: sample.constant_iv.map(<[u8]>::to_vec), + kid: sample.kid, + subsamples: sample.subsamples.to_vec(), + auxiliary_info_size: sample.auxiliary_info_size, + } + } + + fn as_borrowed(&self) -> ResolvedSampleEncryptionSample<'_> { + ResolvedSampleEncryptionSample { + sample_index: self.sample_index, + metadata_source: self.metadata_source, + is_protected: self.is_protected, + crypt_byte_block: self.crypt_byte_block, + skip_byte_block: self.skip_byte_block, + per_sample_iv_size: self.per_sample_iv_size, + initialization_vector: &self.initialization_vector, + constant_iv: self.constant_iv.as_deref(), + kid: self.kid, + subsamples: &self.subsamples, + auxiliary_info_size: self.auxiliary_info_size, + } + } +} + +struct ActiveAuxiliaryInfoCache<'a> { + staged_samples: + BTreeMap>, +} + +impl<'a> ActiveAuxiliaryInfoCache<'a> { + fn stage( + sample_edits: Option<&'a OrderedWorkQueue>, + staged_spans: &'a [QueueAuxiliaryInfoSpan], + ) -> Result { + let mut staged_samples = staged_spans + .iter() + .copied() + .map(|span| (span, VecDeque::new())) + .collect::>(); + let expected_spans = staged_spans.iter().copied().collect::>(); + + if let Some(sample_edits) = sample_edits { + let mut edits = sample_edits.items().iter().collect::>(); + edits.sort_by_key(|edit| edit.absolute_offset); + for edit in edits { + let Some(span) = edit.auxiliary_info_span else { + continue; + }; + let Some(samples) = staged_samples.get_mut(&span) else { + return Err(DecryptRewriteError::InvalidLayout { + reason: format!( + "queued auxiliary info span at offset {} with size {} was not staged before decrypt execution", + span.absolute_offset, span.size + ), + }); + }; + samples.push_back(&edit.sample); + } + } else if !staged_spans.is_empty() { + return Err(DecryptRewriteError::InvalidLayout { + reason: "queued auxiliary info stage had no backing Common Encryption sample edits" + .to_owned(), + }); + } + + for span in &expected_spans { + if staged_samples.get(span).is_none_or(VecDeque::is_empty) { + return Err(DecryptRewriteError::InvalidLayout { + reason: format!( + "queued auxiliary info span at offset {} with size {} did not cover any Common Encryption samples", + span.absolute_offset, span.size + ), + }); + } + } + + Ok(Self { staged_samples }) + } + + fn resolved_sample_for_edit( + &mut self, + edit: &'a CommonEncryptionSampleEdit, + ) -> Result, DecryptRewriteError> { + let Some(span) = edit.auxiliary_info_span else { + return Ok(edit.sample.as_borrowed()); + }; + let Some(samples) = self.staged_samples.get_mut(&span) else { + return Err(DecryptRewriteError::InvalidLayout { + reason: format!( + "missing staged auxiliary info span at offset {} with size {} for track {} sample {}", + span.absolute_offset, span.size, edit.track_id, edit.sample.sample_index + ), + }); + }; + let Some(staged_sample) = samples.pop_front() else { + return Err(DecryptRewriteError::InvalidLayout { + reason: format!( + "staged auxiliary info span at offset {} with size {} ran out of parsed sample state before track {} sample {}", + span.absolute_offset, span.size, edit.track_id, edit.sample.sample_index + ), + }); + }; + if staged_sample.sample_index != edit.sample.sample_index { + return Err(DecryptRewriteError::InvalidLayout { + reason: format!( + "staged auxiliary info sample order drifted: expected track {} sample {} but cache produced sample {}", + edit.track_id, edit.sample.sample_index, staged_sample.sample_index + ), + }); + } + Ok(staged_sample.as_borrowed()) + } + + fn finish(self) -> Result<(), DecryptRewriteError> { + for (span, remaining_samples) in self.staged_samples { + if !remaining_samples.is_empty() { + return Err(DecryptRewriteError::InvalidLayout { + reason: format!( + "staged auxiliary info span at offset {} with size {} still had {} queued sample state entr{} after decrypt execution", + span.absolute_offset, + span.size, + remaining_samples.len(), + if remaining_samples.len() == 1 { + "y" + } else { + "ies" + } + ), + }); + } + } + Ok(()) + } +} + +fn queue_common_encryption_mdat_edits( + mdat_edits: BTreeMap>, +) -> BTreeMap> { + mdat_edits + .into_iter() + .map(|(mdat_offset, edits)| (mdat_offset, OrderedWorkQueue::new(edits))) + .collect() +} + +fn compute_fragment_auxiliary_info_spans( + moof_offset: u64, + saio: Option<&Saio>, + truns: &[Trun], + resolved_samples: &[ResolvedSampleEncryptionSample<'_>], +) -> Result>, DecryptRewriteError> { + let Some(saio) = saio else { + return Ok(vec![None; truns.len()]); + }; + if saio.entry_count == 0 { + return Ok(vec![None; truns.len()]); + } + + let mut spans = Vec::with_capacity(truns.len()); + let mut sample_cursor = 0usize; + let mut next_chained_offset = None::; + let saio_entry_count = usize::try_from(saio.entry_count).unwrap_or(usize::MAX); + + for (run_index, trun) in truns.iter().enumerate() { + let run_sample_count = + usize::try_from(trun.sample_count).map_err(|_| DecryptRewriteError::InvalidLayout { + reason: "fragment run sample count does not fit in usize".to_owned(), + })?; + let next_sample_cursor = sample_cursor.checked_add(run_sample_count).ok_or_else(|| { + DecryptRewriteError::InvalidLayout { + reason: "fragment run sample count overflowed usize".to_owned(), + } + })?; + let run_samples = resolved_samples + .get(sample_cursor..next_sample_cursor) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "resolved sample metadata does not cover the fragment run layout" + .to_owned(), + })?; + let run_auxiliary_info_size = run_samples.iter().try_fold(0_u64, |acc, sample| { + acc.checked_add(u64::from(sample.auxiliary_info_size)) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "fragment auxiliary info size overflowed u64".to_owned(), + }) + })?; + + let span = if run_auxiliary_info_size == 0 { + None + } else { + let start_offset = if run_index < saio_entry_count { + let saio_offset = match saio.version() { + 0 => saio.offset_v0.get(run_index).copied(), + 1 => saio.offset_v1.get(run_index).copied(), + _ => None, + } + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: + "fragment auxiliary info offsets do not cover the declared saio entry count" + .to_owned(), + })?; + moof_offset.checked_add(saio_offset).ok_or_else(|| { + DecryptRewriteError::InvalidLayout { + reason: "fragment auxiliary info offset overflowed u64".to_owned(), + } + })? + } else if saio_entry_count == 1 { + next_chained_offset.ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "single-entry saio did not produce a chained auxiliary info offset" + .to_owned(), + })? + } else { + 0 + }; + + if run_index >= saio_entry_count && saio_entry_count != 1 { + None + } else { + let span = QueueAuxiliaryInfoSpan { + absolute_offset: start_offset, + size: run_auxiliary_info_size, + }; + next_chained_offset = Some( + start_offset + .checked_add(run_auxiliary_info_size) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "fragment auxiliary info span overflowed u64".to_owned(), + })?, + ); + Some(span) + } + }; + + spans.push(span); + sample_cursor = next_sample_cursor; + } + + if sample_cursor != resolved_samples.len() { + return Err(DecryptRewriteError::InvalidLayout { + reason: "fragment runs did not account for every resolved sample metadata record" + .to_owned(), + }); + } + + Ok(spans) +} + +struct CommonEncryptionFragmentQueueContext<'a> { + active: &'a ActiveTrackDecryption<'a>, + original_moof_offset: u64, + tfhd: &'a Tfhd, + truns: &'a [Trun], + trun_infos: &'a [BoxInfo], + mdat_infos: &'a [BoxInfo], + saio: Option<&'a Saio>, + resolved_samples: &'a [ResolvedSampleEncryptionSample<'a>], +} + +fn append_common_encryption_sample_edits( + mdat_edits: &mut BTreeMap>, + context: CommonEncryptionFragmentQueueContext<'_>, +) -> Result<(), DecryptRewriteError> { + let sample_spans = compute_sample_spans( + context.tfhd, + context.active.track.trex.as_ref(), + context.original_moof_offset, + context.truns, + context.trun_infos, + )?; + if sample_spans.len() != context.resolved_samples.len() { + return Err(DecryptRewriteError::InvalidLayout { + reason: format!( + "track {} resolved {} encrypted sample records but {} sample span(s) in the stream-first Common Encryption path", + context.active.track.track_id, + context.resolved_samples.len(), + sample_spans.len() + ), + }); + } + + let auxiliary_info_spans = compute_fragment_auxiliary_info_spans( + context.original_moof_offset, + context.saio, + context.truns, + context.resolved_samples, + )?; + let mut sample_cursor = 0usize; + for (trun, auxiliary_info_span) in context.truns.iter().zip(auxiliary_info_spans) { + let run_sample_count = + usize::try_from(trun.sample_count).map_err(|_| DecryptRewriteError::InvalidLayout { + reason: "fragment run sample count does not fit in usize".to_owned(), + })?; + let next_sample_cursor = sample_cursor.checked_add(run_sample_count).ok_or_else(|| { + DecryptRewriteError::InvalidLayout { + reason: "fragment run sample count overflowed usize".to_owned(), + } + })?; + let run_samples = &context.resolved_samples[sample_cursor..next_sample_cursor]; + let run_spans = &sample_spans[sample_cursor..next_sample_cursor]; + + for (sample, span) in run_samples.iter().zip(run_spans.iter()) { + let mdat_info = + find_mdat_info_containing_sample(context.mdat_infos, span.offset, span.size) + .ok_or(DecryptRewriteError::SampleDataRangeNotFound { + track_id: context.active.track.track_id, + sample_index: sample.sample_index, + absolute_offset: span.offset, + sample_size: span.size, + })?; + mdat_edits + .entry(mdat_info.offset()) + .or_default() + .push(CommonEncryptionSampleEdit { + absolute_offset: span.offset, + sample_size: span.size, + track_id: context.active.track.track_id, + scheme_type: context.active.sample_entry.scheme_type, + content_key: context.active.key, + auxiliary_info_span, + sample: OwnedResolvedSampleEncryptionSample::from_resolved(sample), + }); + } + + sample_cursor = next_sample_cursor; + } + + Ok(()) +} + impl ProgressReporter where F: FnMut(DecryptProgress), @@ -1253,75 +1807,2409 @@ where } } -fn decrypt_bytes_with_optional_progress( - input: &[u8], +fn decrypt_sync_stream_with_optional_progress( + input: &mut R, + output: &mut W, + fragments_info_reader: Option<&mut dyn SyncReadSeek>, options: &DecryptOptions, - progress: Option, -) -> Result, DecryptError> + reporter: &mut ProgressReporter, +) -> Result<(), DecryptError> where + R: Read + Seek, + W: Write + Seek, F: FnMut(DecryptProgress), { - let mut reporter = ProgressReporter::new(progress); - let output = decrypt_input_bytes(input, options, &mut reporter)?; - reporter.report(DecryptProgressPhase::FinalizeOutput, 0, Some(1)); - reporter.report(DecryptProgressPhase::FinalizeOutput, 1, Some(1)); - Ok(output) + reporter.report(DecryptProgressPhase::InspectStructure, 0, Some(1)); + let plan = plan_sync_stream_decrypt(input, fragments_info_reader, options, reporter)?; + reporter.report(DecryptProgressPhase::InspectStructure, 1, Some(1)); + reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); + execute_sync_stream_decrypt_plan(input, output, &plan)?; + reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); + Ok(()) } -fn decrypt_file_with_optional_progress( - input_path: &Path, - output_path: &Path, +#[cfg(feature = "async")] +async fn decrypt_async_stream_with_optional_progress( + input: &mut R, + output: &mut W, + fragments_info_reader: Option<&mut dyn AsyncReadSeek>, options: &DecryptOptions, - progress: Option, + reporter: &mut ProgressReporter, ) -> Result<(), DecryptError> where + R: AsyncReadSeek, + W: AsyncWriteSeek, F: FnMut(DecryptProgress), { - let mut reporter = ProgressReporter::new(progress); - reporter.report(DecryptProgressPhase::OpenInput, 0, Some(1)); - let input = fs::read(input_path)?; - reporter.report(DecryptProgressPhase::OpenInput, 1, Some(1)); - - let output = decrypt_input_bytes(&input, options, &mut reporter)?; - - reporter.report(DecryptProgressPhase::OpenOutput, 0, Some(1)); - fs::write(output_path, output)?; - reporter.report(DecryptProgressPhase::OpenOutput, 1, Some(1)); - reporter.report(DecryptProgressPhase::FinalizeOutput, 0, Some(1)); - reporter.report(DecryptProgressPhase::FinalizeOutput, 1, Some(1)); + reporter.report(DecryptProgressPhase::InspectStructure, 0, Some(1)); + let plan = plan_async_stream_decrypt(input, fragments_info_reader, options, reporter).await?; + reporter.report(DecryptProgressPhase::InspectStructure, 1, Some(1)); + reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); + execute_async_stream_decrypt_plan(input, output, &plan).await?; + reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); Ok(()) } -#[cfg(feature = "async")] -async fn decrypt_file_with_optional_progress_async( - input_path: &Path, - output_path: &Path, +fn plan_sync_stream_decrypt( + input: &mut R, + mut fragments_info_reader: Option<&mut dyn SyncReadSeek>, options: &DecryptOptions, - progress: Option, -) -> Result<(), DecryptError> + reporter: &mut ProgressReporter, +) -> Result where - F: FnMut(DecryptProgress) + Send, + R: Read + Seek, + F: FnMut(DecryptProgress), { - let mut reporter = ProgressReporter::new(progress); - reporter.report(DecryptProgressPhase::OpenInput, 0, Some(1)); - let input = tokio_fs::read(input_path).await?; - reporter.report(DecryptProgressPhase::OpenInput, 1, Some(1)); + let root_boxes = read_root_box_infos_from_reader(input)?; + let layout = classify_decrypt_input_from_reader(input, &root_boxes)?; + + let execution = match layout { + DecryptInputLayout::InitSegment => SyncStreamDecryptExecution::RootRewrite( + build_common_encryption_init_stream_plan(input, &root_boxes, options.keys())?, + ), + DecryptInputLayout::FragmentedFile | DecryptInputLayout::MediaSegment => { + let fragments_info_bytes = if layout == DecryptInputLayout::MediaSegment { + reporter.report(DecryptProgressPhase::OpenFragmentsInfo, 0, Some(1)); + let fragments_info_bytes = resolve_stream_fragments_info_init_bytes( + fragments_info_reader.take(), + options, + )?; + reporter.report(DecryptProgressPhase::OpenFragmentsInfo, 1, Some(1)); + Some(fragments_info_bytes) + } else { + None + }; - let output = decrypt_input_bytes(&input, options, &mut reporter)?; + SyncStreamDecryptExecution::CommonEncryption(build_common_encryption_stream_plan( + input, + &root_boxes, + layout, + options.keys(), + fragments_info_bytes.as_deref(), + )?) + } + DecryptInputLayout::MarlinIpmpFile => SyncStreamDecryptExecution::Movie( + build_marlin_movie_stream_plan(input, &root_boxes, options.keys())?, + ), + DecryptInputLayout::OmaDcfProtectedMovieFile => SyncStreamDecryptExecution::Movie( + build_oma_dcf_movie_stream_plan(input, &root_boxes, options.keys())?, + ), + DecryptInputLayout::IaecProtectedMovieFile => SyncStreamDecryptExecution::Movie( + build_iaec_movie_stream_plan(input, &root_boxes, options.keys())?, + ), + DecryptInputLayout::OmaDcfAtomFile => SyncStreamDecryptExecution::RootRewrite( + build_oma_dcf_atom_stream_plan(input, &root_boxes, options.keys())?, + ), + }; - reporter.report(DecryptProgressPhase::OpenOutput, 0, Some(1)); - tokio_fs::write(output_path, output).await?; - reporter.report(DecryptProgressPhase::OpenOutput, 1, Some(1)); - reporter.report(DecryptProgressPhase::FinalizeOutput, 0, Some(1)); - reporter.report(DecryptProgressPhase::FinalizeOutput, 1, Some(1)); - Ok(()) + Ok(SyncStreamDecryptPlan { execution }) } -fn decrypt_input_bytes( - input: &[u8], +#[cfg(feature = "async")] +async fn plan_async_stream_decrypt( + input: &mut R, + mut fragments_info_reader: Option<&mut dyn AsyncReadSeek>, options: &DecryptOptions, reporter: &mut ProgressReporter, -) -> Result, DecryptError> +) -> Result +where + R: AsyncReadSeek, + F: FnMut(DecryptProgress), +{ + let root_boxes = read_root_box_infos_from_async_reader(input).await?; + let layout = classify_decrypt_input_from_async_reader(input, &root_boxes).await?; + + let execution = match layout { + DecryptInputLayout::InitSegment => SyncStreamDecryptExecution::RootRewrite( + build_common_encryption_init_stream_plan_async(input, &root_boxes, options.keys()) + .await?, + ), + DecryptInputLayout::FragmentedFile | DecryptInputLayout::MediaSegment => { + let fragments_info_bytes = if layout == DecryptInputLayout::MediaSegment { + reporter.report(DecryptProgressPhase::OpenFragmentsInfo, 0, Some(1)); + let fragments_info_bytes = resolve_async_stream_fragments_info_init_bytes( + fragments_info_reader.take(), + options, + ) + .await?; + reporter.report(DecryptProgressPhase::OpenFragmentsInfo, 1, Some(1)); + Some(fragments_info_bytes) + } else { + None + }; + + SyncStreamDecryptExecution::CommonEncryption( + build_common_encryption_stream_plan_async( + input, + &root_boxes, + layout, + options.keys(), + fragments_info_bytes.as_deref(), + ) + .await?, + ) + } + DecryptInputLayout::MarlinIpmpFile => SyncStreamDecryptExecution::Movie( + build_marlin_movie_stream_plan_async(input, &root_boxes, options.keys()).await?, + ), + DecryptInputLayout::OmaDcfProtectedMovieFile => SyncStreamDecryptExecution::Movie( + build_oma_dcf_movie_stream_plan_async(input, &root_boxes, options.keys()).await?, + ), + DecryptInputLayout::IaecProtectedMovieFile => SyncStreamDecryptExecution::Movie( + build_iaec_movie_stream_plan_async(input, &root_boxes, options.keys()).await?, + ), + DecryptInputLayout::OmaDcfAtomFile => SyncStreamDecryptExecution::RootRewrite( + build_oma_dcf_atom_stream_plan_async(input, &root_boxes, options.keys()).await?, + ), + }; + + Ok(SyncStreamDecryptPlan { execution }) +} + +struct SyncReadSeekAdapter<'a> { + inner: &'a mut dyn SyncReadSeek, +} + +impl Read for SyncReadSeekAdapter<'_> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + std::io::Read::read(&mut self.inner, buf) + } +} + +impl Seek for SyncReadSeekAdapter<'_> { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + std::io::Seek::seek(&mut self.inner, pos) + } +} + +#[cfg(feature = "async")] +struct AsyncReadSeekAdapter<'a> { + inner: &'a mut dyn AsyncReadSeek, +} + +#[cfg(feature = "async")] +impl AsyncRead for AsyncReadSeekAdapter<'_> { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut *self.inner).poll_read(cx, buf) + } +} + +#[cfg(feature = "async")] +impl AsyncSeek for AsyncReadSeekAdapter<'_> { + fn start_seek(mut self: Pin<&mut Self>, position: SeekFrom) -> std::io::Result<()> { + Pin::new(&mut *self.inner).start_seek(position) + } + + fn poll_complete(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut *self.inner).poll_complete(cx) + } +} + +fn execute_sync_stream_decrypt_plan( + input: &mut R, + output: &mut W, + plan: &SyncStreamDecryptPlan, +) -> Result<(), DecryptError> +where + R: Read + Seek, + W: Write + Seek, +{ + match &plan.execution { + SyncStreamDecryptExecution::RootRewrite(plan) => { + execute_root_rewrite_stream_plan(input, output, plan) + } + SyncStreamDecryptExecution::CommonEncryption(plan) => { + execute_common_encryption_stream_plan(input, output, plan) + } + SyncStreamDecryptExecution::Movie(plan) => execute_movie_stream_plan(input, output, plan), + } +} + +#[cfg(feature = "async")] +async fn execute_async_stream_decrypt_plan( + input: &mut R, + output: &mut W, + plan: &SyncStreamDecryptPlan, +) -> Result<(), DecryptError> +where + R: AsyncReadSeek, + W: AsyncWriteSeek, +{ + match &plan.execution { + SyncStreamDecryptExecution::RootRewrite(plan) => { + execute_root_rewrite_stream_plan_async(input, output, plan).await + } + SyncStreamDecryptExecution::CommonEncryption(plan) => { + execute_common_encryption_stream_plan_async(input, output, plan).await + } + SyncStreamDecryptExecution::Movie(plan) => { + execute_movie_stream_plan_async(input, output, plan).await + } + } +} + +fn resolve_stream_fragments_info_init_bytes( + fragments_info_reader: Option<&mut dyn SyncReadSeek>, + options: &DecryptOptions, +) -> Result, DecryptError> { + if let Some(bytes) = options.fragments_info_bytes() { + return Ok(bytes.to_vec()); + } + let Some(reader) = fragments_info_reader else { + return Err(DecryptError::MissingFragmentsInfo); + }; + let mut adapter = SyncReadSeekAdapter { inner: reader }; + let root_boxes = read_root_box_infos_from_reader(&mut adapter)?; + collect_common_encryption_init_segment_bytes_from_reader(&mut adapter, &root_boxes) +} + +#[cfg(feature = "async")] +async fn resolve_async_stream_fragments_info_init_bytes( + fragments_info_reader: Option<&mut dyn AsyncReadSeek>, + options: &DecryptOptions, +) -> Result, DecryptError> { + if let Some(bytes) = options.fragments_info_bytes() { + return Ok(bytes.to_vec()); + } + let Some(reader) = fragments_info_reader else { + return Err(DecryptError::MissingFragmentsInfo); + }; + let mut adapter = AsyncReadSeekAdapter { inner: reader }; + let root_boxes = read_root_box_infos_from_async_reader(&mut adapter).await?; + collect_common_encryption_init_segment_bytes_from_async_reader(&mut adapter, &root_boxes).await +} + +fn build_common_encryption_init_stream_plan( + input: &mut R, + root_boxes: &[BoxInfo], + keys: &[DecryptionKey], +) -> Result +where + R: Read + Seek, +{ + let init_bytes = collect_common_encryption_init_moov_bytes_from_reader(input, root_boxes)?; + let context = analyze_init_segment(&init_bytes)?; + let rebuilt_moov = rebuild_common_encryption_moov(&init_bytes, &context, keys)?; + let original_moov = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + .ok_or_else(|| invalid_layout("expected one moov box in the init segment".to_owned()))?; + + Ok(RootRewriteStreamPlan { + root_boxes: root_boxes.to_vec(), + replacements: BTreeMap::from([(original_moov.offset(), rebuilt_moov)]), + }) +} + +#[cfg(feature = "async")] +async fn build_common_encryption_init_stream_plan_async( + input: &mut R, + root_boxes: &[BoxInfo], + keys: &[DecryptionKey], +) -> Result +where + R: AsyncReadSeek, +{ + let init_bytes = + collect_common_encryption_init_moov_bytes_from_async_reader(input, root_boxes).await?; + let context = analyze_init_segment(&init_bytes)?; + let rebuilt_moov = rebuild_common_encryption_moov(&init_bytes, &context, keys)?; + let original_moov = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + .ok_or_else(|| invalid_layout("expected one moov box in the init segment".to_owned()))?; + + Ok(RootRewriteStreamPlan { + root_boxes: root_boxes.to_vec(), + replacements: BTreeMap::from([(original_moov.offset(), rebuilt_moov)]), + }) +} + +fn build_oma_dcf_atom_stream_plan( + input: &mut R, + root_boxes: &[BoxInfo], + keys: &[DecryptionKey], +) -> Result +where + R: Read + Seek, +{ + let mut replacements = BTreeMap::new(); + let mut odrm_index = 0_u32; + for info in root_boxes.iter().copied() { + if info.box_type() != ODRM { + continue; + } + + odrm_index = odrm_index + .checked_add(1) + .ok_or_else(|| invalid_layout("OMA DCF atom index overflowed u32".to_owned()))?; + let Some(key) = keys.iter().find_map(|entry| match entry.id() { + DecryptionKeyId::TrackId(candidate) if candidate == odrm_index => { + Some(entry.key_bytes()) + } + _ => None, + }) else { + continue; + }; + + let odrm_bytes = read_box_bytes_from_reader(input, info)?; + let local_root_boxes = read_root_box_infos(&odrm_bytes)?; + let local_odrm_info = local_root_boxes + .iter() + .copied() + .find(|candidate| candidate.box_type() == ODRM) + .ok_or_else(|| invalid_layout("expected one local odrm box".to_owned()))?; + replacements.insert( + info.offset(), + rewrite_oma_dcf_atom_box(&odrm_bytes, local_odrm_info, key)?, + ); + } + + Ok(RootRewriteStreamPlan { + root_boxes: root_boxes.to_vec(), + replacements, + }) +} + +#[cfg(feature = "async")] +async fn build_oma_dcf_atom_stream_plan_async( + input: &mut R, + root_boxes: &[BoxInfo], + keys: &[DecryptionKey], +) -> Result +where + R: AsyncReadSeek, +{ + let mut replacements = BTreeMap::new(); + let mut odrm_index = 0_u32; + for info in root_boxes.iter().copied() { + if info.box_type() != ODRM { + continue; + } + + odrm_index = odrm_index + .checked_add(1) + .ok_or_else(|| invalid_layout("OMA DCF atom index overflowed u32".to_owned()))?; + let Some(key) = keys.iter().find_map(|entry| match entry.id() { + DecryptionKeyId::TrackId(candidate) if candidate == odrm_index => { + Some(entry.key_bytes()) + } + _ => None, + }) else { + continue; + }; + + let odrm_bytes = read_box_bytes_from_async_reader(input, info).await?; + let local_root_boxes = read_root_box_infos(&odrm_bytes)?; + let local_odrm_info = local_root_boxes + .iter() + .copied() + .find(|candidate| candidate.box_type() == ODRM) + .ok_or_else(|| invalid_layout("expected one local odrm box".to_owned()))?; + replacements.insert( + info.offset(), + rewrite_oma_dcf_atom_box(&odrm_bytes, local_odrm_info, key)?, + ); + } + + Ok(RootRewriteStreamPlan { + root_boxes: root_boxes.to_vec(), + replacements, + }) +} + +fn execute_root_rewrite_stream_plan( + input: &mut R, + output: &mut W, + plan: &RootRewriteStreamPlan, +) -> Result<(), DecryptError> +where + R: Read + Seek, + W: Write + Seek, +{ + output.seek(SeekFrom::Start(0))?; + for root_info in &plan.root_boxes { + if let Some(replacement) = plan.replacements.get(&root_info.offset()) { + output.write_all(replacement)?; + } else { + copy_exact_range(input, output, root_info.offset(), root_info.size())?; + } + } + output.flush()?; + Ok(()) +} + +#[cfg(feature = "async")] +async fn execute_root_rewrite_stream_plan_async( + input: &mut R, + output: &mut W, + plan: &RootRewriteStreamPlan, +) -> Result<(), DecryptError> +where + R: AsyncReadSeek, + W: AsyncWriteSeek, +{ + output.seek(SeekFrom::Start(0)).await?; + for root_info in &plan.root_boxes { + if let Some(replacement) = plan.replacements.get(&root_info.offset()) { + output.write_all(replacement).await?; + } else { + copy_exact_range_async(input, output, root_info.offset(), root_info.size()).await?; + } + } + output.flush().await?; + Ok(()) +} + +fn collect_selected_root_box_bytes_from_reader( + input: &mut R, + root_boxes: &[BoxInfo], + mut include: P, +) -> Result, DecryptError> +where + R: Read + Seek, + P: FnMut(BoxInfo) -> bool, +{ + let mut bytes = Vec::new(); + for info in root_boxes.iter().copied().filter(|info| include(*info)) { + bytes.extend_from_slice(&read_box_bytes_from_reader(input, info)?); + } + Ok(bytes) +} + +#[cfg(feature = "async")] +async fn collect_selected_root_box_bytes_from_async_reader( + input: &mut R, + root_boxes: &[BoxInfo], + mut include: P, +) -> Result, DecryptError> +where + R: AsyncReadSeek, + P: FnMut(BoxInfo) -> bool, +{ + let mut bytes = Vec::new(); + for info in root_boxes.iter().copied().filter(|info| include(*info)) { + bytes.extend_from_slice(&read_box_bytes_from_async_reader(input, info).await?); + } + Ok(bytes) +} + +fn collect_common_encryption_init_moov_bytes_from_reader( + input: &mut R, + root_boxes: &[BoxInfo], +) -> Result, DecryptError> +where + R: Read + Seek, +{ + collect_selected_root_box_bytes_from_reader(input, root_boxes, |info| info.box_type() == MOOV) +} + +#[cfg(feature = "async")] +async fn collect_common_encryption_init_moov_bytes_from_async_reader( + input: &mut R, + root_boxes: &[BoxInfo], +) -> Result, DecryptError> +where + R: AsyncReadSeek, +{ + collect_selected_root_box_bytes_from_async_reader(input, root_boxes, |info| { + info.box_type() == MOOV + }) + .await +} + +fn collect_non_mdat_root_box_bytes_from_reader( + input: &mut R, + root_boxes: &[BoxInfo], +) -> Result, DecryptError> +where + R: Read + Seek, +{ + collect_selected_root_box_bytes_from_reader(input, root_boxes, |info| info.box_type() != MDAT) +} + +#[cfg(feature = "async")] +async fn collect_non_mdat_root_box_bytes_from_async_reader( + input: &mut R, + root_boxes: &[BoxInfo], +) -> Result, DecryptError> +where + R: AsyncReadSeek, +{ + collect_selected_root_box_bytes_from_async_reader(input, root_boxes, |info| { + info.box_type() != MDAT + }) + .await +} + +fn extract_required_root_box_bytes( + input: &[u8], + box_type: FourCc, + description: &'static str, +) -> Result, DecryptError> { + let root_boxes = read_root_box_infos(input)?; + let info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == box_type) + .ok_or_else(|| invalid_layout(format!("expected one root {description} box")))?; + Ok(slice_box_bytes(input, info)?.to_vec()) +} + +type StreamedMoviePayloadPlan = ( + RebuiltMovieSampleSizes, + TrackRelativeChunkOffsets, + Vec, + u64, +); + +fn build_marlin_movie_stream_plan( + input: &mut R, + root_boxes: &[BoxInfo], + keys: &[DecryptionKey], +) -> Result +where + R: Read + Seek, +{ + let metadata_input = collect_non_mdat_root_box_bytes_from_reader(input, root_boxes)?; + let mdat_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MDAT) + .collect::>(); + let context = analyze_marlin_movie_metadata_from_reader(&metadata_input, input, mdat_infos)?; + let metadata_root_boxes = read_root_box_infos(&metadata_input)?; + let mdat_ranges = media_data_ranges_from_infos(&context.mdat_infos); + + let mut track_processes = BTreeMap::new(); + for track in &context.tracks { + let process = match track.marlin.as_ref() { + Some(protection) => resolve_marlin_track_key(track.track_id, protection, keys)? + .map(|key| MovieSampleProcessKind::Marlin { key }) + .unwrap_or(MovieSampleProcessKind::Copy), + None => MovieSampleProcessKind::Copy, + }; + track_processes.insert(track.track_id, process); + } + + let payload_tracks = context + .tracks + .iter() + .map(|track| MovieTrackPayloadPlan { + track_id: track.track_id, + stsc: &track.stsc, + chunk_offsets: &track.chunk_offsets, + sample_sizes: &track.sample_sizes, + }) + .collect::>(); + let (clear_sample_sizes, relative_chunk_offsets, sample_edits, clear_payload_size) = + plan_movie_payload_from_reader(input, &mdat_ranges, &payload_tracks, |track_id| { + track_processes.get(&track_id).cloned().ok_or_else(|| { + invalid_layout(format!( + "missing stream-first Marlin process for track {}", + track_id + )) + }) + })?; + + let mut track_plans = Vec::new(); + for track in &context.tracks { + let clear_sizes = clear_sample_sizes.get(&track.track_id).ok_or_else(|| { + invalid_layout(format!( + "missing clear sample sizes for Marlin track {}", + track.track_id + )) + })?; + track_plans.push(MovieTrackRewritePlan { + track_id: track.track_id, + trak_info: track.trak_info, + mdia_info: track.mdia_info, + minf_info: track.minf_info, + stbl_info: track.stbl_info, + chunk_offsets: track.chunk_offsets.clone(), + stsd_replacement: None, + stsz_replacement: Some(( + track.stsz_info.offset(), + build_patched_stsz_bytes(&track.stsz, clear_sizes, "Marlin")?, + )), + }); + } + + let placeholder_offsets = track_plans + .iter() + .map(|plan| (plan.track_id, chunk_offsets_values(&plan.chunk_offsets))) + .collect::(); + let moov_placeholder = build_marlin_moov_with_track_replacements( + &metadata_input, + &context, + &track_plans, + &placeholder_offsets, + )?; + let clear_ftyp = encode_box_with_children(&build_clear_marlin_ftyp(&context.ftyp), &[])?; + let clear_mdat_header = build_streamed_mdat_header(clear_payload_size)?; + let mdat_payload_offset = compute_single_mdat_payload_offset( + &metadata_input, + &metadata_root_boxes, + Some(context.ftyp_info), + context.moov_info, + Some(&clear_ftyp), + &moov_placeholder, + u64::try_from(clear_mdat_header.len()).map_err(|_| { + invalid_layout("clear Marlin mdat header size does not fit in u64".to_owned()) + })?, + )?; + let absolute_offsets = relative_chunk_offsets + .iter() + .map(|(track_id, offsets)| { + let absolute = offsets + .iter() + .map(|offset| { + mdat_payload_offset.checked_add(*offset).ok_or_else(|| { + invalid_layout("clear Marlin chunk offset overflowed u64".to_owned()) + }) + }) + .collect::, _>>()?; + Ok((*track_id, absolute)) + }) + .collect::>()?; + let clear_moov = build_marlin_moov_with_track_replacements( + &metadata_input, + &context, + &track_plans, + &absolute_offsets, + )?; + let original_ftyp = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == FTYP) + .ok_or_else(|| { + invalid_layout("expected one root ftyp box in the Marlin movie file".to_owned()) + })?; + let original_moov = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + .ok_or_else(|| { + invalid_layout("expected one root moov box in the Marlin movie file".to_owned()) + })?; + + Ok(MovieRewriteStreamPlan { + root_boxes: root_boxes.to_vec(), + root_replacements: BTreeMap::from([ + (original_ftyp.offset(), clear_ftyp), + (original_moov.offset(), clear_moov), + ]), + clear_mdat_header, + sample_edits, + }) +} + +#[cfg(feature = "async")] +async fn build_marlin_movie_stream_plan_async( + input: &mut R, + root_boxes: &[BoxInfo], + keys: &[DecryptionKey], +) -> Result +where + R: AsyncReadSeek, +{ + let metadata_input = + collect_non_mdat_root_box_bytes_from_async_reader(input, root_boxes).await?; + let mdat_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MDAT) + .collect::>(); + let context = + analyze_marlin_movie_metadata_from_async_reader(&metadata_input, input, mdat_infos).await?; + let metadata_root_boxes = read_root_box_infos(&metadata_input)?; + let mdat_ranges = media_data_ranges_from_infos(&context.mdat_infos); + + let mut track_processes = BTreeMap::new(); + for track in &context.tracks { + let process = match track.marlin.as_ref() { + Some(protection) => resolve_marlin_track_key(track.track_id, protection, keys)? + .map(|key| MovieSampleProcessKind::Marlin { key }) + .unwrap_or(MovieSampleProcessKind::Copy), + None => MovieSampleProcessKind::Copy, + }; + track_processes.insert(track.track_id, process); + } + + let payload_tracks = context + .tracks + .iter() + .map(|track| MovieTrackPayloadPlan { + track_id: track.track_id, + stsc: &track.stsc, + chunk_offsets: &track.chunk_offsets, + sample_sizes: &track.sample_sizes, + }) + .collect::>(); + let (clear_sample_sizes, relative_chunk_offsets, sample_edits, clear_payload_size) = + plan_movie_payload_from_async_reader(input, &mdat_ranges, &payload_tracks, |track_id| { + track_processes.get(&track_id).cloned().ok_or_else(|| { + invalid_layout(format!( + "missing stream-first Marlin process for track {}", + track_id + )) + }) + }) + .await?; + + let mut track_plans = Vec::new(); + for track in &context.tracks { + let clear_sizes = clear_sample_sizes.get(&track.track_id).ok_or_else(|| { + invalid_layout(format!( + "missing clear sample sizes for Marlin track {}", + track.track_id + )) + })?; + track_plans.push(MovieTrackRewritePlan { + track_id: track.track_id, + trak_info: track.trak_info, + mdia_info: track.mdia_info, + minf_info: track.minf_info, + stbl_info: track.stbl_info, + chunk_offsets: track.chunk_offsets.clone(), + stsd_replacement: None, + stsz_replacement: Some(( + track.stsz_info.offset(), + build_patched_stsz_bytes(&track.stsz, clear_sizes, "Marlin")?, + )), + }); + } + + let placeholder_offsets = track_plans + .iter() + .map(|plan| (plan.track_id, chunk_offsets_values(&plan.chunk_offsets))) + .collect::(); + let moov_placeholder = build_marlin_moov_with_track_replacements( + &metadata_input, + &context, + &track_plans, + &placeholder_offsets, + )?; + let clear_ftyp = encode_box_with_children(&build_clear_marlin_ftyp(&context.ftyp), &[])?; + let clear_mdat_header = build_streamed_mdat_header(clear_payload_size)?; + let mdat_payload_offset = compute_single_mdat_payload_offset( + &metadata_input, + &metadata_root_boxes, + Some(context.ftyp_info), + context.moov_info, + Some(&clear_ftyp), + &moov_placeholder, + u64::try_from(clear_mdat_header.len()).map_err(|_| { + invalid_layout("clear Marlin mdat header size does not fit in u64".to_owned()) + })?, + )?; + let absolute_offsets = relative_chunk_offsets + .iter() + .map(|(track_id, offsets)| { + let absolute = offsets + .iter() + .map(|offset| { + mdat_payload_offset.checked_add(*offset).ok_or_else(|| { + invalid_layout("clear Marlin chunk offset overflowed u64".to_owned()) + }) + }) + .collect::, _>>()?; + Ok((*track_id, absolute)) + }) + .collect::>()?; + let clear_moov = build_marlin_moov_with_track_replacements( + &metadata_input, + &context, + &track_plans, + &absolute_offsets, + )?; + let original_ftyp = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == FTYP) + .ok_or_else(|| { + invalid_layout("expected one root ftyp box in the Marlin movie file".to_owned()) + })?; + let original_moov = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + .ok_or_else(|| { + invalid_layout("expected one root moov box in the Marlin movie file".to_owned()) + })?; + + Ok(MovieRewriteStreamPlan { + root_boxes: root_boxes.to_vec(), + root_replacements: BTreeMap::from([ + (original_ftyp.offset(), clear_ftyp), + (original_moov.offset(), clear_moov), + ]), + clear_mdat_header, + sample_edits, + }) +} + +fn build_oma_dcf_movie_stream_plan( + input: &mut R, + root_boxes: &[BoxInfo], + keys: &[DecryptionKey], +) -> Result +where + R: Read + Seek, +{ + let metadata_input = collect_non_mdat_root_box_bytes_from_reader(input, root_boxes)?; + let mdat_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MDAT) + .collect::>(); + let context = analyze_oma_dcf_movie_metadata(&metadata_input, mdat_infos)?; + let metadata_root_boxes = read_root_box_infos(&metadata_input)?; + let mdat_ranges = media_data_ranges_from_infos(&context.mdat_infos); + let protected_by_track = context + .tracks + .iter() + .map(|track| (track.track_id, track)) + .collect::>(); + let track_keys = keys + .iter() + .filter_map(|entry| match entry.id() { + DecryptionKeyId::TrackId(track_id) => Some((track_id, entry.key_bytes())), + _ => None, + }) + .collect::>(); + + let mut payload_tracks = context + .tracks + .iter() + .map(|track| MovieTrackPayloadPlan { + track_id: track.track_id, + stsc: &track.stsc, + chunk_offsets: &track.chunk_offsets, + sample_sizes: &track.sample_sizes, + }) + .collect::>(); + payload_tracks.extend( + context + .other_tracks + .iter() + .map(|track| MovieTrackPayloadPlan { + track_id: track.track_id, + stsc: &track.stsc, + chunk_offsets: &track.chunk_offsets, + sample_sizes: &track.sample_sizes, + }), + ); + + let (clear_sample_sizes, relative_chunk_offsets, sample_edits, clear_payload_size) = + plan_movie_payload_from_reader(input, &mdat_ranges, &payload_tracks, |track_id| { + let Some(track) = protected_by_track.get(&track_id) else { + return Ok(MovieSampleProcessKind::Copy); + }; + let Some(key) = track_keys.get(&track_id).copied() else { + return Ok(MovieSampleProcessKind::Copy); + }; + Ok(MovieSampleProcessKind::Oma { + odaf: track.odaf.clone(), + ohdr: track.ohdr.clone(), + key, + }) + })?; + + let mut track_plans = Vec::new(); + for track in &context.tracks { + let stsd_replacement = if track_keys.contains_key(&track.track_id) { + Some(( + track.stsd_info.offset(), + rebuild_box_with_child_replacements( + &metadata_input, + track.stsd_info, + &BTreeMap::from([( + track.sample_entry_info.offset(), + Some(build_clear_sample_entry_bytes( + &metadata_input, + track.sample_entry_info, + track.original_format, + track.sinf_info, + )?), + )]), + None, + )?, + )) + } else { + None + }; + let stsz_replacement = if track_keys.contains_key(&track.track_id) { + Some(( + track.stsz_info.offset(), + build_patched_stsz_bytes( + &track.stsz, + clear_sample_sizes.get(&track.track_id).ok_or_else(|| { + invalid_layout(format!( + "missing rebuilt sample sizes for OMA DCF track {}", + track.track_id + )) + })?, + "OMA DCF", + )?, + )) + } else { + None + }; + track_plans.push(MovieTrackRewritePlan { + track_id: track.track_id, + trak_info: track.trak_info, + mdia_info: track.mdia_info, + minf_info: track.minf_info, + stbl_info: track.stbl_info, + chunk_offsets: track.chunk_offsets.clone(), + stsd_replacement, + stsz_replacement, + }); + } + track_plans.extend( + context + .other_tracks + .iter() + .map(|track| MovieTrackRewritePlan { + track_id: track.track_id, + trak_info: track.trak_info, + mdia_info: track.mdia_info, + minf_info: track.minf_info, + stbl_info: track.stbl_info, + chunk_offsets: track.chunk_offsets.clone(), + stsd_replacement: None, + stsz_replacement: None, + }), + ); + + let placeholder_offsets = track_plans + .iter() + .map(|plan| (plan.track_id, chunk_offsets_values(&plan.chunk_offsets))) + .collect::(); + let moov_placeholder = build_movie_moov_with_track_replacements( + &metadata_input, + context.moov_info, + &track_plans, + &placeholder_offsets, + )?; + let clear_ftyp = build_patched_oma_clear_ftyp_bytes(&metadata_input, context.ftyp_info)?; + let clear_mdat_header = build_streamed_mdat_header(clear_payload_size)?; + let mdat_payload_offset = compute_single_mdat_payload_offset( + &metadata_input, + &metadata_root_boxes, + context.ftyp_info, + context.moov_info, + clear_ftyp.as_deref(), + &moov_placeholder, + u64::try_from(clear_mdat_header.len()).map_err(|_| { + invalid_layout("clear OMA DCF mdat header size does not fit in u64".to_owned()) + })?, + )?; + let absolute_offsets = relative_chunk_offsets + .iter() + .map(|(track_id, offsets)| { + let absolute = offsets + .iter() + .map(|offset| { + mdat_payload_offset.checked_add(*offset).ok_or_else(|| { + invalid_layout("patched movie chunk offset overflowed u64".to_owned()) + }) + }) + .collect::, _>>()?; + Ok((*track_id, absolute)) + }) + .collect::>()?; + let clear_moov = build_movie_moov_with_track_replacements( + &metadata_input, + context.moov_info, + &track_plans, + &absolute_offsets, + )?; + let original_moov = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + .ok_or_else(|| { + invalid_layout("expected one root moov box in the protected movie file".to_owned()) + })?; + let original_ftyp = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == FTYP); + let mut root_replacements = BTreeMap::from([(original_moov.offset(), clear_moov)]); + if let (Some(original_ftyp), Some(clear_ftyp)) = (original_ftyp, clear_ftyp) { + root_replacements.insert(original_ftyp.offset(), clear_ftyp); + } + + Ok(MovieRewriteStreamPlan { + root_boxes: root_boxes.to_vec(), + root_replacements, + clear_mdat_header, + sample_edits, + }) +} + +#[cfg(feature = "async")] +async fn build_oma_dcf_movie_stream_plan_async( + input: &mut R, + root_boxes: &[BoxInfo], + keys: &[DecryptionKey], +) -> Result +where + R: AsyncReadSeek, +{ + let metadata_input = + collect_non_mdat_root_box_bytes_from_async_reader(input, root_boxes).await?; + let mdat_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MDAT) + .collect::>(); + let context = analyze_oma_dcf_movie_metadata(&metadata_input, mdat_infos)?; + let metadata_root_boxes = read_root_box_infos(&metadata_input)?; + let mdat_ranges = media_data_ranges_from_infos(&context.mdat_infos); + let protected_by_track = context + .tracks + .iter() + .map(|track| (track.track_id, track)) + .collect::>(); + let track_keys = keys + .iter() + .filter_map(|entry| match entry.id() { + DecryptionKeyId::TrackId(track_id) => Some((track_id, entry.key_bytes())), + _ => None, + }) + .collect::>(); + + let mut payload_tracks = context + .tracks + .iter() + .map(|track| MovieTrackPayloadPlan { + track_id: track.track_id, + stsc: &track.stsc, + chunk_offsets: &track.chunk_offsets, + sample_sizes: &track.sample_sizes, + }) + .collect::>(); + payload_tracks.extend( + context + .other_tracks + .iter() + .map(|track| MovieTrackPayloadPlan { + track_id: track.track_id, + stsc: &track.stsc, + chunk_offsets: &track.chunk_offsets, + sample_sizes: &track.sample_sizes, + }), + ); + + let (clear_sample_sizes, relative_chunk_offsets, sample_edits, clear_payload_size) = + plan_movie_payload_from_async_reader(input, &mdat_ranges, &payload_tracks, |track_id| { + let Some(track) = protected_by_track.get(&track_id) else { + return Ok(MovieSampleProcessKind::Copy); + }; + let Some(key) = track_keys.get(&track_id).copied() else { + return Ok(MovieSampleProcessKind::Copy); + }; + Ok(MovieSampleProcessKind::Oma { + odaf: track.odaf.clone(), + ohdr: track.ohdr.clone(), + key, + }) + }) + .await?; + + let mut track_plans = Vec::new(); + for track in &context.tracks { + let stsd_replacement = if track_keys.contains_key(&track.track_id) { + Some(( + track.stsd_info.offset(), + rebuild_box_with_child_replacements( + &metadata_input, + track.stsd_info, + &BTreeMap::from([( + track.sample_entry_info.offset(), + Some(build_clear_sample_entry_bytes( + &metadata_input, + track.sample_entry_info, + track.original_format, + track.sinf_info, + )?), + )]), + None, + )?, + )) + } else { + None + }; + let stsz_replacement = if track_keys.contains_key(&track.track_id) { + Some(( + track.stsz_info.offset(), + build_patched_stsz_bytes( + &track.stsz, + clear_sample_sizes.get(&track.track_id).ok_or_else(|| { + invalid_layout(format!( + "missing rebuilt sample sizes for OMA DCF track {}", + track.track_id + )) + })?, + "OMA DCF", + )?, + )) + } else { + None + }; + track_plans.push(MovieTrackRewritePlan { + track_id: track.track_id, + trak_info: track.trak_info, + mdia_info: track.mdia_info, + minf_info: track.minf_info, + stbl_info: track.stbl_info, + chunk_offsets: track.chunk_offsets.clone(), + stsd_replacement, + stsz_replacement, + }); + } + track_plans.extend( + context + .other_tracks + .iter() + .map(|track| MovieTrackRewritePlan { + track_id: track.track_id, + trak_info: track.trak_info, + mdia_info: track.mdia_info, + minf_info: track.minf_info, + stbl_info: track.stbl_info, + chunk_offsets: track.chunk_offsets.clone(), + stsd_replacement: None, + stsz_replacement: None, + }), + ); + + let placeholder_offsets = track_plans + .iter() + .map(|plan| (plan.track_id, chunk_offsets_values(&plan.chunk_offsets))) + .collect::(); + let moov_placeholder = build_movie_moov_with_track_replacements( + &metadata_input, + context.moov_info, + &track_plans, + &placeholder_offsets, + )?; + let clear_ftyp = build_patched_oma_clear_ftyp_bytes(&metadata_input, context.ftyp_info)?; + let clear_mdat_header = build_streamed_mdat_header(clear_payload_size)?; + let mdat_payload_offset = compute_single_mdat_payload_offset( + &metadata_input, + &metadata_root_boxes, + context.ftyp_info, + context.moov_info, + clear_ftyp.as_deref(), + &moov_placeholder, + u64::try_from(clear_mdat_header.len()).map_err(|_| { + invalid_layout("clear OMA DCF mdat header size does not fit in u64".to_owned()) + })?, + )?; + let absolute_offsets = relative_chunk_offsets + .iter() + .map(|(track_id, offsets)| { + let absolute = offsets + .iter() + .map(|offset| { + mdat_payload_offset.checked_add(*offset).ok_or_else(|| { + invalid_layout("patched movie chunk offset overflowed u64".to_owned()) + }) + }) + .collect::, _>>()?; + Ok((*track_id, absolute)) + }) + .collect::>()?; + let clear_moov = build_movie_moov_with_track_replacements( + &metadata_input, + context.moov_info, + &track_plans, + &absolute_offsets, + )?; + let original_moov = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + .ok_or_else(|| { + invalid_layout("expected one root moov box in the protected movie file".to_owned()) + })?; + let original_ftyp = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == FTYP); + let mut root_replacements = BTreeMap::from([(original_moov.offset(), clear_moov)]); + if let (Some(original_ftyp), Some(clear_ftyp)) = (original_ftyp, clear_ftyp) { + root_replacements.insert(original_ftyp.offset(), clear_ftyp); + } + + Ok(MovieRewriteStreamPlan { + root_boxes: root_boxes.to_vec(), + root_replacements, + clear_mdat_header, + sample_edits, + }) +} + +fn build_iaec_movie_stream_plan( + input: &mut R, + root_boxes: &[BoxInfo], + keys: &[DecryptionKey], +) -> Result +where + R: Read + Seek, +{ + let metadata_input = collect_non_mdat_root_box_bytes_from_reader(input, root_boxes)?; + let mdat_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MDAT) + .collect::>(); + let context = analyze_iaec_movie_metadata(&metadata_input, mdat_infos)?; + let metadata_root_boxes = read_root_box_infos(&metadata_input)?; + let mdat_ranges = media_data_ranges_from_infos(&context.mdat_infos); + let protected_by_track = context + .tracks + .iter() + .map(|track| (track.track_id, track)) + .collect::>(); + let track_keys = keys + .iter() + .filter_map(|entry| match entry.id() { + DecryptionKeyId::TrackId(track_id) => Some((track_id, entry.key_bytes())), + _ => None, + }) + .collect::>(); + + let mut payload_tracks = context + .tracks + .iter() + .map(|track| MovieTrackPayloadPlan { + track_id: track.track_id, + stsc: &track.stsc, + chunk_offsets: &track.chunk_offsets, + sample_sizes: &track.sample_sizes, + }) + .collect::>(); + payload_tracks.extend( + context + .other_tracks + .iter() + .map(|track| MovieTrackPayloadPlan { + track_id: track.track_id, + stsc: &track.stsc, + chunk_offsets: &track.chunk_offsets, + sample_sizes: &track.sample_sizes, + }), + ); + + let (clear_sample_sizes, relative_chunk_offsets, sample_edits, clear_payload_size) = + plan_movie_payload_from_reader(input, &mdat_ranges, &payload_tracks, |track_id| { + let Some(track) = protected_by_track.get(&track_id) else { + return Ok(MovieSampleProcessKind::Copy); + }; + let Some(key) = track_keys.get(&track_id).copied() else { + return Ok(MovieSampleProcessKind::Copy); + }; + Ok(MovieSampleProcessKind::Iaec { + isfm: track.isfm.clone(), + islt: track.islt.clone(), + key, + }) + })?; + + let mut track_plans = Vec::new(); + for track in &context.tracks { + let stsd_replacement = if track_keys.contains_key(&track.track_id) { + Some(( + track.stsd_info.offset(), + rebuild_box_with_child_replacements( + &metadata_input, + track.stsd_info, + &BTreeMap::from([( + track.sample_entry_info.offset(), + Some(build_clear_sample_entry_bytes( + &metadata_input, + track.sample_entry_info, + track.original_format, + track.sinf_info, + )?), + )]), + None, + )?, + )) + } else { + None + }; + let stsz_replacement = if track_keys.contains_key(&track.track_id) { + Some(( + track.stsz_info.offset(), + build_patched_stsz_bytes( + &track.stsz, + clear_sample_sizes.get(&track.track_id).ok_or_else(|| { + invalid_layout(format!( + "missing rebuilt sample sizes for IAEC track {}", + track.track_id + )) + })?, + "IAEC", + )?, + )) + } else { + None + }; + track_plans.push(MovieTrackRewritePlan { + track_id: track.track_id, + trak_info: track.trak_info, + mdia_info: track.mdia_info, + minf_info: track.minf_info, + stbl_info: track.stbl_info, + chunk_offsets: track.chunk_offsets.clone(), + stsd_replacement, + stsz_replacement, + }); + } + track_plans.extend( + context + .other_tracks + .iter() + .map(|track| MovieTrackRewritePlan { + track_id: track.track_id, + trak_info: track.trak_info, + mdia_info: track.mdia_info, + minf_info: track.minf_info, + stbl_info: track.stbl_info, + chunk_offsets: track.chunk_offsets.clone(), + stsd_replacement: None, + stsz_replacement: None, + }), + ); + + let placeholder_offsets = track_plans + .iter() + .map(|plan| (plan.track_id, chunk_offsets_values(&plan.chunk_offsets))) + .collect::(); + let moov_placeholder = build_movie_moov_with_track_replacements( + &metadata_input, + context.moov_info, + &track_plans, + &placeholder_offsets, + )?; + let clear_mdat_header = build_streamed_mdat_header(clear_payload_size)?; + let mdat_payload_offset = compute_single_mdat_payload_offset( + &metadata_input, + &metadata_root_boxes, + context.ftyp_info, + context.moov_info, + None, + &moov_placeholder, + u64::try_from(clear_mdat_header.len()).map_err(|_| { + invalid_layout("clear IAEC mdat header size does not fit in u64".to_owned()) + })?, + )?; + let absolute_offsets = relative_chunk_offsets + .iter() + .map(|(track_id, offsets)| { + let absolute = offsets + .iter() + .map(|offset| { + mdat_payload_offset.checked_add(*offset).ok_or_else(|| { + invalid_layout("patched movie chunk offset overflowed u64".to_owned()) + }) + }) + .collect::, _>>()?; + Ok((*track_id, absolute)) + }) + .collect::>()?; + let clear_moov = build_movie_moov_with_track_replacements( + &metadata_input, + context.moov_info, + &track_plans, + &absolute_offsets, + )?; + let original_moov = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + .ok_or_else(|| { + invalid_layout("expected one root moov box in the protected movie file".to_owned()) + })?; + + Ok(MovieRewriteStreamPlan { + root_boxes: root_boxes.to_vec(), + root_replacements: BTreeMap::from([(original_moov.offset(), clear_moov)]), + clear_mdat_header, + sample_edits, + }) +} + +#[cfg(feature = "async")] +async fn build_iaec_movie_stream_plan_async( + input: &mut R, + root_boxes: &[BoxInfo], + keys: &[DecryptionKey], +) -> Result +where + R: AsyncReadSeek, +{ + let metadata_input = + collect_non_mdat_root_box_bytes_from_async_reader(input, root_boxes).await?; + let mdat_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MDAT) + .collect::>(); + let context = analyze_iaec_movie_metadata(&metadata_input, mdat_infos)?; + let metadata_root_boxes = read_root_box_infos(&metadata_input)?; + let mdat_ranges = media_data_ranges_from_infos(&context.mdat_infos); + let protected_by_track = context + .tracks + .iter() + .map(|track| (track.track_id, track)) + .collect::>(); + let track_keys = keys + .iter() + .filter_map(|entry| match entry.id() { + DecryptionKeyId::TrackId(track_id) => Some((track_id, entry.key_bytes())), + _ => None, + }) + .collect::>(); + + let mut payload_tracks = context + .tracks + .iter() + .map(|track| MovieTrackPayloadPlan { + track_id: track.track_id, + stsc: &track.stsc, + chunk_offsets: &track.chunk_offsets, + sample_sizes: &track.sample_sizes, + }) + .collect::>(); + payload_tracks.extend( + context + .other_tracks + .iter() + .map(|track| MovieTrackPayloadPlan { + track_id: track.track_id, + stsc: &track.stsc, + chunk_offsets: &track.chunk_offsets, + sample_sizes: &track.sample_sizes, + }), + ); + + let (clear_sample_sizes, relative_chunk_offsets, sample_edits, clear_payload_size) = + plan_movie_payload_from_async_reader(input, &mdat_ranges, &payload_tracks, |track_id| { + let Some(track) = protected_by_track.get(&track_id) else { + return Ok(MovieSampleProcessKind::Copy); + }; + let Some(key) = track_keys.get(&track_id).copied() else { + return Ok(MovieSampleProcessKind::Copy); + }; + Ok(MovieSampleProcessKind::Iaec { + isfm: track.isfm.clone(), + islt: track.islt.clone(), + key, + }) + }) + .await?; + + let mut track_plans = Vec::new(); + for track in &context.tracks { + let stsd_replacement = if track_keys.contains_key(&track.track_id) { + Some(( + track.stsd_info.offset(), + rebuild_box_with_child_replacements( + &metadata_input, + track.stsd_info, + &BTreeMap::from([( + track.sample_entry_info.offset(), + Some(build_clear_sample_entry_bytes( + &metadata_input, + track.sample_entry_info, + track.original_format, + track.sinf_info, + )?), + )]), + None, + )?, + )) + } else { + None + }; + let stsz_replacement = if track_keys.contains_key(&track.track_id) { + Some(( + track.stsz_info.offset(), + build_patched_stsz_bytes( + &track.stsz, + clear_sample_sizes.get(&track.track_id).ok_or_else(|| { + invalid_layout(format!( + "missing rebuilt sample sizes for IAEC track {}", + track.track_id + )) + })?, + "IAEC", + )?, + )) + } else { + None + }; + track_plans.push(MovieTrackRewritePlan { + track_id: track.track_id, + trak_info: track.trak_info, + mdia_info: track.mdia_info, + minf_info: track.minf_info, + stbl_info: track.stbl_info, + chunk_offsets: track.chunk_offsets.clone(), + stsd_replacement, + stsz_replacement, + }); + } + track_plans.extend( + context + .other_tracks + .iter() + .map(|track| MovieTrackRewritePlan { + track_id: track.track_id, + trak_info: track.trak_info, + mdia_info: track.mdia_info, + minf_info: track.minf_info, + stbl_info: track.stbl_info, + chunk_offsets: track.chunk_offsets.clone(), + stsd_replacement: None, + stsz_replacement: None, + }), + ); + + let placeholder_offsets = track_plans + .iter() + .map(|plan| (plan.track_id, chunk_offsets_values(&plan.chunk_offsets))) + .collect::(); + let moov_placeholder = build_movie_moov_with_track_replacements( + &metadata_input, + context.moov_info, + &track_plans, + &placeholder_offsets, + )?; + let clear_mdat_header = build_streamed_mdat_header(clear_payload_size)?; + let mdat_payload_offset = compute_single_mdat_payload_offset( + &metadata_input, + &metadata_root_boxes, + context.ftyp_info, + context.moov_info, + None, + &moov_placeholder, + u64::try_from(clear_mdat_header.len()).map_err(|_| { + invalid_layout("clear IAEC mdat header size does not fit in u64".to_owned()) + })?, + )?; + let absolute_offsets = relative_chunk_offsets + .iter() + .map(|(track_id, offsets)| { + let absolute = offsets + .iter() + .map(|offset| { + mdat_payload_offset.checked_add(*offset).ok_or_else(|| { + invalid_layout("patched movie chunk offset overflowed u64".to_owned()) + }) + }) + .collect::, _>>()?; + Ok((*track_id, absolute)) + }) + .collect::>()?; + let clear_moov = build_movie_moov_with_track_replacements( + &metadata_input, + context.moov_info, + &track_plans, + &absolute_offsets, + )?; + let original_moov = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + .ok_or_else(|| { + invalid_layout("expected one root moov box in the protected movie file".to_owned()) + })?; + + Ok(MovieRewriteStreamPlan { + root_boxes: root_boxes.to_vec(), + root_replacements: BTreeMap::from([(original_moov.offset(), clear_moov)]), + clear_mdat_header, + sample_edits, + }) +} + +fn build_streamed_mdat_header(payload_size: u64) -> Result, DecryptRewriteError> { + let header_size = if payload_size + .checked_add(8) + .is_some_and(|size| size <= u64::from(u32::MAX)) + { + 8 + } else { + 16 + }; + encode_raw_box_with_header_size(MDAT, &[], header_size).and_then(|_| { + let total_size = payload_size + .checked_add(header_size) + .ok_or_else(|| invalid_layout("clear mdat size overflowed u64".to_owned()))?; + Ok(BoxInfo::new(MDAT, total_size) + .with_header_size(header_size) + .encode()) + }) +} + +fn execute_movie_stream_plan( + input: &mut R, + output: &mut W, + plan: &MovieRewriteStreamPlan, +) -> Result<(), DecryptError> +where + R: Read + Seek, + W: Write + Seek, +{ + output.seek(SeekFrom::Start(0))?; + let mut raw_queue = RawOffsetQueue::new(0); + let mut queue_buffer = vec![0_u8; 64 * 1024]; + for root_info in &plan.root_boxes { + if root_info.box_type() == MDAT { + continue; + } + if let Some(replacement) = plan.root_replacements.get(&root_info.offset()) { + output.write_all(replacement)?; + } else { + copy_exact_range(input, output, root_info.offset(), root_info.size())?; + } + } + output.write_all(&plan.clear_mdat_header)?; + for sample_edit in &plan.sample_edits { + with_range_from_seekable_queue( + input, + &mut raw_queue, + &mut queue_buffer, + sample_edit.absolute_offset, + u64::from(sample_edit.sample_size), + "movie sample execution", + |sample_bytes| write_processed_movie_sample(output, &sample_edit.process, sample_bytes), + )?; + } + output.flush()?; + Ok(()) +} + +#[cfg(feature = "async")] +async fn execute_movie_stream_plan_async( + input: &mut R, + output: &mut W, + plan: &MovieRewriteStreamPlan, +) -> Result<(), DecryptError> +where + R: AsyncReadSeek, + W: AsyncWriteSeek, +{ + output.seek(SeekFrom::Start(0)).await?; + let mut raw_queue = RawOffsetQueue::new(0); + let mut queue_buffer = vec![0_u8; 64 * 1024]; + for root_info in &plan.root_boxes { + if root_info.box_type() == MDAT { + continue; + } + if let Some(replacement) = plan.root_replacements.get(&root_info.offset()) { + output.write_all(replacement).await?; + } else { + copy_exact_range_async(input, output, root_info.offset(), root_info.size()).await?; + } + } + output.write_all(&plan.clear_mdat_header).await?; + for sample_edit in &plan.sample_edits { + let encrypted = with_range_from_seekable_queue_async( + input, + &mut raw_queue, + &mut queue_buffer, + sample_edit.absolute_offset, + u64::from(sample_edit.sample_size), + "movie sample execution", + |sample_bytes| Ok(sample_bytes.to_vec()), + ) + .await?; + write_processed_movie_sample_async(output, &sample_edit.process, &encrypted).await?; + } + output.flush().await?; + Ok(()) +} + +fn plan_movie_payload_from_reader( + input: &mut R, + mdat_ranges: &[MediaDataRange], + tracks: &[MovieTrackPayloadPlan<'_>], + mut resolve_process: F, +) -> Result +where + R: Read + Seek, + F: FnMut(u32) -> Result, +{ + let mut all_chunks = Vec::new(); + let mut sample_indices = BTreeMap::new(); + let mut rebuilt_sample_sizes = BTreeMap::>::new(); + let mut relative_offsets = BTreeMap::>::new(); + let mut raw_queue = RawOffsetQueue::new(0); + let mut queue_buffer = vec![0_u8; 64 * 1024]; + for track in tracks { + sample_indices.insert(track.track_id, 0_u32); + rebuilt_sample_sizes.insert(track.track_id, Vec::new()); + relative_offsets.insert(track.track_id, Vec::new()); + for chunk in compute_track_chunks( + track.track_id, + track.stsc, + track.chunk_offsets, + track.sample_sizes, + )? { + all_chunks.push((track.track_id, chunk)); + } + } + all_chunks.sort_by_key(|(_, chunk)| chunk.offset); + + let mut payload_size = 0_u64; + let mut previous_chunk_end = None; + let mut sample_edits = Vec::new(); + for (track_id, chunk) in all_chunks { + let chunk_size = sum_chunk_size(&chunk.sample_sizes)?; + if let Some(previous_chunk_end) = previous_chunk_end + && chunk.offset < previous_chunk_end + { + return Err(invalid_layout(format!( + "track {track_id} has overlapping chunk ranges in the protected movie layout at sample-description index {}", + chunk.sample_description_index + ))); + } + previous_chunk_end = Some( + chunk + .offset + .checked_add(chunk_size) + .ok_or_else(|| invalid_layout("movie chunk end overflowed u64".to_owned()))?, + ); + + relative_offsets + .get_mut(&track_id) + .unwrap() + .push(payload_size); + + let process = resolve_process(track_id)?; + let mut sample_offset = chunk.offset; + for sample_size in chunk.sample_sizes { + let sample_index = sample_indices.get_mut(&track_id).ok_or_else(|| { + invalid_layout(format!( + "missing sample index state for movie track {}", + track_id + )) + })?; + *sample_index = sample_index + .checked_add(1) + .ok_or_else(|| invalid_layout("movie sample index overflowed u32".to_owned()))?; + ensure_sample_range_in_mdat( + mdat_ranges, + track_id, + *sample_index, + sample_offset, + sample_size, + )?; + let clear_size = with_range_from_seekable_queue( + input, + &mut raw_queue, + &mut queue_buffer, + sample_offset, + u64::from(sample_size), + "movie sample planning", + |sample_bytes| { + Ok(processed_movie_sample_len_from_bytes( + &process, + sample_bytes, + )?) + }, + ) + .map_err(|error| invalid_layout(error.to_string()))?; + rebuilt_sample_sizes + .get_mut(&track_id) + .unwrap() + .push(clear_size); + sample_edits.push(MovieSampleEdit { + absolute_offset: sample_offset, + sample_size, + process: process.clone(), + }); + payload_size = payload_size.checked_add(clear_size).ok_or_else(|| { + invalid_layout("rebuilt mdat payload length does not fit in u64".to_owned()) + })?; + sample_offset = sample_offset + .checked_add(u64::from(sample_size)) + .ok_or_else(|| invalid_layout("movie sample offset overflowed u64".to_owned()))?; + } + } + + Ok(( + rebuilt_sample_sizes, + relative_offsets, + sample_edits, + payload_size, + )) +} + +#[cfg(feature = "async")] +async fn plan_movie_payload_from_async_reader( + input: &mut R, + mdat_ranges: &[MediaDataRange], + tracks: &[MovieTrackPayloadPlan<'_>], + mut resolve_process: F, +) -> Result +where + R: AsyncReadSeek, + F: FnMut(u32) -> Result, +{ + let mut all_chunks = Vec::new(); + let mut sample_indices = BTreeMap::new(); + let mut rebuilt_sample_sizes = BTreeMap::>::new(); + let mut relative_offsets = BTreeMap::>::new(); + let mut raw_queue = RawOffsetQueue::new(0); + let mut queue_buffer = vec![0_u8; 64 * 1024]; + for track in tracks { + sample_indices.insert(track.track_id, 0_u32); + rebuilt_sample_sizes.insert(track.track_id, Vec::new()); + relative_offsets.insert(track.track_id, Vec::new()); + for chunk in compute_track_chunks( + track.track_id, + track.stsc, + track.chunk_offsets, + track.sample_sizes, + )? { + all_chunks.push((track.track_id, chunk)); + } + } + all_chunks.sort_by_key(|(_, chunk)| chunk.offset); + + let mut payload_size = 0_u64; + let mut previous_chunk_end = None; + let mut sample_edits = Vec::new(); + for (track_id, chunk) in all_chunks { + let chunk_size = sum_chunk_size(&chunk.sample_sizes)?; + if let Some(previous_chunk_end) = previous_chunk_end + && chunk.offset < previous_chunk_end + { + return Err(invalid_layout(format!( + "track {track_id} has overlapping chunk ranges in the protected movie layout at sample-description index {}", + chunk.sample_description_index + ))); + } + previous_chunk_end = Some( + chunk + .offset + .checked_add(chunk_size) + .ok_or_else(|| invalid_layout("movie chunk end overflowed u64".to_owned()))?, + ); + + relative_offsets + .get_mut(&track_id) + .unwrap() + .push(payload_size); + + let process = resolve_process(track_id)?; + let mut sample_offset = chunk.offset; + for sample_size in chunk.sample_sizes { + let sample_index = sample_indices.get_mut(&track_id).ok_or_else(|| { + invalid_layout(format!( + "missing sample index state for movie track {}", + track_id + )) + })?; + *sample_index = sample_index + .checked_add(1) + .ok_or_else(|| invalid_layout("movie sample index overflowed u32".to_owned()))?; + ensure_sample_range_in_mdat( + mdat_ranges, + track_id, + *sample_index, + sample_offset, + sample_size, + )?; + let clear_size = with_range_from_seekable_queue_async( + input, + &mut raw_queue, + &mut queue_buffer, + sample_offset, + u64::from(sample_size), + "movie sample planning", + |sample_bytes| { + Ok(processed_movie_sample_len_from_bytes( + &process, + sample_bytes, + )?) + }, + ) + .await + .map_err(|error| invalid_layout(error.to_string()))?; + rebuilt_sample_sizes + .get_mut(&track_id) + .unwrap() + .push(clear_size); + sample_edits.push(MovieSampleEdit { + absolute_offset: sample_offset, + sample_size, + process: process.clone(), + }); + payload_size = payload_size.checked_add(clear_size).ok_or_else(|| { + invalid_layout("rebuilt mdat payload length does not fit in u64".to_owned()) + })?; + sample_offset = sample_offset + .checked_add(u64::from(sample_size)) + .ok_or_else(|| invalid_layout("movie sample offset overflowed u64".to_owned()))?; + } + } + + Ok(( + rebuilt_sample_sizes, + relative_offsets, + sample_edits, + payload_size, + )) +} + +fn process_movie_sample_bytes( + process: &MovieSampleProcessKind, + sample_bytes: &[u8], +) -> Result, DecryptRewriteError> { + match process { + MovieSampleProcessKind::Copy => Ok(sample_bytes.to_vec()), + MovieSampleProcessKind::Marlin { key } => decrypt_marlin_sample_payload(sample_bytes, *key), + MovieSampleProcessKind::Oma { odaf, ohdr, key } => { + decrypt_oma_dcf_sample_entry_payload(odaf, ohdr, *key, sample_bytes) + } + MovieSampleProcessKind::Iaec { isfm, islt, key } => { + decrypt_iaec_sample_entry_payload(isfm, islt.as_ref(), *key, sample_bytes) + } + } +} + +fn ensure_sample_range_in_mdat( + ranges: &[MediaDataRange], + track_id: u32, + sample_index: u32, + absolute_offset: u64, + sample_size: u32, +) -> Result<(), DecryptRewriteError> { + let end = absolute_offset + .checked_add(u64::from(sample_size)) + .ok_or_else(|| invalid_layout("sample range end overflowed u64".to_owned()))?; + if ranges + .iter() + .any(|range| absolute_offset >= range.start && end <= range.end) + { + return Ok(()); + } + Err(DecryptRewriteError::SampleDataRangeNotFound { + track_id, + sample_index, + absolute_offset, + sample_size, + }) +} + +fn read_box_bytes_from_reader(reader: &mut R, info: BoxInfo) -> Result, DecryptError> +where + R: Read + Seek, +{ + info.seek_to_start(reader)?; + let size = usize::try_from(info.size()).map_err(|_| DecryptError::InvalidInput { + reason: format!("box {} is too large to buffer in memory", info.box_type()), + })?; + let mut bytes = vec![0_u8; size]; + reader.read_exact(&mut bytes)?; + Ok(bytes) +} + +#[cfg(feature = "async")] +async fn read_box_bytes_from_async_reader( + reader: &mut R, + info: BoxInfo, +) -> Result, DecryptError> +where + R: AsyncReadSeek, +{ + info.seek_to_start_async(reader).await?; + let size = usize::try_from(info.size()).map_err(|_| DecryptError::InvalidInput { + reason: format!("box {} is too large to buffer in memory", info.box_type()), + })?; + let mut bytes = vec![0_u8; size]; + reader.read_exact(&mut bytes).await?; + Ok(bytes) +} + +fn read_root_box_infos_from_reader(reader: &mut R) -> Result, DecryptError> +where + R: Read + Seek, +{ + reader.seek(SeekFrom::Start(0))?; + let stream_end = reader.seek(SeekFrom::End(0))?; + reader.seek(SeekFrom::Start(0))?; + + let mut root_boxes = Vec::new(); + loop { + let position = reader.stream_position()?; + if position >= stream_end { + break; + } + + let info = BoxInfo::read(reader).map_err(std::io::Error::other)?; + info.seek_to_end(reader).map_err(std::io::Error::other)?; + root_boxes.push(info); + } + Ok(root_boxes) +} + +#[cfg(feature = "async")] +async fn read_root_box_infos_from_async_reader( + reader: &mut R, +) -> Result, DecryptError> +where + R: AsyncReadSeek, +{ + reader.seek(SeekFrom::Start(0)).await?; + let stream_end = reader.seek(SeekFrom::End(0)).await?; + reader.seek(SeekFrom::Start(0)).await?; + + let mut root_boxes = Vec::new(); + loop { + let position = reader.stream_position().await?; + if position >= stream_end { + break; + } + + let info = BoxInfo::read_async(reader) + .await + .map_err(std::io::Error::other)?; + info.seek_to_end_async(reader) + .await + .map_err(std::io::Error::other)?; + root_boxes.push(info); + } + Ok(root_boxes) +} + +fn classify_decrypt_input_from_reader( + reader: &mut R, + root_boxes: &[BoxInfo], +) -> Result +where + R: Read + Seek, +{ + let has_moov = root_boxes.iter().any(|info| info.box_type() == MOOV); + let has_moof = root_boxes.iter().any(|info| info.box_type() == MOOF); + let has_mdat = root_boxes.iter().any(|info| info.box_type() == MDAT); + let has_odrm = root_boxes.iter().any(|info| info.box_type() == ODRM); + + let ftyp = extract_box_as::<_, Ftyp>(reader, None, BoxPath::from([FTYP]))? + .into_iter() + .next(); + let is_marlin_ipmp_movie = ftyp.as_ref().is_some_and(|entry| { + entry.major_brand == MARLIN_BRAND_MGSV + || entry.compatible_brands.contains(&MARLIN_BRAND_MGSV) + }); + let is_oma_dcf_atom_file = has_odrm + && ftyp.as_ref().is_some_and(|entry| { + entry.major_brand == ODCF || entry.compatible_brands.contains(&ODCF) + }); + + let protected_movie_layout = + if has_moov && has_mdat && !has_moof && !is_oma_dcf_atom_file && is_marlin_ipmp_movie { + Some(DecryptInputLayout::MarlinIpmpFile) + } else if has_moov && has_mdat && !has_moof && !is_oma_dcf_atom_file { + detect_non_fragmented_protected_movie_layout_from_reader(reader)? + } else { + None + }; + + match ( + has_moov, + has_moof, + has_mdat, + is_oma_dcf_atom_file, + protected_movie_layout, + ) { + (false, false, _, true, _) => Ok(DecryptInputLayout::OmaDcfAtomFile), + (true, true, _, false, _) => Ok(DecryptInputLayout::FragmentedFile), + (true, false, true, false, Some(DecryptInputLayout::MarlinIpmpFile)) => { + Ok(DecryptInputLayout::MarlinIpmpFile) + } + (true, false, true, false, Some(DecryptInputLayout::OmaDcfProtectedMovieFile)) => { + Ok(DecryptInputLayout::OmaDcfProtectedMovieFile) + } + (true, false, true, false, Some(DecryptInputLayout::IaecProtectedMovieFile)) => { + Ok(DecryptInputLayout::IaecProtectedMovieFile) + } + (true, false, false, false, _) => Ok(DecryptInputLayout::InitSegment), + (false, true, _, false, _) => Ok(DecryptInputLayout::MediaSegment), + (false, false, false, false, _) => Err(DecryptError::InvalidInput { + reason: "expected a moov box, a moof box, both, or a root OMA DCF atom file" + .to_owned(), + }), + (_, _, _, true, _) => Err(DecryptError::InvalidInput { + reason: + "root OMA DCF atom files are expected to carry odrm without moov or moof at the top level" + .to_owned(), + }), + (true, false, true, false, None) => Err(DecryptError::InvalidInput { + reason: + "non-fragmented movie files are only supported for the current Marlin IPMP, OMA DCF, or IAEC protected layouts" + .to_owned(), + }), + _ => Err(DecryptError::InvalidInput { + reason: "input does not match one of the currently supported decrypt layouts" + .to_owned(), + }), + } +} + +#[cfg(feature = "async")] +async fn classify_decrypt_input_from_async_reader( + reader: &mut R, + root_boxes: &[BoxInfo], +) -> Result +where + R: AsyncReadSeek, +{ + let has_moov = root_boxes.iter().any(|info| info.box_type() == MOOV); + let has_moof = root_boxes.iter().any(|info| info.box_type() == MOOF); + let has_mdat = root_boxes.iter().any(|info| info.box_type() == MDAT); + let has_odrm = root_boxes.iter().any(|info| info.box_type() == ODRM); + + let ftyp = extract_box_as_async::<_, Ftyp>(reader, None, BoxPath::from([FTYP])) + .await? + .into_iter() + .next(); + let is_marlin_ipmp_movie = ftyp.as_ref().is_some_and(|entry| { + entry.major_brand == MARLIN_BRAND_MGSV + || entry.compatible_brands.contains(&MARLIN_BRAND_MGSV) + }); + let is_oma_dcf_atom_file = has_odrm + && ftyp.as_ref().is_some_and(|entry| { + entry.major_brand == ODCF || entry.compatible_brands.contains(&ODCF) + }); + + let protected_movie_layout = + if has_moov && has_mdat && !has_moof && !is_oma_dcf_atom_file && is_marlin_ipmp_movie { + Some(DecryptInputLayout::MarlinIpmpFile) + } else if has_moov && has_mdat && !has_moof && !is_oma_dcf_atom_file { + detect_non_fragmented_protected_movie_layout_from_async_reader(reader).await? + } else { + None + }; + + match ( + has_moov, + has_moof, + has_mdat, + is_oma_dcf_atom_file, + protected_movie_layout, + ) { + (false, false, _, true, _) => Ok(DecryptInputLayout::OmaDcfAtomFile), + (true, true, _, false, _) => Ok(DecryptInputLayout::FragmentedFile), + (true, false, true, false, Some(DecryptInputLayout::MarlinIpmpFile)) => { + Ok(DecryptInputLayout::MarlinIpmpFile) + } + (true, false, true, false, Some(DecryptInputLayout::OmaDcfProtectedMovieFile)) => { + Ok(DecryptInputLayout::OmaDcfProtectedMovieFile) + } + (true, false, true, false, Some(DecryptInputLayout::IaecProtectedMovieFile)) => { + Ok(DecryptInputLayout::IaecProtectedMovieFile) + } + (true, false, false, false, _) => Ok(DecryptInputLayout::InitSegment), + (false, true, _, false, _) => Ok(DecryptInputLayout::MediaSegment), + (false, false, false, false, _) => Err(DecryptError::InvalidInput { + reason: "expected a moov box, a moof box, both, or a root OMA DCF atom file" + .to_owned(), + }), + (_, _, _, true, _) => Err(DecryptError::InvalidInput { + reason: + "root OMA DCF atom files are expected to carry odrm without moov or moof at the top level" + .to_owned(), + }), + (true, false, true, false, None) => Err(DecryptError::InvalidInput { + reason: + "non-fragmented movie files are only supported for the current Marlin IPMP, OMA DCF, or IAEC protected layouts" + .to_owned(), + }), + _ => Err(DecryptError::InvalidInput { + reason: "input does not match one of the currently supported decrypt layouts" + .to_owned(), + }), + } +} + +fn decrypt_bytes_with_optional_progress( + input: &[u8], + options: &DecryptOptions, + progress: Option, +) -> Result, DecryptError> +where + F: FnMut(DecryptProgress), +{ + let mut reporter = ProgressReporter::new(progress); + let output = decrypt_input_bytes(input, options, &mut reporter)?; + reporter.report(DecryptProgressPhase::FinalizeOutput, 0, Some(1)); + reporter.report(DecryptProgressPhase::FinalizeOutput, 1, Some(1)); + Ok(output) +} + +fn decrypt_io_at_path(operation: &'static str, path: &Path, source: io::Error) -> DecryptError { + DecryptError::Io(io::Error::new( + source.kind(), + format!("failed to {operation} `{}`: {source}", path.display()), + )) +} + +fn decrypt_invalid_file_arguments(message: String) -> DecryptError { + DecryptError::Io(io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid decrypt file arguments: {message}"), + )) +} + +fn absolute_decrypt_path(path: &Path) -> Result { + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + Ok(std::env::current_dir()?.join(path)) +} + +fn validate_decrypt_file_paths( + input_path: &Path, + output_path: &Path, + fragments_info_path: Option<&Path>, +) -> Result<(), DecryptError> { + let input_absolute = absolute_decrypt_path(input_path)?; + let output_absolute = absolute_decrypt_path(output_path)?; + if input_absolute == output_absolute { + return Err(decrypt_invalid_file_arguments(format!( + "decrypt output path `{}` conflicts with input `{}`", + output_absolute.display(), + input_absolute.display() + ))); + } + if let Some(fragments_info_path) = fragments_info_path { + let fragments_absolute = absolute_decrypt_path(fragments_info_path)?; + if fragments_absolute == output_absolute { + return Err(decrypt_invalid_file_arguments(format!( + "decrypt output path `{}` conflicts with fragments-info path `{}`", + output_absolute.display(), + fragments_absolute.display() + ))); + } + } + Ok(()) +} + +pub(crate) fn decrypt_file_with_optional_progress_and_fragments_info_path( + input_path: &Path, + output_path: &Path, + fragments_info_path: Option<&Path>, + options: &DecryptOptions, + progress: Option, +) -> Result<(), DecryptError> +where + F: FnMut(DecryptProgress), +{ + validate_decrypt_file_paths(input_path, output_path, fragments_info_path)?; + let mut reporter = ProgressReporter::new(progress); + reporter.report(DecryptProgressPhase::OpenInput, 0, Some(1)); + let mut input = fs::File::open(input_path) + .map_err(|error| decrypt_io_at_path("open decrypt input", input_path, error))?; + reporter.report(DecryptProgressPhase::OpenInput, 1, Some(1)); + + let mut fragments_info = fragments_info_path + .map(|path| { + fs::File::open(path) + .map_err(|error| decrypt_io_at_path("open decrypt fragments-info", path, error)) + }) + .transpose()?; + + // Keep the externally visible progress phase order stable while the file-backed path moves + // onto the stream-first core internally. + let mut output = fs::File::create(output_path) + .map_err(|error| decrypt_io_at_path("create decrypt output", output_path, error))?; + if let Err(error) = decrypt_sync_stream_with_optional_progress( + &mut input, + &mut output, + fragments_info + .as_mut() + .map(|file| file as &mut dyn SyncReadSeek), + options, + &mut reporter, + ) { + drop(output); + let _ = fs::remove_file(output_path); + return Err(error); + } + + reporter.report(DecryptProgressPhase::OpenOutput, 0, Some(1)); + output.flush()?; + reporter.report(DecryptProgressPhase::OpenOutput, 1, Some(1)); + reporter.report(DecryptProgressPhase::FinalizeOutput, 0, Some(1)); + reporter.report(DecryptProgressPhase::FinalizeOutput, 1, Some(1)); + Ok(()) +} + +#[cfg(feature = "async")] +async fn decrypt_file_with_optional_progress_async( + input_path: &Path, + output_path: &Path, + options: &DecryptOptions, + progress: Option, +) -> Result<(), DecryptError> +where + F: FnMut(DecryptProgress) + Send, +{ + validate_decrypt_file_paths(input_path, output_path, None)?; + let mut reporter = ProgressReporter::new(progress); + reporter.report(DecryptProgressPhase::OpenInput, 0, Some(1)); + let mut input = tokio_fs::File::open(input_path) + .await + .map_err(|error| decrypt_io_at_path("open decrypt input", input_path, error))?; + reporter.report(DecryptProgressPhase::OpenInput, 1, Some(1)); + + let mut output = tokio_fs::File::create(output_path) + .await + .map_err(|error| decrypt_io_at_path("create decrypt output", output_path, error))?; + if let Err(error) = decrypt_async_stream_with_optional_progress( + &mut input, + &mut output, + None, + options, + &mut reporter, + ) + .await + { + drop(output); + let _ = tokio_fs::remove_file(output_path).await; + return Err(error); + } + reporter.report(DecryptProgressPhase::OpenOutput, 0, Some(1)); + output.flush().await?; + reporter.report(DecryptProgressPhase::OpenOutput, 1, Some(1)); + reporter.report(DecryptProgressPhase::FinalizeOutput, 0, Some(1)); + reporter.report(DecryptProgressPhase::FinalizeOutput, 1, Some(1)); + Ok(()) +} + +fn decrypt_input_bytes( + input: &[u8], + options: &DecryptOptions, + reporter: &mut ProgressReporter, +) -> Result, DecryptError> where F: FnMut(DecryptProgress), { @@ -1335,52 +4223,1953 @@ where reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); Ok(output) } - DecryptInputLayout::MediaSegment => { - reporter.report(DecryptProgressPhase::OpenFragmentsInfo, 0, Some(1)); - let fragments_info = options - .fragments_info_bytes() - .ok_or(DecryptError::MissingFragmentsInfo)?; - reporter.report(DecryptProgressPhase::OpenFragmentsInfo, 1, Some(1)); - reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); - let output = decrypt_common_encryption_media_segment_bytes( - fragments_info, + DecryptInputLayout::MediaSegment => { + reporter.report(DecryptProgressPhase::OpenFragmentsInfo, 0, Some(1)); + let fragments_info = options + .fragments_info_bytes() + .ok_or(DecryptError::MissingFragmentsInfo)?; + reporter.report(DecryptProgressPhase::OpenFragmentsInfo, 1, Some(1)); + reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); + let output = decrypt_common_encryption_media_segment_bytes( + fragments_info, + input, + options.keys(), + )?; + reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); + Ok(output) + } + DecryptInputLayout::FragmentedFile => { + reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); + let output = decrypt_common_encryption_file_bytes(input, options.keys())?; + reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); + Ok(output) + } + DecryptInputLayout::MarlinIpmpFile => { + reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); + let output = decrypt_marlin_movie_file_bytes(input, options.keys())?; + reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); + Ok(output) + } + DecryptInputLayout::OmaDcfProtectedMovieFile => { + reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); + let output = decrypt_oma_dcf_movie_file_bytes(input, options.keys())?; + reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); + Ok(output) + } + DecryptInputLayout::IaecProtectedMovieFile => { + reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); + let output = decrypt_iaec_movie_file_bytes(input, options.keys())?; + reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); + Ok(output) + } + DecryptInputLayout::OmaDcfAtomFile => { + reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); + let output = decrypt_oma_dcf_atom_file_bytes(input, options.keys())?; + reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); + Ok(output) + } + } +} + +fn build_common_encryption_stream_plan( + input: &mut R, + root_boxes: &[BoxInfo], + layout: DecryptInputLayout, + keys: &[DecryptionKey], + fragments_info_bytes: Option<&[u8]>, +) -> Result +where + R: Read + Seek, +{ + let init_bytes = match layout { + DecryptInputLayout::FragmentedFile => { + collect_common_encryption_init_moov_bytes_from_reader(input, root_boxes)? + } + DecryptInputLayout::MediaSegment => { + let bytes = fragments_info_bytes.ok_or(DecryptError::MissingFragmentsInfo)?; + extract_required_root_box_bytes(bytes, MOOV, "moov")? + } + _ => { + return Err(DecryptError::InvalidInput { + reason: "the stream-first Common Encryption core expects either a fragmented file or a standalone media segment".to_owned(), + }); + } + }; + let context = analyze_init_segment(&init_bytes)?; + let moov_replacement = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + .map(|info| { + rebuild_common_encryption_moov(&init_bytes, &context, keys) + .map(|bytes| (info.offset(), bytes)) + }) + .transpose()?; + let (moof_replacements, mdat_edits) = + build_common_encryption_fragment_replacements_from_stream( + input, root_boxes, &context, keys, + )?; + let extra_root_replacements = build_common_encryption_mfra_replacements_from_stream( + input, + root_boxes, + moov_replacement + .as_ref() + .map(|(offset, bytes)| (*offset, bytes.as_slice())), + &moof_replacements, + )?; + + Ok(CommonEncryptionStreamPlan { + root_boxes: root_boxes.to_vec(), + moov_replacement, + moof_replacements, + extra_root_replacements, + mdat_edits, + }) +} + +#[cfg(feature = "async")] +async fn build_common_encryption_stream_plan_async( + input: &mut R, + root_boxes: &[BoxInfo], + layout: DecryptInputLayout, + keys: &[DecryptionKey], + fragments_info_bytes: Option<&[u8]>, +) -> Result +where + R: AsyncReadSeek, +{ + let init_bytes = match layout { + DecryptInputLayout::FragmentedFile => { + collect_common_encryption_init_moov_bytes_from_async_reader(input, root_boxes).await? + } + DecryptInputLayout::MediaSegment => { + let bytes = fragments_info_bytes.ok_or(DecryptError::MissingFragmentsInfo)?; + extract_required_root_box_bytes(bytes, MOOV, "moov")? + } + _ => { + return Err(DecryptError::InvalidInput { + reason: "the stream-first Common Encryption core expects either a fragmented file or a standalone media segment".to_owned(), + }); + } + }; + let context = analyze_init_segment(&init_bytes)?; + let moov_replacement = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + .map(|info| { + rebuild_common_encryption_moov(&init_bytes, &context, keys) + .map(|bytes| (info.offset(), bytes)) + }) + .transpose()?; + let (moof_replacements, mdat_edits) = + build_common_encryption_fragment_replacements_from_async_stream( + input, root_boxes, &context, keys, + ) + .await?; + let extra_root_replacements = build_common_encryption_mfra_replacements_from_async_stream( + input, + root_boxes, + moov_replacement + .as_ref() + .map(|(offset, bytes)| (*offset, bytes.as_slice())), + &moof_replacements, + ) + .await?; + + Ok(CommonEncryptionStreamPlan { + root_boxes: root_boxes.to_vec(), + moov_replacement, + moof_replacements, + extra_root_replacements, + mdat_edits, + }) +} + +fn collect_common_encryption_init_segment_bytes_from_reader( + input: &mut R, + root_boxes: &[BoxInfo], +) -> Result, DecryptError> +where + R: Read + Seek, +{ + let mut init_bytes = Vec::new(); + for info in root_boxes.iter().copied() { + if matches!(info.box_type(), FTYP | MOOV) { + init_bytes.extend_from_slice(&read_box_bytes_from_reader(input, info)?); + } + } + Ok(init_bytes) +} + +#[cfg(feature = "async")] +async fn collect_common_encryption_init_segment_bytes_from_async_reader( + input: &mut R, + root_boxes: &[BoxInfo], +) -> Result, DecryptError> +where + R: AsyncReadSeek, +{ + let mut init_bytes = Vec::new(); + for info in root_boxes.iter().copied() { + if matches!(info.box_type(), FTYP | MOOV) { + init_bytes.extend_from_slice(&read_box_bytes_from_async_reader(input, info).await?); + } + } + Ok(init_bytes) +} + +fn build_common_encryption_fragment_replacements_from_stream( + input: &mut R, + root_boxes: &[BoxInfo], + context: &InitDecryptContext, + keys: &[DecryptionKey], +) -> Result +where + R: Read + Seek, +{ + let track_by_id = context + .tracks + .iter() + .map(|track| (track.track_id, track)) + .collect::>(); + let mdat_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MDAT) + .collect::>(); + let moof_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MOOF) + .collect::>(); + + let mut moof_replacements = BTreeMap::new(); + let mut mdat_edits = BTreeMap::>::new(); + for original_moof_info in moof_infos { + let moof_bytes = read_box_bytes_from_reader(input, original_moof_info)?; + let local_root_boxes = read_root_box_infos(&moof_bytes)?; + let local_moof_info = local_root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOF) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "expected one local moof box while planning stream-first Common Encryption rewrite".to_owned(), + })?; + let mut reader = Cursor::new(&moof_bytes); + let trafs = extract_box(&mut reader, None, BoxPath::from([MOOF, TRAF]))?; + let mut plans = Vec::new(); + for traf_info in trafs { + let mut reader = Cursor::new(&moof_bytes); + let tfhd = extract_single_as::<_, Tfhd>( + &mut reader, + Some(&traf_info), + BoxPath::from([TFHD]), + "tfhd", + )?; + + let mut reader = Cursor::new(&moof_bytes); + let truns = + extract_box_as::<_, Trun>(&mut reader, Some(&traf_info), BoxPath::from([TRUN]))?; + let mut reader = Cursor::new(&moof_bytes); + let trun_infos = extract_box(&mut reader, Some(&traf_info), BoxPath::from([TRUN]))?; + if truns.is_empty() || truns.len() != trun_infos.len() { + return Err(DecryptRewriteError::InvalidLayout { + reason: format!( + "track {} requires one or more aligned trun boxes in the stream-first Common Encryption path", + tfhd.track_id + ), + } + .into()); + } + + let mut remove_infos = Vec::new(); + if let Some(track) = track_by_id.get(&tfhd.track_id).copied() { + let sample_description_index = + resolve_fragment_sample_description_index(track, &tfhd)?; + if let Some(active) = + activate_track_sample_entry(track, sample_description_index, keys)? + { + let (senc, senc_info) = extract_fragment_sample_encryption_box( + &moof_bytes, + &traf_info, + &active.sample_entry.tenc, + )?; + let mut reader = Cursor::new(&moof_bytes); + let saiz = extract_optional_single_as::<_, Saiz>( + &mut reader, + Some(&traf_info), + BoxPath::from([SAIZ]), + "saiz", + )?; + let mut reader = Cursor::new(&moof_bytes); + let saio = extract_optional_single_as::<_, Saio>( + &mut reader, + Some(&traf_info), + BoxPath::from([SAIO]), + "saio", + )?; + let mut reader = Cursor::new(&moof_bytes); + let sgpd_entries = extract_box_as::<_, Sgpd>( + &mut reader, + Some(&traf_info), + BoxPath::from([SGPD]), + )?; + let mut reader = Cursor::new(&moof_bytes); + let sgpd_infos = + extract_box(&mut reader, Some(&traf_info), BoxPath::from([SGPD]))?; + let mut reader = Cursor::new(&moof_bytes); + let sbgp_entries = extract_box_as::<_, Sbgp>( + &mut reader, + Some(&traf_info), + BoxPath::from([SBGP]), + )?; + let mut reader = Cursor::new(&moof_bytes); + let sbgp_infos = + extract_box(&mut reader, Some(&traf_info), BoxPath::from([SBGP]))?; + + let sgpd = select_seig_sgpd(&sgpd_entries); + let sbgp = select_seig_sbgp(&sbgp_entries); + let resolved = resolve_sample_encryption( + &senc, + SampleEncryptionContext { + tenc: Some(&active.sample_entry.tenc), + sgpd, + sbgp, + saiz: saiz.as_ref(), + }, + ) + .map_err(DecryptRewriteError::from)?; + if active.sample_entry.scheme_type != PIFF { + append_common_encryption_sample_edits( + &mut mdat_edits, + CommonEncryptionFragmentQueueContext { + active: &active, + original_moof_offset: original_moof_info.offset(), + tfhd: &tfhd, + truns: &truns, + trun_infos: &trun_infos, + mdat_infos: &mdat_infos, + saio: saio.as_ref(), + resolved_samples: &resolved.samples, + }, + )?; + } + + if active.sample_entry.scheme_type == PIFF { + plans.push(TrafRewritePlan { + moof_info: local_moof_info, + traf_info, + tfhd_flags: tfhd.flags(), + trun_infos, + truns, + remove_infos, + }); + continue; + } + + remove_infos.push(senc_info); + if let Some(saiz_info) = + extract_optional_single_info_from_infos(&traf_info, SAIZ, &moof_bytes)? + { + remove_infos.push(saiz_info); + } + if let Some(saio_info) = + extract_optional_single_info_from_infos(&traf_info, SAIO, &moof_bytes)? + && saio.as_ref().is_none_or(|saio| { + saio.aux_info_type == FourCc::ANY + || saio.aux_info_type == active.sample_entry.scheme_type + }) + { + remove_infos.push(saio_info); + } + for (entry, info) in sbgp_entries.iter().zip(sbgp_infos.iter().copied()) { + if entry.grouping_type == u32::from_be_bytes(*b"seig") { + remove_infos.push(info); + } + } + for (entry, info) in sgpd_entries.iter().zip(sgpd_infos.iter().copied()) { + if entry.grouping_type == SEIG { + remove_infos.push(info); + } + } + } + } + + plans.push(TrafRewritePlan { + moof_info: local_moof_info, + traf_info, + tfhd_flags: tfhd.flags(), + trun_infos, + truns, + remove_infos, + }); + } + + let moof_plans = plans + .iter() + .filter(|plan| plan.moof_info.offset() == local_moof_info.offset()) + .collect::>(); + if moof_plans.is_empty() { + continue; + } + + let removed_in_moof = moof_plans + .iter() + .flat_map(|plan| plan.remove_infos.iter()) + .try_fold(0_u64, |acc, info| { + acc.checked_add(info.size()).ok_or_else(|| { + DecryptRewriteError::InvalidLayout { + reason: "removed fragment metadata size overflowed u64 in the stream-first Common Encryption path".to_owned(), + } + }) + })?; + + if removed_in_moof != 0 + && moof_plans.iter().any(|plan| { + plan.tfhd_flags & TFHD_BASE_DATA_OFFSET_PRESENT != 0 + || plan + .truns + .iter() + .any(|trun| trun.flags() & TRUN_DATA_OFFSET_PRESENT == 0) + }) + { + continue; + } + + let mut traf_edits = Vec::new(); + for plan in moof_plans { + let mut child_edits = Vec::new(); + for (trun_info, trun) in plan.trun_infos.iter().copied().zip(plan.truns.iter()) { + let mut patched_trun = trun.clone(); + if removed_in_moof != 0 { + let removed = i64::try_from(removed_in_moof).map_err(|_| { + DecryptRewriteError::InvalidLayout { + reason: "removed fragment metadata size does not fit in i64 in the stream-first Common Encryption path".to_owned(), + } + })?; + let patched = i64::from(trun.data_offset) + .checked_sub(removed) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "patched trun data offset overflowed i64 in the stream-first Common Encryption path".to_owned(), + })?; + patched_trun.data_offset = + i32::try_from(patched).map_err(|_| DecryptRewriteError::InvalidLayout { + reason: format!( + "patched trun data offset for traf at {} does not fit in i32", + plan.traf_info.offset() + ), + })?; + } + child_edits.push(DirectChildEdit { + child_info: trun_info, + replacement: Some(encode_box_with_children(&patched_trun, &[])?), + }); + } + child_edits.extend( + plan.remove_infos + .iter() + .copied() + .map(|info| DirectChildEdit { + child_info: info, + replacement: None, + }), + ); + + let rebuilt_traf = + rebuild_box_with_child_edits(&moof_bytes, plan.traf_info, &child_edits)?; + if rebuilt_traf != slice_box_bytes(&moof_bytes, plan.traf_info)? { + traf_edits.push(DirectChildEdit { + child_info: plan.traf_info, + replacement: Some(rebuilt_traf), + }); + } + } + + if !traf_edits.is_empty() { + moof_replacements.insert( + original_moof_info.offset(), + rebuild_box_with_child_edits(&moof_bytes, local_moof_info, &traf_edits)?, + ); + } + } + + Ok(( + moof_replacements, + queue_common_encryption_mdat_edits(mdat_edits), + )) +} + +#[cfg(feature = "async")] +async fn build_common_encryption_fragment_replacements_from_async_stream( + input: &mut R, + root_boxes: &[BoxInfo], + context: &InitDecryptContext, + keys: &[DecryptionKey], +) -> Result +where + R: AsyncReadSeek, +{ + let track_by_id = context + .tracks + .iter() + .map(|track| (track.track_id, track)) + .collect::>(); + let mdat_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MDAT) + .collect::>(); + let moof_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MOOF) + .collect::>(); + + let mut moof_replacements = BTreeMap::new(); + let mut mdat_edits = BTreeMap::>::new(); + for original_moof_info in moof_infos { + let moof_bytes = read_box_bytes_from_async_reader(input, original_moof_info).await?; + let local_root_boxes = read_root_box_infos(&moof_bytes)?; + let local_moof_info = local_root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOF) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "expected one local moof box while planning stream-first Common Encryption rewrite".to_owned(), + })?; + let mut reader = Cursor::new(&moof_bytes); + let trafs = extract_box(&mut reader, None, BoxPath::from([MOOF, TRAF]))?; + let mut plans = Vec::new(); + for traf_info in trafs { + let mut reader = Cursor::new(&moof_bytes); + let tfhd = extract_single_as::<_, Tfhd>( + &mut reader, + Some(&traf_info), + BoxPath::from([TFHD]), + "tfhd", + )?; + + let mut reader = Cursor::new(&moof_bytes); + let truns = + extract_box_as::<_, Trun>(&mut reader, Some(&traf_info), BoxPath::from([TRUN]))?; + let mut reader = Cursor::new(&moof_bytes); + let trun_infos = extract_box(&mut reader, Some(&traf_info), BoxPath::from([TRUN]))?; + if truns.is_empty() || truns.len() != trun_infos.len() { + return Err(DecryptRewriteError::InvalidLayout { + reason: format!( + "track {} requires one or more aligned trun boxes in the stream-first Common Encryption path", + tfhd.track_id + ), + } + .into()); + } + + let mut remove_infos = Vec::new(); + if let Some(track) = track_by_id.get(&tfhd.track_id).copied() { + let sample_description_index = + resolve_fragment_sample_description_index(track, &tfhd)?; + if let Some(active) = + activate_track_sample_entry(track, sample_description_index, keys)? + { + let (senc, senc_info) = extract_fragment_sample_encryption_box( + &moof_bytes, + &traf_info, + &active.sample_entry.tenc, + )?; + let mut reader = Cursor::new(&moof_bytes); + let saiz = extract_optional_single_as::<_, Saiz>( + &mut reader, + Some(&traf_info), + BoxPath::from([SAIZ]), + "saiz", + )?; + let mut reader = Cursor::new(&moof_bytes); + let saio = extract_optional_single_as::<_, Saio>( + &mut reader, + Some(&traf_info), + BoxPath::from([SAIO]), + "saio", + )?; + let mut reader = Cursor::new(&moof_bytes); + let sgpd_entries = extract_box_as::<_, Sgpd>( + &mut reader, + Some(&traf_info), + BoxPath::from([SGPD]), + )?; + let mut reader = Cursor::new(&moof_bytes); + let sgpd_infos = + extract_box(&mut reader, Some(&traf_info), BoxPath::from([SGPD]))?; + let mut reader = Cursor::new(&moof_bytes); + let sbgp_entries = extract_box_as::<_, Sbgp>( + &mut reader, + Some(&traf_info), + BoxPath::from([SBGP]), + )?; + let mut reader = Cursor::new(&moof_bytes); + let sbgp_infos = + extract_box(&mut reader, Some(&traf_info), BoxPath::from([SBGP]))?; + + let sgpd = select_seig_sgpd(&sgpd_entries); + let sbgp = select_seig_sbgp(&sbgp_entries); + let resolved = resolve_sample_encryption( + &senc, + SampleEncryptionContext { + tenc: Some(&active.sample_entry.tenc), + sgpd, + sbgp, + saiz: saiz.as_ref(), + }, + ) + .map_err(DecryptRewriteError::from)?; + if active.sample_entry.scheme_type != PIFF { + append_common_encryption_sample_edits( + &mut mdat_edits, + CommonEncryptionFragmentQueueContext { + active: &active, + original_moof_offset: original_moof_info.offset(), + tfhd: &tfhd, + truns: &truns, + trun_infos: &trun_infos, + mdat_infos: &mdat_infos, + saio: saio.as_ref(), + resolved_samples: &resolved.samples, + }, + )?; + } + + if active.sample_entry.scheme_type == PIFF { + plans.push(TrafRewritePlan { + moof_info: local_moof_info, + traf_info, + tfhd_flags: tfhd.flags(), + trun_infos, + truns, + remove_infos, + }); + continue; + } + + remove_infos.push(senc_info); + if let Some(saiz_info) = + extract_optional_single_info_from_infos(&traf_info, SAIZ, &moof_bytes)? + { + remove_infos.push(saiz_info); + } + if let Some(saio_info) = + extract_optional_single_info_from_infos(&traf_info, SAIO, &moof_bytes)? + && saio.as_ref().is_none_or(|saio| { + saio.aux_info_type == FourCc::ANY + || saio.aux_info_type == active.sample_entry.scheme_type + }) + { + remove_infos.push(saio_info); + } + for (entry, info) in sbgp_entries.iter().zip(sbgp_infos.iter().copied()) { + if entry.grouping_type == u32::from_be_bytes(*b"seig") { + remove_infos.push(info); + } + } + for (entry, info) in sgpd_entries.iter().zip(sgpd_infos.iter().copied()) { + if entry.grouping_type == SEIG { + remove_infos.push(info); + } + } + } + } + + plans.push(TrafRewritePlan { + moof_info: local_moof_info, + traf_info, + tfhd_flags: tfhd.flags(), + trun_infos, + truns, + remove_infos, + }); + } + + let moof_plans = plans + .iter() + .filter(|plan| plan.moof_info.offset() == local_moof_info.offset()) + .collect::>(); + if moof_plans.is_empty() { + continue; + } + + let removed_in_moof = moof_plans + .iter() + .flat_map(|plan| plan.remove_infos.iter()) + .try_fold(0_u64, |acc, info| { + acc.checked_add(info.size()).ok_or_else(|| { + DecryptRewriteError::InvalidLayout { + reason: "removed fragment metadata size overflowed u64 in the stream-first Common Encryption path".to_owned(), + } + }) + })?; + + if removed_in_moof != 0 + && moof_plans.iter().any(|plan| { + plan.tfhd_flags & TFHD_BASE_DATA_OFFSET_PRESENT != 0 + || plan + .truns + .iter() + .any(|trun| trun.flags() & TRUN_DATA_OFFSET_PRESENT == 0) + }) + { + continue; + } + + let mut traf_edits = Vec::new(); + for plan in moof_plans { + let mut child_edits = Vec::new(); + for (trun_info, trun) in plan.trun_infos.iter().copied().zip(plan.truns.iter()) { + let mut patched_trun = trun.clone(); + if removed_in_moof != 0 { + let removed = i64::try_from(removed_in_moof).map_err(|_| { + DecryptRewriteError::InvalidLayout { + reason: "removed fragment metadata size does not fit in i64 in the stream-first Common Encryption path".to_owned(), + } + })?; + let patched = i64::from(trun.data_offset) + .checked_sub(removed) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "patched trun data offset overflowed i64 in the stream-first Common Encryption path".to_owned(), + })?; + patched_trun.data_offset = + i32::try_from(patched).map_err(|_| DecryptRewriteError::InvalidLayout { + reason: format!( + "patched trun data offset for traf at {} does not fit in i32", + plan.traf_info.offset() + ), + })?; + } + child_edits.push(DirectChildEdit { + child_info: trun_info, + replacement: Some(encode_box_with_children(&patched_trun, &[])?), + }); + } + child_edits.extend( + plan.remove_infos + .iter() + .copied() + .map(|info| DirectChildEdit { + child_info: info, + replacement: None, + }), + ); + + let rebuilt_traf = + rebuild_box_with_child_edits(&moof_bytes, plan.traf_info, &child_edits)?; + if rebuilt_traf != slice_box_bytes(&moof_bytes, plan.traf_info)? { + traf_edits.push(DirectChildEdit { + child_info: plan.traf_info, + replacement: Some(rebuilt_traf), + }); + } + } + + if !traf_edits.is_empty() { + moof_replacements.insert( + original_moof_info.offset(), + rebuild_box_with_child_edits(&moof_bytes, local_moof_info, &traf_edits)?, + ); + } + } + + Ok(( + moof_replacements, + queue_common_encryption_mdat_edits(mdat_edits), + )) +} + +fn build_common_encryption_mfra_replacements_from_stream( + input: &mut R, + root_boxes: &[BoxInfo], + moov_replacement: Option<(u64, &[u8])>, + moof_replacements: &BTreeMap>, +) -> Result>, DecryptError> +where + R: Read + Seek, +{ + let mfra_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MFRA) + .collect::>(); + if mfra_infos.is_empty() { + return Ok(BTreeMap::new()); + } + + let rewritten_offsets = compute_rewritten_root_offsets_stream( + root_boxes, + moov_replacement, + moof_replacements, + &BTreeMap::new(), + )?; + let mut replacements = BTreeMap::new(); + for original_mfra_info in mfra_infos { + let mfra_bytes = read_box_bytes_from_reader(input, original_mfra_info)?; + let local_root_boxes = read_root_box_infos(&mfra_bytes)?; + let local_mfra_info = local_root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MFRA) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "expected one local mfra box while planning stream-first Common Encryption rewrite".to_owned(), + })?; + + let mut reader = Cursor::new(&mfra_bytes); + let tfra_boxes = + extract_box_as::<_, Tfra>(&mut reader, Some(&local_mfra_info), BoxPath::from([TFRA]))?; + let mut reader = Cursor::new(&mfra_bytes); + let tfra_infos = extract_box(&mut reader, Some(&local_mfra_info), BoxPath::from([TFRA]))?; + if tfra_boxes.len() != tfra_infos.len() { + return Err(DecryptRewriteError::InvalidLayout { + reason: "expected aligned tfra boxes inside mfra for the stream-first Common Encryption rewrite".to_owned(), + } + .into()); + } + + let mut child_edits = Vec::new(); + for (tfra_info, tfra_box) in tfra_infos.iter().copied().zip(tfra_boxes) { + let mut patched_tfra = tfra_box.clone(); + let version = patched_tfra.version(); + let mut changed = false; + for entry in &mut patched_tfra.entries { + let original_moof_offset = if version == 0 { + u64::from(entry.moof_offset_v0) + } else { + entry.moof_offset_v1 + }; + let Some(&rewritten_moof_offset) = rewritten_offsets.get(&original_moof_offset) + else { + continue; + }; + + if version == 0 { + let rewritten_moof_offset = + u32::try_from(rewritten_moof_offset).map_err(|_| { + DecryptRewriteError::InvalidLayout { + reason: "rewritten tfra moof offset does not fit in u32".to_owned(), + } + })?; + if entry.moof_offset_v0 != rewritten_moof_offset { + entry.moof_offset_v0 = rewritten_moof_offset; + changed = true; + } + } else if entry.moof_offset_v1 != rewritten_moof_offset { + entry.moof_offset_v1 = rewritten_moof_offset; + changed = true; + } + } + if changed { + child_edits.push(DirectChildEdit { + child_info: tfra_info, + replacement: Some(encode_box_with_children(&patched_tfra, &[])?), + }); + } + } + + let mut rebuilt_mfra = + rebuild_box_with_child_edits(&mfra_bytes, local_mfra_info, &child_edits)?; + if let Some(mfro_info) = + extract_optional_single_info_from_infos(&local_mfra_info, MFRO, &mfra_bytes)? + { + let mut reader = Cursor::new(&mfra_bytes); + let Some(mut mfro) = extract_optional_single_as::<_, Mfro>( + &mut reader, + Some(&local_mfra_info), + BoxPath::from([MFRO]), + "mfro", + )? + else { + return Err(DecryptRewriteError::InvalidLayout { + reason: "expected mfro to decode when its box info is present".to_owned(), + } + .into()); + }; + mfro.size = u32::try_from(rebuilt_mfra.len()).map_err(|_| { + DecryptRewriteError::InvalidLayout { + reason: "rewritten mfra size does not fit in u32".to_owned(), + } + })?; + let mfro_replacement = encode_box_with_children(&mfro, &[])?; + rebuilt_mfra = rebuild_box_with_child_edits( + &mfra_bytes, + local_mfra_info, + &[ + child_edits, + vec![DirectChildEdit { + child_info: mfro_info, + replacement: Some(mfro_replacement), + }], + ] + .concat(), + )?; + } + + if rebuilt_mfra != slice_box_bytes(&mfra_bytes, local_mfra_info)? { + replacements.insert(original_mfra_info.offset(), rebuilt_mfra); + } + } + + Ok(replacements) +} + +#[cfg(feature = "async")] +async fn build_common_encryption_mfra_replacements_from_async_stream( + input: &mut R, + root_boxes: &[BoxInfo], + moov_replacement: Option<(u64, &[u8])>, + moof_replacements: &BTreeMap>, +) -> Result>, DecryptError> +where + R: AsyncReadSeek, +{ + let mfra_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MFRA) + .collect::>(); + if mfra_infos.is_empty() { + return Ok(BTreeMap::new()); + } + + let rewritten_offsets = compute_rewritten_root_offsets_stream( + root_boxes, + moov_replacement, + moof_replacements, + &BTreeMap::new(), + )?; + let mut replacements = BTreeMap::new(); + for original_mfra_info in mfra_infos { + let mfra_bytes = read_box_bytes_from_async_reader(input, original_mfra_info).await?; + let local_root_boxes = read_root_box_infos(&mfra_bytes)?; + let local_mfra_info = local_root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MFRA) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "expected one local mfra box while planning stream-first Common Encryption rewrite".to_owned(), + })?; + + let mut reader = Cursor::new(&mfra_bytes); + let tfra_boxes = + extract_box_as::<_, Tfra>(&mut reader, Some(&local_mfra_info), BoxPath::from([TFRA]))?; + let mut reader = Cursor::new(&mfra_bytes); + let tfra_infos = extract_box(&mut reader, Some(&local_mfra_info), BoxPath::from([TFRA]))?; + if tfra_boxes.len() != tfra_infos.len() { + return Err(DecryptRewriteError::InvalidLayout { + reason: "expected aligned tfra boxes inside mfra for the stream-first Common Encryption rewrite".to_owned(), + } + .into()); + } + + let mut child_edits = Vec::new(); + for (tfra_info, tfra_box) in tfra_infos.iter().copied().zip(tfra_boxes) { + let mut patched_tfra = tfra_box.clone(); + let version = patched_tfra.version(); + let mut changed = false; + for entry in &mut patched_tfra.entries { + let original_moof_offset = if version == 0 { + u64::from(entry.moof_offset_v0) + } else { + entry.moof_offset_v1 + }; + let Some(&rewritten_moof_offset) = rewritten_offsets.get(&original_moof_offset) + else { + continue; + }; + + if version == 0 { + let rewritten_moof_offset = + u32::try_from(rewritten_moof_offset).map_err(|_| { + DecryptRewriteError::InvalidLayout { + reason: "rewritten tfra moof offset does not fit in u32".to_owned(), + } + })?; + if entry.moof_offset_v0 != rewritten_moof_offset { + entry.moof_offset_v0 = rewritten_moof_offset; + changed = true; + } + } else if entry.moof_offset_v1 != rewritten_moof_offset { + entry.moof_offset_v1 = rewritten_moof_offset; + changed = true; + } + } + if changed { + child_edits.push(DirectChildEdit { + child_info: tfra_info, + replacement: Some(encode_box_with_children(&patched_tfra, &[])?), + }); + } + } + + let mut rebuilt_mfra = + rebuild_box_with_child_edits(&mfra_bytes, local_mfra_info, &child_edits)?; + if let Some(mfro_info) = + extract_optional_single_info_from_infos(&local_mfra_info, MFRO, &mfra_bytes)? + { + let mut reader = Cursor::new(&mfra_bytes); + let Some(mut mfro) = extract_optional_single_as::<_, Mfro>( + &mut reader, + Some(&local_mfra_info), + BoxPath::from([MFRO]), + "mfro", + )? + else { + return Err(DecryptRewriteError::InvalidLayout { + reason: "expected mfro to decode when its box info is present".to_owned(), + } + .into()); + }; + mfro.size = u32::try_from(rebuilt_mfra.len()).map_err(|_| { + DecryptRewriteError::InvalidLayout { + reason: "rewritten mfra size does not fit in u32".to_owned(), + } + })?; + let mfro_replacement = encode_box_with_children(&mfro, &[])?; + rebuilt_mfra = rebuild_box_with_child_edits( + &mfra_bytes, + local_mfra_info, + &[ + child_edits, + vec![DirectChildEdit { + child_info: mfro_info, + replacement: Some(mfro_replacement), + }], + ] + .concat(), + )?; + } + + if rebuilt_mfra != slice_box_bytes(&mfra_bytes, local_mfra_info)? { + replacements.insert(original_mfra_info.offset(), rebuilt_mfra); + } + } + + Ok(replacements) +} + +fn compute_rewritten_root_offsets_stream( + root_boxes: &[BoxInfo], + moov_replacement: Option<(u64, &[u8])>, + moof_replacements: &BTreeMap>, + extra_root_replacements: &BTreeMap>, +) -> Result, DecryptRewriteError> { + let mut next_offset = 0_u64; + let mut offsets = BTreeMap::new(); + for info in root_boxes { + offsets.insert(info.offset(), next_offset); + next_offset = next_offset + .checked_add(rewritten_root_box_size_stream( + *info, + moov_replacement, + moof_replacements, + extra_root_replacements, + )?) + .ok_or_else(|| invalid_layout("rewritten root offset overflowed u64".to_owned()))?; + } + Ok(offsets) +} + +fn rewritten_root_box_size_stream( + info: BoxInfo, + moov_replacement: Option<(u64, &[u8])>, + moof_replacements: &BTreeMap>, + extra_root_replacements: &BTreeMap>, +) -> Result { + if let Some((moov_offset, replacement)) = moov_replacement + && info.offset() == moov_offset + { + return u64::try_from(replacement.len()) + .map_err(|_| invalid_layout("rebuilt moov size does not fit in u64".to_owned())); + } + if let Some(replacement) = extra_root_replacements.get(&info.offset()) { + return u64::try_from(replacement.len()).map_err(|_| { + invalid_layout("rewritten root replacement size does not fit in u64".to_owned()) + }); + } + if let Some(replacement) = moof_replacements.get(&info.offset()) { + return u64::try_from(replacement.len()) + .map_err(|_| invalid_layout("rebuilt moof size does not fit in u64".to_owned())); + } + Ok(info.size()) +} + +fn execute_common_encryption_stream_plan( + input: &mut R, + output: &mut W, + plan: &CommonEncryptionStreamPlan, +) -> Result<(), DecryptError> +where + R: Read + Seek, + W: Write + Seek, +{ + input.seek(SeekFrom::Start(0))?; + output.seek(SeekFrom::Start(0))?; + execute_common_encryption_stream_plan_non_seekable(input, output, plan) +} + +fn execute_common_encryption_stream_plan_non_seekable( + input: &mut R, + output: &mut W, + plan: &CommonEncryptionStreamPlan, +) -> Result<(), DecryptError> +where + R: Read, + W: Write, +{ + let mut cursor = 0_u64; + for root_info in &plan.root_boxes { + if root_info.offset() > cursor { + copy_exact_from_current(input, output, root_info.offset() - cursor)?; + cursor = root_info.offset(); + } + if let Some((offset, replacement)) = &plan.moov_replacement + && root_info.offset() == *offset + { + discard_exact_from_current(input, root_info.size())?; + output.write_all(replacement)?; + cursor = cursor.checked_add(root_info.size()).ok_or_else(|| { + DecryptRewriteError::InvalidLayout { + reason: "non-seekable root rewrite cursor overflowed u64".to_owned(), + } + })?; + continue; + } + if let Some(replacement) = plan.extra_root_replacements.get(&root_info.offset()) { + discard_exact_from_current(input, root_info.size())?; + output.write_all(replacement)?; + cursor = cursor.checked_add(root_info.size()).ok_or_else(|| { + DecryptRewriteError::InvalidLayout { + reason: "non-seekable root rewrite cursor overflowed u64".to_owned(), + } + })?; + continue; + } + if let Some(replacement) = plan.moof_replacements.get(&root_info.offset()) { + discard_exact_from_current(input, root_info.size())?; + output.write_all(replacement)?; + cursor = cursor.checked_add(root_info.size()).ok_or_else(|| { + DecryptRewriteError::InvalidLayout { + reason: "non-seekable root rewrite cursor overflowed u64".to_owned(), + } + })?; + continue; + } + if root_info.box_type() == MDAT { + stream_mdat_with_sample_edits_non_seekable( input, - options.keys(), + output, + *root_info, + plan.mdat_edits.get(&root_info.offset()), )?; - reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); - Ok(output) + cursor = cursor.checked_add(root_info.size()).ok_or_else(|| { + DecryptRewriteError::InvalidLayout { + reason: "non-seekable root rewrite cursor overflowed u64".to_owned(), + } + })?; + continue; } - DecryptInputLayout::FragmentedFile => { - reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); - let output = decrypt_common_encryption_file_bytes(input, options.keys())?; - reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); - Ok(output) + copy_exact_from_current(input, output, root_info.size())?; + cursor = cursor.checked_add(root_info.size()).ok_or_else(|| { + DecryptRewriteError::InvalidLayout { + reason: "non-seekable root rewrite cursor overflowed u64".to_owned(), + } + })?; + } + output.flush()?; + Ok(()) +} + +#[cfg(feature = "async")] +async fn execute_common_encryption_stream_plan_async( + input: &mut R, + output: &mut W, + plan: &CommonEncryptionStreamPlan, +) -> Result<(), DecryptError> +where + R: AsyncReadSeek, + W: AsyncWriteSeek, +{ + input.seek(SeekFrom::Start(0)).await?; + output.seek(SeekFrom::Start(0)).await?; + execute_common_encryption_stream_plan_non_seekable_async(input, output, plan).await +} + +#[cfg(feature = "async")] +async fn execute_common_encryption_stream_plan_non_seekable_async( + input: &mut R, + output: &mut W, + plan: &CommonEncryptionStreamPlan, +) -> Result<(), DecryptError> +where + R: AsyncReadForward, + W: AsyncWriteForward, +{ + let mut cursor = 0_u64; + for root_info in &plan.root_boxes { + if root_info.offset() > cursor { + copy_exact_from_current_async(input, output, root_info.offset() - cursor).await?; + cursor = root_info.offset(); + } + if let Some((offset, replacement)) = &plan.moov_replacement + && root_info.offset() == *offset + { + discard_exact_from_current_async(input, root_info.size()).await?; + output.write_all(replacement).await?; + cursor = cursor.checked_add(root_info.size()).ok_or_else(|| { + DecryptRewriteError::InvalidLayout { + reason: "non-seekable root rewrite cursor overflowed u64".to_owned(), + } + })?; + continue; } - DecryptInputLayout::MarlinIpmpFile => { - reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); - let output = decrypt_marlin_movie_file_bytes(input, options.keys())?; - reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); - Ok(output) + if let Some(replacement) = plan.extra_root_replacements.get(&root_info.offset()) { + discard_exact_from_current_async(input, root_info.size()).await?; + output.write_all(replacement).await?; + cursor = cursor.checked_add(root_info.size()).ok_or_else(|| { + DecryptRewriteError::InvalidLayout { + reason: "non-seekable root rewrite cursor overflowed u64".to_owned(), + } + })?; + continue; + } + if let Some(replacement) = plan.moof_replacements.get(&root_info.offset()) { + discard_exact_from_current_async(input, root_info.size()).await?; + output.write_all(replacement).await?; + cursor = cursor.checked_add(root_info.size()).ok_or_else(|| { + DecryptRewriteError::InvalidLayout { + reason: "non-seekable root rewrite cursor overflowed u64".to_owned(), + } + })?; + continue; + } + if root_info.box_type() == MDAT { + stream_mdat_with_sample_edits_non_seekable_async( + input, + output, + *root_info, + plan.mdat_edits.get(&root_info.offset()), + ) + .await?; + cursor = cursor.checked_add(root_info.size()).ok_or_else(|| { + DecryptRewriteError::InvalidLayout { + reason: "non-seekable root rewrite cursor overflowed u64".to_owned(), + } + })?; + continue; + } + copy_exact_from_current_async(input, output, root_info.size()).await?; + cursor = cursor.checked_add(root_info.size()).ok_or_else(|| { + DecryptRewriteError::InvalidLayout { + reason: "non-seekable root rewrite cursor overflowed u64".to_owned(), + } + })?; + } + output.flush().await?; + Ok(()) +} + +fn stream_mdat_with_sample_edits_non_seekable( + input: &mut R, + output: &mut W, + mdat_info: BoxInfo, + sample_edits: Option<&OrderedWorkQueue>, +) -> Result<(), DecryptError> +where + R: Read, + W: Write, +{ + copy_exact_from_current(input, output, mdat_info.header_size())?; + + let payload_start = mdat_info.offset() + mdat_info.header_size(); + let payload_end = mdat_info.offset() + mdat_info.size(); + let mut cursor = payload_start; + let mut raw_queue = RawOffsetQueue::new(payload_start); + let mut queue_buffer = vec![0_u8; 64 * 1024]; + let mut decryptor_reuse = DecryptorReuseCache::::new(); + let mut auxiliary_info_cache = None::>; + let mut parser = RangeQueueParser::new(sample_edits, payload_start, payload_end); + loop { + match parser + .next_stage() + .map_err(|error| DecryptRewriteError::InvalidLayout { + reason: error.to_string(), + })? { + RangeQueueParserStage::AuxiliaryInfo(staged_auxiliary_info_spans) => { + auxiliary_info_cache = Some(ActiveAuxiliaryInfoCache::stage( + sample_edits, + staged_auxiliary_info_spans, + )?); + } + RangeQueueParserStage::CopyRange { start, size } => { + if start != cursor { + return Err(DecryptRewriteError::InvalidLayout { + reason: "non-seekable Common Encryption parser lost mdat payload position" + .to_owned(), + } + .into()); + } + copy_range_from_progressive_queue( + input, + output, + &mut raw_queue, + &mut queue_buffer, + start, + size, + )?; + cursor = + start + .checked_add(size) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "non-seekable mdat cursor overflowed u64".to_owned(), + })?; + } + RangeQueueParserStage::WorkItem(edit) => { + if edit.absolute_offset != cursor { + return Err(DecryptRewriteError::InvalidLayout { + reason: "non-seekable Common Encryption parser lost sample alignment" + .to_owned(), + } + .into()); + } + let encrypted = read_range_from_progressive_queue( + input, + &mut raw_queue, + &mut queue_buffer, + edit.absolute_offset, + u64::from(edit.sample_size), + )?; + let resolved_sample = auxiliary_info_cache + .as_mut() + .map(|cache| cache.resolved_sample_for_edit(edit)) + .transpose()? + .unwrap_or_else(|| edit.sample.as_borrowed()); + let clear = decrypt_common_encryption_sample_edit_with_reuse( + edit, + &resolved_sample, + &encrypted, + &mut decryptor_reuse, + )?; + output.write_all(&clear)?; + cursor = cursor + .checked_add(u64::from(edit.sample_size)) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "non-seekable mdat cursor overflowed u64".to_owned(), + })?; + } + RangeQueueParserStage::Complete => break, + } + } + if let Some(auxiliary_info_cache) = auxiliary_info_cache { + auxiliary_info_cache.finish()?; + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn stream_mdat_with_sample_edits_non_seekable_async( + input: &mut R, + output: &mut W, + mdat_info: BoxInfo, + sample_edits: Option<&OrderedWorkQueue>, +) -> Result<(), DecryptError> +where + R: AsyncReadForward, + W: AsyncWriteForward, +{ + copy_exact_from_current_async(input, output, mdat_info.header_size()).await?; + + let payload_start = mdat_info.offset() + mdat_info.header_size(); + let payload_end = mdat_info.offset() + mdat_info.size(); + let mut cursor = payload_start; + let mut raw_queue = RawOffsetQueue::new(payload_start); + let mut queue_buffer = vec![0_u8; 64 * 1024]; + let mut decryptor_reuse = DecryptorReuseCache::::new(); + let mut auxiliary_info_cache = None::>; + let mut parser = RangeQueueParser::new(sample_edits, payload_start, payload_end); + loop { + match parser + .next_stage() + .map_err(|error| DecryptRewriteError::InvalidLayout { + reason: error.to_string(), + })? { + RangeQueueParserStage::AuxiliaryInfo(staged_auxiliary_info_spans) => { + auxiliary_info_cache = Some(ActiveAuxiliaryInfoCache::stage( + sample_edits, + staged_auxiliary_info_spans, + )?); + } + RangeQueueParserStage::CopyRange { start, size } => { + if start != cursor { + return Err(DecryptRewriteError::InvalidLayout { + reason: "non-seekable Common Encryption parser lost mdat payload position" + .to_owned(), + } + .into()); + } + copy_range_from_progressive_queue_async( + input, + output, + &mut raw_queue, + &mut queue_buffer, + start, + size, + ) + .await?; + cursor = + start + .checked_add(size) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "non-seekable mdat cursor overflowed u64".to_owned(), + })?; + } + RangeQueueParserStage::WorkItem(edit) => { + if edit.absolute_offset != cursor { + return Err(DecryptRewriteError::InvalidLayout { + reason: "non-seekable Common Encryption parser lost sample alignment" + .to_owned(), + } + .into()); + } + let encrypted = read_range_from_progressive_queue_async( + input, + &mut raw_queue, + &mut queue_buffer, + edit.absolute_offset, + u64::from(edit.sample_size), + ) + .await?; + let resolved_sample = auxiliary_info_cache + .as_mut() + .map(|cache| cache.resolved_sample_for_edit(edit)) + .transpose()? + .unwrap_or_else(|| edit.sample.as_borrowed()); + let clear = decrypt_common_encryption_sample_edit_with_reuse( + edit, + &resolved_sample, + &encrypted, + &mut decryptor_reuse, + )?; + output.write_all(&clear).await?; + cursor = cursor + .checked_add(u64::from(edit.sample_size)) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "non-seekable mdat cursor overflowed u64".to_owned(), + })?; + } + RangeQueueParserStage::Complete => break, + } + } + if let Some(auxiliary_info_cache) = auxiliary_info_cache { + auxiliary_info_cache.finish()?; + } + Ok(()) +} + +fn decrypt_common_encryption_sample_edit_with_reuse( + edit: &CommonEncryptionSampleEdit, + resolved_sample: &ResolvedSampleEncryptionSample<'_>, + encrypted_sample: &[u8], + decryptor_reuse: &mut DecryptorReuseCache, +) -> Result, DecryptError> { + if edit.scheme_type == PIFF { + return Ok(encrypted_sample.to_vec()); + } + let scheme = NativeCommonEncryptionScheme::from_scheme_type(edit.scheme_type).ok_or( + DecryptRewriteError::UnsupportedTrackSchemeType { + track_id: edit.track_id, + scheme_type: edit.scheme_type, + }, + )?; + let aes = decryptor_reuse.touch_or_insert_with( + DecryptorReuseKey::new(edit.scheme_type, edit.content_key), + || Aes128::new(&edit.content_key.into()), + ); + let clear = decrypt_common_encryption_sample_with_cipher( + scheme, + aes, + resolved_sample, + encrypted_sample, + ) + .map_err(DecryptRewriteError::from)?; + if clear.len() != encrypted_sample.len() { + return Err(DecryptRewriteError::InvalidLayout { + reason: format!( + "track {} changed Common Encryption sample size from {} to {} in the stream-first decrypt path", + edit.track_id, + encrypted_sample.len(), + clear.len() + ), + } + .into()); + } + Ok(clear) +} + +fn map_raw_offset_queue_error( + stage: &'static str, + start: u64, + size: u64, + error: RawOffsetQueueError, +) -> DecryptError { + DecryptRewriteError::InvalidLayout { + reason: format!("{stage} queue access failed for offset {start} size {size}: {error}"), + } + .into() +} + +fn fill_progressive_raw_queue( + input: &mut R, + raw_queue: &mut RawOffsetQueue, + target_end: u64, + buffer: &mut [u8], + stage: &'static str, +) -> Result<(), DecryptError> +where + R: Read, +{ + while raw_queue.tail() < target_end { + let remaining = target_end - raw_queue.tail(); + let chunk_len = usize::try_from(remaining.min(buffer.len() as u64)).unwrap(); + if let Err(error) = input.read_exact(&mut buffer[..chunk_len]) { + if error.kind() == io::ErrorKind::UnexpectedEof { + return Err(DecryptRewriteError::InvalidLayout { + reason: format!( + "{stage} ended before reaching progressive offset {target_end}; buffered tail is {}", + raw_queue.tail() + ), + } + .into()); + } + return Err(error.into()); } - DecryptInputLayout::OmaDcfProtectedMovieFile => { - reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); - let output = decrypt_oma_dcf_movie_file_bytes(input, options.keys())?; - reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); - Ok(output) + raw_queue.push_bytes(&buffer[..chunk_len]); + } + Ok(()) +} + +fn copy_range_from_progressive_queue( + input: &mut R, + output: &mut W, + raw_queue: &mut RawOffsetQueue, + buffer: &mut [u8], + start: u64, + size: u64, +) -> Result<(), DecryptError> +where + R: Read, + W: Write, +{ + let mut cursor = start; + let end = start + .checked_add(size) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "progressive copy range overflowed u64".to_owned(), + })?; + while cursor < end { + let chunk_end = end.min(cursor + buffer.len() as u64); + fill_progressive_raw_queue( + input, + raw_queue, + chunk_end, + buffer, + "progressive copy range", + )?; + raw_queue + .with_range_bytes(cursor, chunk_end - cursor, |bytes| output.write_all(bytes)) + .map_err(|error| { + map_raw_offset_queue_error( + "progressive copy range", + cursor, + chunk_end - cursor, + error, + ) + })??; + raw_queue.trim_to(chunk_end).map_err(|error| { + map_raw_offset_queue_error("progressive copy trim", chunk_end, 0, error) + })?; + cursor = chunk_end; + } + Ok(()) +} + +fn read_range_from_progressive_queue( + input: &mut R, + raw_queue: &mut RawOffsetQueue, + buffer: &mut [u8], + start: u64, + size: u64, +) -> Result, DecryptError> +where + R: Read, +{ + let end = start + .checked_add(size) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "progressive sample range overflowed u64".to_owned(), + })?; + fill_progressive_raw_queue(input, raw_queue, end, buffer, "progressive sample range")?; + let bytes = raw_queue + .with_range_bytes(start, size, <[u8]>::to_vec) + .map_err(|error| { + map_raw_offset_queue_error("progressive sample range", start, size, error) + })?; + raw_queue + .trim_to(end) + .map_err(|error| map_raw_offset_queue_error("progressive sample trim", end, 0, error))?; + Ok(bytes) +} + +fn copy_exact_range( + input: &mut R, + output: &mut W, + start: u64, + size: u64, +) -> Result<(), DecryptError> +where + R: Read + Seek, + W: Write, +{ + input.seek(SeekFrom::Start(start))?; + let mut remaining = size; + let mut buffer = [0_u8; 64 * 1024]; + while remaining != 0 { + let chunk_len = usize::try_from(remaining.min(buffer.len() as u64)).unwrap(); + input.read_exact(&mut buffer[..chunk_len])?; + output.write_all(&buffer[..chunk_len])?; + remaining -= u64::try_from(chunk_len).unwrap(); + } + Ok(()) +} + +fn copy_exact_from_current( + input: &mut R, + output: &mut W, + size: u64, +) -> Result<(), DecryptError> +where + R: Read, + W: Write, +{ + let mut remaining = size; + let mut buffer = [0_u8; 64 * 1024]; + while remaining != 0 { + let chunk_len = usize::try_from(remaining.min(buffer.len() as u64)).unwrap(); + input.read_exact(&mut buffer[..chunk_len])?; + output.write_all(&buffer[..chunk_len])?; + remaining -= u64::try_from(chunk_len).unwrap(); + } + Ok(()) +} + +fn discard_exact_from_current(input: &mut R, size: u64) -> Result<(), DecryptError> +where + R: Read, +{ + let mut remaining = size; + let mut buffer = [0_u8; 64 * 1024]; + while remaining != 0 { + let chunk_len = usize::try_from(remaining.min(buffer.len() as u64)).unwrap(); + input.read_exact(&mut buffer[..chunk_len])?; + remaining -= u64::try_from(chunk_len).unwrap(); + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn copy_exact_range_async( + input: &mut R, + output: &mut W, + start: u64, + size: u64, +) -> Result<(), DecryptError> +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + input.seek(SeekFrom::Start(start)).await?; + let mut remaining = size; + let mut buffer = vec![0_u8; 64 * 1024]; + while remaining != 0 { + let chunk_len = usize::try_from(remaining.min(buffer.len() as u64)).unwrap(); + input.read_exact(&mut buffer[..chunk_len]).await?; + output.write_all(&buffer[..chunk_len]).await?; + remaining -= u64::try_from(chunk_len).unwrap(); + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn copy_exact_from_current_async( + input: &mut R, + output: &mut W, + size: u64, +) -> Result<(), DecryptError> +where + R: AsyncReadForward, + W: AsyncWriteForward, +{ + let mut remaining = size; + let mut buffer = vec![0_u8; 64 * 1024]; + while remaining != 0 { + let chunk_len = usize::try_from(remaining.min(buffer.len() as u64)).unwrap(); + input.read_exact(&mut buffer[..chunk_len]).await?; + output.write_all(&buffer[..chunk_len]).await?; + remaining -= u64::try_from(chunk_len).unwrap(); + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn discard_exact_from_current_async(input: &mut R, size: u64) -> Result<(), DecryptError> +where + R: AsyncReadForward, +{ + let mut remaining = size; + let mut buffer = vec![0_u8; 64 * 1024]; + while remaining != 0 { + let chunk_len = usize::try_from(remaining.min(buffer.len() as u64)).unwrap(); + input.read_exact(&mut buffer[..chunk_len]).await?; + remaining -= u64::try_from(chunk_len).unwrap(); + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn fill_progressive_raw_queue_async( + input: &mut R, + raw_queue: &mut RawOffsetQueue, + target_end: u64, + buffer: &mut [u8], + stage: &'static str, +) -> Result<(), DecryptError> +where + R: AsyncReadForward, +{ + while raw_queue.tail() < target_end { + let remaining = target_end - raw_queue.tail(); + let chunk_len = usize::try_from(remaining.min(buffer.len() as u64)).unwrap(); + if let Err(error) = input.read_exact(&mut buffer[..chunk_len]).await { + if error.kind() == io::ErrorKind::UnexpectedEof { + return Err(DecryptRewriteError::InvalidLayout { + reason: format!( + "{stage} ended before reaching progressive offset {target_end}; buffered tail is {}", + raw_queue.tail() + ), + } + .into()); + } + return Err(error.into()); } - DecryptInputLayout::IaecProtectedMovieFile => { - reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); - let output = decrypt_iaec_movie_file_bytes(input, options.keys())?; - reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); - Ok(output) + raw_queue.push_bytes(&buffer[..chunk_len]); + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn copy_range_from_progressive_queue_async( + input: &mut R, + output: &mut W, + raw_queue: &mut RawOffsetQueue, + buffer: &mut [u8], + start: u64, + size: u64, +) -> Result<(), DecryptError> +where + R: AsyncReadForward, + W: AsyncWriteForward, +{ + let mut cursor = start; + let end = start + .checked_add(size) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "progressive copy range overflowed u64".to_owned(), + })?; + while cursor < end { + let chunk_end = end.min(cursor + buffer.len() as u64); + fill_progressive_raw_queue_async( + input, + raw_queue, + chunk_end, + buffer, + "progressive copy range", + ) + .await?; + let chunk = raw_queue + .with_range_bytes(cursor, chunk_end - cursor, <[u8]>::to_vec) + .map_err(|error| { + map_raw_offset_queue_error( + "progressive copy range", + cursor, + chunk_end - cursor, + error, + ) + })?; + output.write_all(&chunk).await?; + raw_queue.trim_to(chunk_end).map_err(|error| { + map_raw_offset_queue_error("progressive copy trim", chunk_end, 0, error) + })?; + cursor = chunk_end; + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn read_range_from_progressive_queue_async( + input: &mut R, + raw_queue: &mut RawOffsetQueue, + buffer: &mut [u8], + start: u64, + size: u64, +) -> Result, DecryptError> +where + R: AsyncReadForward, +{ + let end = start + .checked_add(size) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: "progressive sample range overflowed u64".to_owned(), + })?; + fill_progressive_raw_queue_async(input, raw_queue, end, buffer, "progressive sample range") + .await?; + let bytes = raw_queue + .with_range_bytes(start, size, <[u8]>::to_vec) + .map_err(|error| { + map_raw_offset_queue_error("progressive sample range", start, size, error) + })?; + raw_queue + .trim_to(end) + .map_err(|error| map_raw_offset_queue_error("progressive sample trim", end, 0, error))?; + Ok(bytes) +} + +fn with_range_from_seekable_queue( + input: &mut R, + raw_queue: &mut RawOffsetQueue, + buffer: &mut [u8], + start: u64, + size: u64, + stage: &'static str, + read: F, +) -> Result +where + R: Read + Seek, + F: FnOnce(&[u8]) -> Result, +{ + let end = start + .checked_add(size) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: format!("{stage} range overflowed u64"), + })?; + if start < raw_queue.head() || start > raw_queue.tail() { + input.seek(SeekFrom::Start(start))?; + *raw_queue = RawOffsetQueue::new(start); + } + fill_progressive_raw_queue(input, raw_queue, end, buffer, stage)?; + let value = raw_queue + .with_range_bytes(start, size, read) + .map_err(|error| map_raw_offset_queue_error(stage, start, size, error))??; + raw_queue + .trim_to(end) + .map_err(|error| map_raw_offset_queue_error(stage, end, 0, error))?; + Ok(value) +} + +#[cfg(feature = "async")] +async fn with_range_from_seekable_queue_async( + input: &mut R, + raw_queue: &mut RawOffsetQueue, + buffer: &mut [u8], + start: u64, + size: u64, + stage: &'static str, + read: F, +) -> Result +where + R: AsyncReadSeek, + F: FnOnce(&[u8]) -> Result, +{ + let end = start + .checked_add(size) + .ok_or_else(|| DecryptRewriteError::InvalidLayout { + reason: format!("{stage} range overflowed u64"), + })?; + if start < raw_queue.head() || start > raw_queue.tail() { + input.seek(SeekFrom::Start(start)).await?; + *raw_queue = RawOffsetQueue::new(start); + } + fill_progressive_raw_queue_async(input, raw_queue, end, buffer, stage).await?; + let value = raw_queue + .with_range_bytes(start, size, read) + .map_err(|error| map_raw_offset_queue_error(stage, start, size, error))??; + raw_queue + .trim_to(end) + .map_err(|error| map_raw_offset_queue_error(stage, end, 0, error))?; + Ok(value) +} + +fn processed_movie_sample_len_from_bytes( + process: &MovieSampleProcessKind, + sample_bytes: &[u8], +) -> Result { + match process { + MovieSampleProcessKind::Copy => Ok(sample_bytes.len() as u64), + _ => { + u64::try_from(process_movie_sample_bytes(process, sample_bytes)?.len()).map_err(|_| { + invalid_layout("rebuilt movie sample size does not fit in u64".to_owned()) + }) } - DecryptInputLayout::OmaDcfAtomFile => { - reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); - let output = decrypt_oma_dcf_atom_file_bytes(input, options.keys())?; - reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); - Ok(output) + } +} + +fn write_processed_movie_sample( + output: &mut W, + process: &MovieSampleProcessKind, + sample_bytes: &[u8], +) -> Result<(), DecryptError> +where + W: Write, +{ + match process { + MovieSampleProcessKind::Copy => output.write_all(sample_bytes)?, + _ => output.write_all(&process_movie_sample_bytes(process, sample_bytes)?)?, + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn write_processed_movie_sample_async( + output: &mut W, + process: &MovieSampleProcessKind, + sample_bytes: &[u8], +) -> Result<(), DecryptError> +where + W: AsyncWriteSeek, +{ + match process { + MovieSampleProcessKind::Copy => output.write_all(sample_bytes).await?, + _ => { + let clear = process_movie_sample_bytes(process, sample_bytes)?; + output.write_all(&clear).await?; } } + Ok(()) +} + +fn find_mdat_info_containing_sample( + mdat_infos: &[BoxInfo], + absolute_offset: u64, + sample_size: u32, +) -> Option { + let end = absolute_offset.checked_add(u64::from(sample_size))?; + mdat_infos.iter().copied().find(|info| { + let start = info.offset() + info.header_size(); + let finish = info.offset() + info.size(); + absolute_offset >= start && end <= finish + }) } fn classify_decrypt_input(input: &[u8]) -> Result { @@ -1464,6 +6253,37 @@ fn detect_non_fragmented_protected_movie_layout( Ok(None) } +fn detect_non_fragmented_protected_movie_layout_from_reader( + reader: &mut R, +) -> Result, DecryptError> +where + R: Read + Seek, +{ + if contains_oma_dcf_protected_sample_entries_from_reader(reader)? { + return Ok(Some(DecryptInputLayout::OmaDcfProtectedMovieFile)); + } + if contains_iaec_protected_sample_entries_from_reader(reader)? { + return Ok(Some(DecryptInputLayout::IaecProtectedMovieFile)); + } + Ok(None) +} + +#[cfg(feature = "async")] +async fn detect_non_fragmented_protected_movie_layout_from_async_reader( + reader: &mut R, +) -> Result, DecryptError> +where + R: AsyncReadSeek, +{ + if contains_oma_dcf_protected_sample_entries_from_async_reader(reader).await? { + return Ok(Some(DecryptInputLayout::OmaDcfProtectedMovieFile)); + } + if contains_iaec_protected_sample_entries_from_async_reader(reader).await? { + return Ok(Some(DecryptInputLayout::IaecProtectedMovieFile)); + } + Ok(None) +} + fn contains_oma_dcf_protected_sample_entries(input: &[u8]) -> Result { let mut reader = Cursor::new(input); let odkm_infos = extract_box( @@ -1481,17 +6301,88 @@ fn contains_oma_dcf_protected_sample_entries(input: &[u8]) -> Result( + &mut reader, + None, + BoxPath::from([MOOV, TRAK, MDIA, MINF, STBL, STSD, FourCc::ANY, SINF, SCHM]), + )?; + Ok(schm_boxes.iter().any(|entry| entry.scheme_type == ODKM)) +} + +fn contains_oma_dcf_protected_sample_entries_from_reader( + reader: &mut R, +) -> Result +where + R: Read + Seek, +{ + let odkm_infos = extract_box( + reader, + None, + BoxPath::from([ + MOOV, + TRAK, + MDIA, + MINF, + STBL, + STSD, + FourCc::ANY, + SINF, + SCHI, + ODKM, + ]), + )?; + if !odkm_infos.is_empty() { + return Ok(true); + } + + let schm_boxes = extract_box_as::<_, Schm>( + reader, + None, + BoxPath::from([MOOV, TRAK, MDIA, MINF, STBL, STSD, FourCc::ANY, SINF, SCHM]), + )?; + Ok(schm_boxes.iter().any(|entry| entry.scheme_type == ODKM)) +} + +#[cfg(feature = "async")] +async fn contains_oma_dcf_protected_sample_entries_from_async_reader( + reader: &mut R, +) -> Result +where + R: AsyncReadSeek, +{ + let odkm_infos = extract_box_async( + reader, + None, + BoxPath::from([ + MOOV, + TRAK, + MDIA, + MINF, + STBL, + STSD, + FourCc::ANY, + SINF, + SCHI, + ODKM, + ]), + ) + .await?; if !odkm_infos.is_empty() { return Ok(true); } - let mut reader = Cursor::new(input); - let schm_boxes = extract_box_as::<_, Schm>( - &mut reader, + let schm_boxes = extract_box_as_async::<_, Schm>( + reader, None, BoxPath::from([MOOV, TRAK, MDIA, MINF, STBL, STSD, FourCc::ANY, SINF, SCHM]), - )?; + ) + .await?; Ok(schm_boxes.iter().any(|entry| entry.scheme_type == ODKM)) } @@ -1505,6 +6396,36 @@ fn contains_iaec_protected_sample_entries(input: &[u8]) -> Result( + reader: &mut R, +) -> Result +where + R: Read + Seek, +{ + let scheme_boxes = extract_box_as::<_, Schm>( + reader, + None, + BoxPath::from([MOOV, TRAK, MDIA, MINF, STBL, STSD, FourCc::ANY, SINF, SCHM]), + )?; + Ok(scheme_boxes.iter().any(|entry| entry.scheme_type == IAEC)) +} + +#[cfg(feature = "async")] +async fn contains_iaec_protected_sample_entries_from_async_reader( + reader: &mut R, +) -> Result +where + R: AsyncReadSeek, +{ + let scheme_boxes = extract_box_as_async::<_, Schm>( + reader, + None, + BoxPath::from([MOOV, TRAK, MDIA, MINF, STBL, STSD, FourCc::ANY, SINF, SCHM]), + ) + .await?; + Ok(scheme_boxes.iter().any(|entry| entry.scheme_type == IAEC)) +} + fn decrypt_oma_dcf_atom_file_bytes( input: &[u8], keys: &[DecryptionKey], @@ -1890,7 +6811,7 @@ fn read_root_box_infos(input: &[u8]) -> Result, DecryptRewriteError let mut reader = Cursor::new(input); let mut root_boxes = Vec::new(); loop { - let position = reader.stream_position().map_err(|error| { + let position = std::io::Seek::stream_position(&mut reader).map_err(|error| { invalid_layout(format!("failed to read root-box position: {error}")) })?; if usize::try_from(position) @@ -2149,20 +7070,263 @@ fn build_marlin_moov_with_track_replacements( if let Some((offset, bytes)) = &plan.stsz_replacement { stbl_replacements.insert(*offset, Some(bytes.clone())); } - let trak_bytes = rebuild_track_with_stbl_replacements( + let trak_bytes = rebuild_track_with_stbl_replacements( + input, + plan.trak_info, + plan.mdia_info, + plan.minf_info, + plan.stbl_info, + &stbl_replacements, + )?; + moov_replacements.insert(plan.trak_info.offset(), Some(trak_bytes)); + } + rebuild_box_with_child_replacements(input, context.moov_info, &moov_replacements, None) +} + +fn analyze_marlin_movie_file(input: &[u8]) -> Result { + let root_boxes = read_root_box_infos(input)?; + let ftyp_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == FTYP) + .ok_or_else(|| { + invalid_layout("expected one root ftyp box in the Marlin movie file".to_owned()) + })?; + let moov_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + .ok_or_else(|| { + invalid_layout("expected one root moov box in the Marlin movie file".to_owned()) + })?; + let mdat_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MDAT) + .collect::>(); + if mdat_infos.is_empty() { + return Err(invalid_layout( + "expected at least one root mdat box in the Marlin movie file".to_owned(), + )); + } + + let mut reader = Cursor::new(input); + let ftyp = extract_single_as::<_, Ftyp>(&mut reader, None, BoxPath::from([FTYP]), "ftyp")?; + if ftyp.major_brand != MARLIN_BRAND_MGSV && !ftyp.compatible_brands.contains(&MARLIN_BRAND_MGSV) + { + return Err(invalid_layout( + "the current Marlin movie path expects the MGSV file-type brand".to_owned(), + )); + } + + let iods_info = { + let mut reader = Cursor::new(input); + extract_single_info(&mut reader, None, BoxPath::from([MOOV, IODS]), "iods")? + }; + let iods = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Iods>(&mut reader, None, BoxPath::from([MOOV, IODS]), "iods")? + }; + let initial_object_descriptor = iods.initial_object_descriptor().ok_or_else(|| { + invalid_layout( + "the current Marlin movie path expects one initial object descriptor in iods" + .to_owned(), + ) + })?; + let od_track_id = initial_object_descriptor + .sub_descriptors + .iter() + .find_map(|descriptor| descriptor.es_id_inc_descriptor()) + .map(|descriptor| descriptor.track_id) + .ok_or_else(|| { + invalid_layout( + "the current Marlin movie path expects iods to carry one ES-ID-increment descriptor" + .to_owned(), + ) + })?; + + let mut reader = Cursor::new(input); + let trak_infos = extract_box(&mut reader, None, BoxPath::from([MOOV, TRAK]))?; + let mut od_track_info = None; + for trak_info in &trak_infos { + let mut reader = Cursor::new(input); + let tkhd = extract_single_as::<_, Tkhd>( + &mut reader, + Some(trak_info), + BoxPath::from([TKHD]), + "trak/tkhd", + )?; + if tkhd.track_id == od_track_id { + od_track_info = Some(*trak_info); + break; + } + } + let od_track_info = od_track_info.ok_or_else(|| { + invalid_layout(format!( + "expected one Marlin object-descriptor track with track id {od_track_id}" + )) + })?; + + let mdat_ranges = media_data_ranges_from_infos(&mdat_infos); + let marlin_tracks = analyze_marlin_od_track(input, &od_track_info, &mdat_ranges)?; + if marlin_tracks.is_empty() { + return Err(invalid_layout( + "the current Marlin movie path found no carried track protection entries in the OD track" + .to_owned(), + )); + } + + let mut tracks = Vec::new(); + for trak_info in trak_infos { + if trak_info.offset() == od_track_info.offset() { + continue; + } + tracks.push(analyze_marlin_movie_track( + input, + &trak_info, + &marlin_tracks, + )?); + } + + Ok(MarlinMovieContext { + ftyp_info, + ftyp, + moov_info, + iods_info, + od_track_info, + mdat_infos, + tracks, + }) +} + +fn analyze_marlin_movie_metadata_from_reader( + input: &[u8], + original_reader: &mut R, + mdat_infos: Vec, +) -> Result +where + R: Read + Seek, +{ + let root_boxes = read_root_box_infos(input)?; + let ftyp_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == FTYP) + .ok_or_else(|| { + invalid_layout("expected one root ftyp box in the Marlin movie file".to_owned()) + })?; + let moov_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + .ok_or_else(|| { + invalid_layout("expected one root moov box in the Marlin movie file".to_owned()) + })?; + if mdat_infos.is_empty() { + return Err(invalid_layout( + "expected at least one root mdat box in the Marlin movie file".to_owned(), + )); + } + + let mut reader = Cursor::new(input); + let ftyp = extract_single_as::<_, Ftyp>(&mut reader, None, BoxPath::from([FTYP]), "ftyp")?; + if ftyp.major_brand != MARLIN_BRAND_MGSV && !ftyp.compatible_brands.contains(&MARLIN_BRAND_MGSV) + { + return Err(invalid_layout( + "the current Marlin movie path expects the MGSV file-type brand".to_owned(), + )); + } + + let iods_info = { + let mut reader = Cursor::new(input); + extract_single_info(&mut reader, None, BoxPath::from([MOOV, IODS]), "iods")? + }; + let iods = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Iods>(&mut reader, None, BoxPath::from([MOOV, IODS]), "iods")? + }; + let initial_object_descriptor = iods.initial_object_descriptor().ok_or_else(|| { + invalid_layout( + "the current Marlin movie path expects one initial object descriptor in iods" + .to_owned(), + ) + })?; + let od_track_id = initial_object_descriptor + .sub_descriptors + .iter() + .find_map(|descriptor| descriptor.es_id_inc_descriptor()) + .map(|descriptor| descriptor.track_id) + .ok_or_else(|| { + invalid_layout( + "the current Marlin movie path expects iods to carry one ES-ID-increment descriptor" + .to_owned(), + ) + })?; + + let mut reader = Cursor::new(input); + let trak_infos = extract_box(&mut reader, None, BoxPath::from([MOOV, TRAK]))?; + let mut od_track_info = None; + for trak_info in &trak_infos { + let mut reader = Cursor::new(input); + let tkhd = extract_single_as::<_, Tkhd>( + &mut reader, + Some(trak_info), + BoxPath::from([TKHD]), + "trak/tkhd", + )?; + if tkhd.track_id == od_track_id { + od_track_info = Some(*trak_info); + break; + } + } + let od_track_info = od_track_info.ok_or_else(|| { + invalid_layout(format!( + "expected one Marlin object-descriptor track with track id {od_track_id}" + )) + })?; + + let mdat_ranges = media_data_ranges_from_infos(&mdat_infos); + let marlin_tracks = + analyze_marlin_od_track_from_reader(input, &od_track_info, original_reader, &mdat_ranges)?; + if marlin_tracks.is_empty() { + return Err(invalid_layout( + "the current Marlin movie path found no carried track protection entries in the OD track" + .to_owned(), + )); + } + + let mut tracks = Vec::new(); + for trak_info in trak_infos { + if trak_info.offset() == od_track_info.offset() { + continue; + } + tracks.push(analyze_marlin_movie_track( input, - plan.trak_info, - plan.mdia_info, - plan.minf_info, - plan.stbl_info, - &stbl_replacements, - )?; - moov_replacements.insert(plan.trak_info.offset(), Some(trak_bytes)); + &trak_info, + &marlin_tracks, + )?); } - rebuild_box_with_child_replacements(input, context.moov_info, &moov_replacements, None) + + Ok(MarlinMovieContext { + ftyp_info, + ftyp, + moov_info, + iods_info, + od_track_info, + mdat_infos, + tracks, + }) } -fn analyze_marlin_movie_file(input: &[u8]) -> Result { +#[cfg(feature = "async")] +async fn analyze_marlin_movie_metadata_from_async_reader( + input: &[u8], + original_reader: &mut R, + mdat_infos: Vec, +) -> Result +where + R: AsyncReadSeek, +{ let root_boxes = read_root_box_infos(input)?; let ftyp_info = root_boxes .iter() @@ -2178,11 +7342,6 @@ fn analyze_marlin_movie_file(input: &[u8]) -> Result>(); if mdat_infos.is_empty() { return Err(invalid_layout( "expected at least one root mdat box in the Marlin movie file".to_owned(), @@ -2247,7 +7406,13 @@ fn analyze_marlin_movie_file(input: &[u8]) -> Result Some(update), + _ => None, + }) + .ok_or_else(|| { + invalid_layout( + "the current Marlin OD track path expects one object-descriptor-update command" + .to_owned(), + ) + })?; + let ipmp_update = commands + .iter() + .find_map(|command| match command { + DescriptorCommand::DescriptorUpdate(update) if update.tag == 0x05 => Some(update), + _ => None, + }) + .ok_or_else(|| { + invalid_layout( + "the current Marlin OD track path expects one IPMP-descriptor-update command" + .to_owned(), + ) + })?; + + let mut tracks = BTreeMap::new(); + for descriptor in &object_update.descriptors { + let Some(object_descriptor) = descriptor.object_descriptor() else { + continue; + }; + let Some(es_id_ref) = object_descriptor + .sub_descriptors + .iter() + .find_map(|descriptor| descriptor.es_id_ref_descriptor()) + else { + continue; + }; + let ref_index = usize::from(es_id_ref.ref_index); + if ref_index == 0 || ref_index > mpod.track_ids.len() { + continue; + } + let track_id = mpod.track_ids[ref_index - 1]; + let Some(pointer) = object_descriptor + .sub_descriptors + .iter() + .find_map(|descriptor| descriptor.ipmp_descriptor_pointer()) + else { + continue; + }; + let Some(ipmp_descriptor) = ipmp_update.descriptors.iter().find_map(|descriptor| { + let ipmp_descriptor = descriptor.ipmp_descriptor()?; + (ipmp_descriptor.ipmps_type == MARLIN_IPMPS_TYPE_MGSV + && ipmp_descriptor.descriptor_id == pointer.descriptor_id) + .then_some(ipmp_descriptor) + }) else { + continue; + }; + let Some(protection) = parse_marlin_track_protection(&ipmp_descriptor.data)? else { + continue; + }; + tracks.insert(track_id, protection); + } + + Ok(tracks) +} + +fn analyze_marlin_od_track_from_reader( + input: &[u8], + od_track_info: &BoxInfo, + original_reader: &mut R, + mdat_ranges: &[MediaDataRange], +) -> Result, DecryptRewriteError> +where + R: Read + Seek, +{ + let od_track_id = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Tkhd>( + &mut reader, + Some(od_track_info), + BoxPath::from([TKHD]), + "trak/tkhd", + )? + .track_id + }; + let mpod = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Mpod>( + &mut reader, + Some(od_track_info), + BoxPath::from([FourCc::from_bytes(*b"tref"), FourCc::from_bytes(*b"mpod")]), + "mpod", + )? + }; + if mpod.track_ids.is_empty() { + return Err(invalid_layout( + "the current Marlin OD track expects one or more mpod track references".to_owned(), + )); + } + + let stsz = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Stsz>( + &mut reader, + Some(od_track_info), + BoxPath::from([MDIA, MINF, STBL, STSZ]), + "stsz", + )? + }; + let od_sample_sizes = sample_sizes_from_stsz(&stsz)?; + if od_sample_sizes.is_empty() { + return Err(invalid_layout(format!( + "the current Marlin OD track path expects at least one OD sample but found {}", + od_sample_sizes.len() + ))); + } + + let stsc = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Stsc>( + &mut reader, + Some(od_track_info), + BoxPath::from([MDIA, MINF, STBL, STSC]), + "stsc", + )? + }; + let chunk_offsets = marlin_track_chunk_offsets(input, od_track_info, od_track_id)?; + let od_chunks = compute_track_chunks(od_track_id, &stsc, &chunk_offsets, &od_sample_sizes)?; + let (sample_offset, sample_size) = od_chunks + .iter() + .find_map(|chunk| chunk.sample_sizes.first().map(|size| (chunk.offset, *size))) + .ok_or_else(|| { + invalid_layout( + "the current Marlin OD track path could not resolve the first OD sample".to_owned(), + ) + })?; + + ensure_sample_range_in_mdat(mdat_ranges, od_track_id, 1, sample_offset, sample_size)?; + let mut raw_queue = RawOffsetQueue::new(0); + let mut queue_buffer = vec![0_u8; 64 * 1024]; + let sample_bytes = with_range_from_seekable_queue( + original_reader, + &mut raw_queue, + &mut queue_buffer, + sample_offset, + u64::from(sample_size), + "Marlin OD sample read", + |sample_bytes| Ok(sample_bytes.to_vec()), + ) + .map_err(|error| invalid_layout(error.to_string()))?; + let commands = parse_descriptor_commands(&sample_bytes).map_err(|error| { + invalid_layout(format!( + "failed to parse Marlin OD track command stream: {error}" + )) + })?; + extract_marlin_track_protections_from_commands(&commands, &mpod) +} + +#[cfg(feature = "async")] +async fn analyze_marlin_od_track_from_async_reader( + input: &[u8], + od_track_info: &BoxInfo, + original_reader: &mut R, + mdat_ranges: &[MediaDataRange], +) -> Result, DecryptRewriteError> +where + R: AsyncReadSeek, +{ + let od_track_id = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Tkhd>( + &mut reader, + Some(od_track_info), + BoxPath::from([TKHD]), + "trak/tkhd", + )? + .track_id + }; + let mpod = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Mpod>( + &mut reader, + Some(od_track_info), + BoxPath::from([FourCc::from_bytes(*b"tref"), FourCc::from_bytes(*b"mpod")]), + "mpod", + )? + }; + if mpod.track_ids.is_empty() { + return Err(invalid_layout( + "the current Marlin OD track expects one or more mpod track references".to_owned(), + )); + } + + let stsz = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Stsz>( + &mut reader, + Some(od_track_info), + BoxPath::from([MDIA, MINF, STBL, STSZ]), + "stsz", + )? + }; + let od_sample_sizes = sample_sizes_from_stsz(&stsz)?; + if od_sample_sizes.is_empty() { + return Err(invalid_layout(format!( + "the current Marlin OD track path expects at least one OD sample but found {}", + od_sample_sizes.len() + ))); + } + + let stsc = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Stsc>( + &mut reader, + Some(od_track_info), + BoxPath::from([MDIA, MINF, STBL, STSC]), + "stsc", + )? + }; + let chunk_offsets = marlin_track_chunk_offsets(input, od_track_info, od_track_id)?; + let od_chunks = compute_track_chunks(od_track_id, &stsc, &chunk_offsets, &od_sample_sizes)?; + let (sample_offset, sample_size) = od_chunks + .iter() + .find_map(|chunk| chunk.sample_sizes.first().map(|size| (chunk.offset, *size))) + .ok_or_else(|| { + invalid_layout( + "the current Marlin OD track path could not resolve the first OD sample".to_owned(), + ) + })?; + + ensure_sample_range_in_mdat(mdat_ranges, od_track_id, 1, sample_offset, sample_size)?; + let mut raw_queue = RawOffsetQueue::new(0); + let mut queue_buffer = vec![0_u8; 64 * 1024]; + let sample_bytes = with_range_from_seekable_queue_async( + original_reader, + &mut raw_queue, + &mut queue_buffer, + sample_offset, + u64::from(sample_size), + "Marlin OD sample read", + |sample_bytes| Ok(sample_bytes.to_vec()), + ) + .await + .map_err(|error| invalid_layout(error.to_string()))?; + let commands = parse_descriptor_commands(&sample_bytes).map_err(|error| { invalid_layout(format!( "failed to parse Marlin OD track command stream: {error}" )) })?; + extract_marlin_track_protections_from_commands(&commands, &mpod) +} + +fn marlin_track_chunk_offsets( + input: &[u8], + od_track_info: &BoxInfo, + od_track_id: u32, +) -> Result { + let mut reader = Cursor::new(input); + let stco = extract_optional_single_as::<_, Stco>( + &mut reader, + Some(od_track_info), + BoxPath::from([MDIA, MINF, STBL, STCO]), + "stco", + )?; + let mut reader = Cursor::new(input); + let co64 = extract_optional_single_as::<_, Co64>( + &mut reader, + Some(od_track_info), + BoxPath::from([MDIA, MINF, STBL, FourCc::from_bytes(*b"co64")]), + "co64", + )?; + let mut reader = Cursor::new(input); + let stco_info = extract_box( + &mut reader, + Some(od_track_info), + BoxPath::from([MDIA, MINF, STBL, STCO]), + )?; + let mut reader = Cursor::new(input); + let co64_info = extract_box( + &mut reader, + Some(od_track_info), + BoxPath::from([MDIA, MINF, STBL, FourCc::from_bytes(*b"co64")]), + )?; + match (stco, co64) { + (Some(_), Some(_)) => Err(invalid_layout( + "the current Marlin OD track path does not support both stco and co64".to_owned(), + )), + (Some(stco), None) => { + let [info] = stco_info.as_slice() else { + return Err(invalid_layout(format!( + "expected exactly one stco box for the Marlin OD track but found {}", + stco_info.len() + ))); + }; + Ok(ChunkOffsetBoxState::Stco { + info: *info, + box_value: stco, + }) + } + (None, Some(co64)) => { + let [info] = co64_info.as_slice() else { + return Err(invalid_layout(format!( + "expected exactly one co64 box for the Marlin OD track but found {}", + co64_info.len() + ))); + }; + Ok(ChunkOffsetBoxState::Co64 { + info: *info, + box_value: co64, + }) + } + (None, None) => Err(invalid_layout(format!( + "track {} is missing stco or co64 chunk offsets", + od_track_id + ))), + } +} + +fn extract_marlin_track_protections_from_commands( + commands: &[DescriptorCommand], + mpod: &Mpod, +) -> Result, DecryptRewriteError> { let object_update = commands .iter() .find_map(|command| match command { @@ -3318,6 +8805,58 @@ fn analyze_oma_dcf_movie_file( }) } +fn analyze_oma_dcf_movie_metadata( + input: &[u8], + mdat_infos: Vec, +) -> Result { + let root_boxes = read_root_box_infos(input)?; + let ftyp_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == FTYP); + let Some(moov_info) = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + else { + return Err(invalid_layout( + "expected one root moov box in the protected movie file".to_owned(), + )); + }; + if mdat_infos.is_empty() { + return Err(invalid_layout( + "expected at least one root mdat box in the protected movie file".to_owned(), + )); + } + + let mut reader = Cursor::new(input); + let traks = extract_box(&mut reader, None, BoxPath::from([MOOV, TRAK]))?; + let mut protected_tracks = Vec::new(); + let mut other_tracks = Vec::new(); + for trak_info in traks { + if let Some(track) = analyze_oma_dcf_movie_track(input, &trak_info)? { + protected_tracks.push(track); + } else { + other_tracks.push(analyze_movie_chunk_track(input, &trak_info)?); + } + } + + if protected_tracks.is_empty() { + return Err(invalid_layout( + "expected at least one OMA DCF protected sample-entry track in the movie file" + .to_owned(), + )); + } + + Ok(OmaProtectedMovieContext { + ftyp_info, + moov_info, + tracks: protected_tracks, + other_tracks, + mdat_infos, + }) +} + fn analyze_oma_dcf_movie_track( input: &[u8], trak_info: &BoxInfo, @@ -4295,6 +9834,57 @@ fn analyze_iaec_movie_file(input: &[u8]) -> Result, +) -> Result { + let root_boxes = read_root_box_infos(input)?; + let ftyp_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == FTYP); + let Some(moov_info) = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + else { + return Err(invalid_layout( + "expected one root moov box in the protected movie file".to_owned(), + )); + }; + if mdat_infos.is_empty() { + return Err(invalid_layout( + "expected at least one root mdat box in the protected movie file".to_owned(), + )); + } + + let mut reader = Cursor::new(input); + let traks = extract_box(&mut reader, None, BoxPath::from([MOOV, TRAK]))?; + let mut protected_tracks = Vec::new(); + let mut other_tracks = Vec::new(); + for trak_info in traks { + if let Some(track) = analyze_iaec_movie_track(input, &trak_info)? { + protected_tracks.push(track); + } else { + other_tracks.push(analyze_movie_chunk_track(input, &trak_info)?); + } + } + + if protected_tracks.is_empty() { + return Err(invalid_layout( + "expected at least one IAEC protected sample-entry track in the movie file".to_owned(), + )); + } + + Ok(IaecProtectedMovieContext { + ftyp_info, + moov_info, + tracks: protected_tracks, + other_tracks, + mdat_infos, + }) +} + fn analyze_iaec_movie_track( input: &[u8], trak_info: &BoxInfo, @@ -6432,7 +12022,7 @@ struct SampleTransformer { impl SampleTransformer { fn new( scheme: NativeCommonEncryptionScheme, - aes: Aes128, + aes: &Aes128, iv: [u8; 16], crypt_byte_block: u8, skip_byte_block: u8, @@ -6443,13 +12033,13 @@ impl SampleTransformer { pattern_stream_offset: 0, cipher: if scheme.uses_cbc() { SampleCipher::Cbc { - aes, + aes: aes.clone(), iv, chain_block: iv, } } else { SampleCipher::Ctr { - aes, + aes: aes.clone(), iv, encrypted_offset: 0, } @@ -6617,7 +12207,209 @@ fn compute_ctr_counter_block(iv: &[u8; 16], stream_offset: u64) -> Block #[cfg(test)] mod tests { use super::*; + #[path = "test_support.rs"] + mod test_support; + use crate::boxes::iso14496_12::StscEntry; + use std::cmp; + use std::fs; + use std::io::Cursor; + #[cfg(feature = "async")] + use std::pin::Pin; + #[cfg(feature = "async")] + use std::task::{Context, Poll}; + + use test_support::{ + build_oma_dcf_broader_movie_fixture, common_encryption_fragment_fixture, + common_encryption_multi_track_fixture, + }; + #[cfg(feature = "async")] + use tokio::io::{AsyncRead, AsyncSeek, ReadBuf}; + + struct LimitedReadCursor { + inner: Cursor>, + readable_limit: u64, + } + + impl LimitedReadCursor { + fn with_unreadable_trailing_box(init_bytes: &[u8]) -> Self { + let trailing_box = [ + 0_u8, 0, 0, 16, b'f', b'r', b'e', b'e', 0, 0, 0, 0, 0, 0, 0, 0, + ]; + let mut bytes = init_bytes.to_vec(); + bytes.extend_from_slice(&trailing_box); + Self { + inner: Cursor::new(bytes), + readable_limit: u64::try_from(init_bytes.len() + 8).unwrap(), + } + } + } + + impl Read for LimitedReadCursor { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let position = self.inner.position(); + if position >= self.readable_limit { + return Err(std::io::Error::other( + "attempted to read unreadable trailing fragments-info bytes", + )); + } + let remaining = usize::try_from(self.readable_limit - position).unwrap(); + let allowed = cmp::min(buf.len(), remaining); + std::io::Read::read(&mut self.inner, &mut buf[..allowed]) + } + } + + impl Seek for LimitedReadCursor { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + std::io::Seek::seek(&mut self.inner, pos) + } + } + + #[cfg(feature = "async")] + impl AsyncRead for LimitedReadCursor { + fn poll_read( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let position = self.inner.position(); + if position >= self.readable_limit && buf.remaining() > 0 { + return Poll::Ready(Err(std::io::Error::other( + "attempted to read unreadable trailing fragments-info bytes", + ))); + } + let remaining = usize::try_from(self.readable_limit.saturating_sub(position)).unwrap(); + let allowed = cmp::min(buf.remaining(), remaining); + let filled_before = buf.filled().len(); + let mut scratch = vec![0_u8; allowed]; + let read = std::io::Read::read(&mut self.inner, &mut scratch)?; + buf.put_slice(&scratch[..read]); + debug_assert_eq!(buf.filled().len(), filled_before + read); + Poll::Ready(Ok(())) + } + } + + #[cfg(feature = "async")] + impl AsyncSeek for LimitedReadCursor { + fn start_seek(mut self: Pin<&mut Self>, position: SeekFrom) -> std::io::Result<()> { + std::io::Seek::seek(&mut self.inner, position)?; + Ok(()) + } + + fn poll_complete( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(self.inner.position())) + } + } + + fn decrypt_stream_to_bytes( + input: &[u8], + options: &DecryptOptions, + fragments_info: Option<&[u8]>, + ) -> Result, DecryptError> { + let mut input_reader = Cursor::new(input); + let mut output_writer = Cursor::new(Vec::new()); + let mut fragments_info_reader = fragments_info.map(Cursor::new); + let fragments_info_reader = fragments_info_reader + .as_mut() + .map(|reader| reader as &mut dyn SyncReadSeek); + let mut reporter = ProgressReporter::new(None::); + decrypt_sync_stream_with_optional_progress( + &mut input_reader, + &mut output_writer, + fragments_info_reader, + options, + &mut reporter, + )?; + Ok(output_writer.into_inner()) + } + + fn decrypt_stream_to_bytes_with_fragments_reader( + input: &[u8], + options: &DecryptOptions, + fragments_info_reader: Option<&mut dyn SyncReadSeek>, + ) -> Result, DecryptError> { + let mut input_reader = Cursor::new(input); + let mut output_writer = Cursor::new(Vec::new()); + let mut reporter = ProgressReporter::new(None::); + decrypt_sync_stream_with_optional_progress( + &mut input_reader, + &mut output_writer, + fragments_info_reader, + options, + &mut reporter, + )?; + Ok(output_writer.into_inner()) + } + + fn build_common_encryption_plan_for_bytes( + input: &[u8], + options: &DecryptOptions, + fragments_info: Option<&[u8]>, + ) -> Result { + let mut reader = Cursor::new(input); + let root_boxes = read_root_box_infos_from_reader(&mut reader)?; + let layout = classify_decrypt_input_from_reader(&mut reader, &root_boxes)?; + build_common_encryption_stream_plan( + &mut reader, + &root_boxes, + layout, + options.keys(), + fragments_info, + ) + } + + fn decrypt_stream_to_bytes_non_seekable( + input: &[u8], + options: &DecryptOptions, + fragments_info: Option<&[u8]>, + ) -> Result, DecryptError> { + let plan = build_common_encryption_plan_for_bytes(input, options, fragments_info)?; + let mut input_reader = input; + let mut output = Vec::new(); + execute_common_encryption_stream_plan_non_seekable(&mut input_reader, &mut output, &plan)?; + Ok(output) + } + + #[cfg(feature = "async")] + async fn decrypt_stream_to_bytes_non_seekable_async( + input: &[u8], + options: &DecryptOptions, + fragments_info: Option<&[u8]>, + ) -> Result, DecryptError> { + let plan = build_common_encryption_plan_for_bytes(input, options, fragments_info)?; + let mut input_reader = Cursor::new(input.to_vec()); + let mut output = Cursor::new(Vec::new()); + execute_common_encryption_stream_plan_non_seekable_async( + &mut input_reader, + &mut output, + &plan, + ) + .await?; + Ok(output.into_inner()) + } + + #[cfg(feature = "async")] + async fn decrypt_stream_to_bytes_with_fragments_reader_async( + input: &[u8], + options: &DecryptOptions, + fragments_info_reader: Option<&mut dyn AsyncReadSeek>, + ) -> Result, DecryptError> { + let mut input_reader = Cursor::new(input.to_vec()); + let mut output_writer = Cursor::new(Vec::new()); + let mut reporter = ProgressReporter::new(None::); + decrypt_async_stream_with_optional_progress( + &mut input_reader, + &mut output_writer, + fragments_info_reader, + options, + &mut reporter, + ) + .await?; + Ok(output_writer.into_inner()) + } #[test] fn compute_track_chunks_preserves_non_default_sample_description_indices() { @@ -6680,4 +12472,365 @@ mod tests { "unexpected error: {error}" ); } + + #[test] + fn sync_stream_core_decrypts_retained_common_encryption_file() { + let fixture = common_encryption_multi_track_fixture(); + let encrypted = fs::read(&fixture.encrypted_path).unwrap(); + let expected = fs::read(&fixture.decrypted_path).unwrap(); + + let output = decrypt_stream_to_bytes( + &encrypted, + &DecryptOptions::new() + .with_key(fixture.keys[0]) + .with_key(fixture.keys[1]), + None, + ) + .unwrap(); + + assert_eq!(output, expected); + } + + #[test] + fn sync_stream_core_decrypts_retained_common_encryption_file_from_non_seekable_input() { + let fixture = common_encryption_multi_track_fixture(); + let encrypted = fs::read(&fixture.encrypted_path).unwrap(); + let expected = fs::read(&fixture.decrypted_path).unwrap(); + + let output = decrypt_stream_to_bytes_non_seekable( + &encrypted, + &DecryptOptions::new() + .with_key(fixture.keys[0]) + .with_key(fixture.keys[1]), + None, + ) + .unwrap(); + + assert_eq!(output, expected); + } + + #[test] + fn sync_stream_core_decrypts_retained_standalone_fragment_with_seekable_fragments_info() { + let fixture = common_encryption_fragment_fixture("cenc-single", "video"); + let encrypted = fs::read(&fixture.encrypted_segment_path).unwrap(); + let expected = fs::read(&fixture.clear_segment_path).unwrap(); + let fragments_info = fs::read(&fixture.fragments_info_path).unwrap(); + + let output = decrypt_stream_to_bytes( + &encrypted, + &DecryptOptions::new().with_key(fixture.keys[0]), + Some(&fragments_info), + ) + .unwrap(); + + assert_eq!(output, expected); + } + + #[test] + fn sync_stream_core_only_reads_init_boxes_from_seekable_fragments_info_reader() { + let fixture = common_encryption_fragment_fixture("cenc-single", "video"); + let encrypted = fs::read(&fixture.encrypted_segment_path).unwrap(); + let expected = fs::read(&fixture.clear_segment_path).unwrap(); + let fragments_info = fs::read(&fixture.fragments_info_path).unwrap(); + let mut fragments_reader = LimitedReadCursor::with_unreadable_trailing_box(&fragments_info); + + let output = decrypt_stream_to_bytes_with_fragments_reader( + &encrypted, + &DecryptOptions::new().with_key(fixture.keys[0]), + Some(&mut fragments_reader), + ) + .unwrap(); + + assert_eq!(output, expected); + } + + #[cfg(feature = "async")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn async_stream_core_decrypts_retained_standalone_fragment_from_non_seekable_input() { + let fixture = common_encryption_fragment_fixture("cenc-single", "video"); + let encrypted = fs::read(&fixture.encrypted_segment_path).unwrap(); + let expected = fs::read(&fixture.clear_segment_path).unwrap(); + let fragments_info = fs::read(&fixture.fragments_info_path).unwrap(); + + let output = decrypt_stream_to_bytes_non_seekable_async( + &encrypted, + &DecryptOptions::new().with_key(fixture.keys[0]), + Some(&fragments_info), + ) + .await + .unwrap(); + + assert_eq!(output, expected); + } + + #[cfg(feature = "async")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn async_stream_core_only_reads_init_boxes_from_seekable_fragments_info_reader() { + let fixture = common_encryption_fragment_fixture("cenc-single", "video"); + let encrypted = fs::read(&fixture.encrypted_segment_path).unwrap(); + let expected = fs::read(&fixture.clear_segment_path).unwrap(); + let fragments_info = fs::read(&fixture.fragments_info_path).unwrap(); + let mut fragments_reader = LimitedReadCursor::with_unreadable_trailing_box(&fragments_info); + + let output = decrypt_stream_to_bytes_with_fragments_reader_async( + &encrypted, + &DecryptOptions::new().with_key(fixture.keys[0]), + Some(&mut fragments_reader), + ) + .await + .unwrap(); + + assert_eq!(output, expected); + } + + #[test] + fn sync_stream_core_keeps_broader_protected_movie_layout_parity() { + let fixture = build_oma_dcf_broader_movie_fixture(); + + let output = decrypt_stream_to_bytes( + &fixture.encrypted, + &DecryptOptions::new().with_key(fixture.keys[0]), + None, + ) + .unwrap(); + + assert_eq!(output, fixture.decrypted); + } + + #[test] + fn compute_fragment_auxiliary_info_spans_chains_single_saio_entry_across_runs() { + let mut saio = Saio::default(); + saio.entry_count = 1; + saio.offset_v0 = vec![24]; + + let mut first_trun = Trun::default(); + first_trun.sample_count = 2; + let mut second_trun = Trun::default(); + second_trun.sample_count = 1; + + let samples = [ + ResolvedSampleEncryptionSample { + sample_index: 1, + metadata_source: ResolvedSampleEncryptionSource::TrackEncryptionBox, + is_protected: true, + crypt_byte_block: 0, + skip_byte_block: 0, + per_sample_iv_size: Some(8), + initialization_vector: &[], + constant_iv: None, + kid: [0; 16], + subsamples: &[], + auxiliary_info_size: 10, + }, + ResolvedSampleEncryptionSample { + sample_index: 2, + metadata_source: ResolvedSampleEncryptionSource::TrackEncryptionBox, + is_protected: true, + crypt_byte_block: 0, + skip_byte_block: 0, + per_sample_iv_size: Some(8), + initialization_vector: &[], + constant_iv: None, + kid: [0; 16], + subsamples: &[], + auxiliary_info_size: 6, + }, + ResolvedSampleEncryptionSample { + sample_index: 3, + metadata_source: ResolvedSampleEncryptionSource::TrackEncryptionBox, + is_protected: true, + crypt_byte_block: 0, + skip_byte_block: 0, + per_sample_iv_size: Some(8), + initialization_vector: &[], + constant_iv: None, + kid: [0; 16], + subsamples: &[], + auxiliary_info_size: 12, + }, + ]; + + let spans = compute_fragment_auxiliary_info_spans( + 100, + Some(&saio), + &[first_trun, second_trun], + &samples, + ) + .unwrap(); + + assert_eq!( + spans, + vec![ + Some(QueueAuxiliaryInfoSpan { + absolute_offset: 124, + size: 16, + }), + Some(QueueAuxiliaryInfoSpan { + absolute_offset: 140, + size: 12, + }), + ] + ); + } + + #[test] + fn queue_common_encryption_mdat_edits_use_earliest_relevant_offsets_without_reordering_sample_writes() + { + let edits = vec![ + CommonEncryptionSampleEdit { + absolute_offset: 400, + sample_size: 16, + track_id: 1, + scheme_type: CENC, + content_key: [0x11; 16], + auxiliary_info_span: Some(QueueAuxiliaryInfoSpan { + absolute_offset: 300, + size: 24, + }), + sample: OwnedResolvedSampleEncryptionSample { + sample_index: 1, + metadata_source: ResolvedSampleEncryptionSource::TrackEncryptionBox, + is_protected: true, + crypt_byte_block: 0, + skip_byte_block: 0, + per_sample_iv_size: Some(8), + initialization_vector: Vec::new(), + constant_iv: None, + kid: [0; 16], + subsamples: Vec::new(), + auxiliary_info_size: 16, + }, + }, + CommonEncryptionSampleEdit { + absolute_offset: 350, + sample_size: 16, + track_id: 1, + scheme_type: CENC, + content_key: [0x11; 16], + auxiliary_info_span: Some(QueueAuxiliaryInfoSpan { + absolute_offset: 600, + size: 24, + }), + sample: OwnedResolvedSampleEncryptionSample { + sample_index: 2, + metadata_source: ResolvedSampleEncryptionSource::TrackEncryptionBox, + is_protected: true, + crypt_byte_block: 0, + skip_byte_block: 0, + per_sample_iv_size: Some(8), + initialization_vector: Vec::new(), + constant_iv: None, + kid: [0; 16], + subsamples: Vec::new(), + auxiliary_info_size: 8, + }, + }, + ]; + + let queued = queue_common_encryption_mdat_edits(BTreeMap::from([(200_u64, edits)])); + let queue = queued.get(&200).unwrap(); + + assert_eq!( + queue + .items() + .iter() + .map(|edit| edit.absolute_offset) + .collect::>(), + vec![400, 350] + ); + assert_eq!( + queue.auxiliary_info_spans(), + &[ + QueueAuxiliaryInfoSpan { + absolute_offset: 300, + size: 24, + }, + QueueAuxiliaryInfoSpan { + absolute_offset: 600, + size: 24, + }, + ] + ); + + let payload_start = 320; + let payload_end = 420; + let mut parser = RangeQueueParser::new(Some(queue), payload_start, payload_end); + let mut work_item_offsets = Vec::new(); + loop { + match parser.next_stage().unwrap() { + RangeQueueParserStage::AuxiliaryInfo(..) + | RangeQueueParserStage::CopyRange { .. } => {} + RangeQueueParserStage::WorkItem(item) => { + work_item_offsets.push(item.absolute_offset) + } + RangeQueueParserStage::Complete => break, + } + } + assert_eq!(work_item_offsets, vec![350, 400]); + } + + #[test] + fn auxiliary_info_stage_builds_live_sample_state_cache_for_fragmented_decrypt() { + let edits = vec![ + CommonEncryptionSampleEdit { + absolute_offset: 400, + sample_size: 16, + track_id: 7, + scheme_type: CENC, + content_key: [0x11; 16], + auxiliary_info_span: Some(QueueAuxiliaryInfoSpan { + absolute_offset: 300, + size: 24, + }), + sample: OwnedResolvedSampleEncryptionSample { + sample_index: 1, + metadata_source: ResolvedSampleEncryptionSource::TrackEncryptionBox, + is_protected: true, + crypt_byte_block: 0, + skip_byte_block: 0, + per_sample_iv_size: Some(8), + initialization_vector: Vec::new(), + constant_iv: None, + kid: [0; 16], + subsamples: Vec::new(), + auxiliary_info_size: 16, + }, + }, + CommonEncryptionSampleEdit { + absolute_offset: 500, + sample_size: 16, + track_id: 7, + scheme_type: CENC, + content_key: [0x11; 16], + auxiliary_info_span: Some(QueueAuxiliaryInfoSpan { + absolute_offset: 300, + size: 24, + }), + sample: OwnedResolvedSampleEncryptionSample { + sample_index: 2, + metadata_source: ResolvedSampleEncryptionSource::TrackEncryptionBox, + is_protected: true, + crypt_byte_block: 0, + skip_byte_block: 0, + per_sample_iv_size: Some(8), + initialization_vector: Vec::new(), + constant_iv: None, + kid: [0; 16], + subsamples: Vec::new(), + auxiliary_info_size: 8, + }, + }, + ]; + + let queue = OrderedWorkQueue::new(edits); + let mut cache = + ActiveAuxiliaryInfoCache::stage(Some(&queue), queue.auxiliary_info_spans()).unwrap(); + let edits = queue.items(); + + let first = cache.resolved_sample_for_edit(&edits[0]).unwrap(); + assert_eq!(first.sample_index, 1); + let second = cache.resolved_sample_for_edit(&edits[1]).unwrap(); + assert_eq!(second.sample_index, 2); + cache.finish().unwrap(); + } } diff --git a/src/decrypt/tests/test_support.rs b/src/decrypt/tests/test_support.rs new file mode 100644 index 0000000..297a68e --- /dev/null +++ b/src/decrypt/tests/test_support.rs @@ -0,0 +1,4 @@ +#[path = "../../../tests/support/mod.rs"] +mod inner; + +pub use inner::*; diff --git a/src/extract.rs b/src/extract.rs index a491a7f..07d70aa 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -7,7 +7,7 @@ use std::any::type_name; use std::error::Error; use std::fmt; -use std::io::{self, Cursor, Read, Seek}; +use std::io::{self, Cursor, Read, Seek, Write}; #[cfg(feature = "async")] use std::sync::{Arc, Mutex}; @@ -16,7 +16,11 @@ use crate::FourCc; #[cfg(feature = "async")] use crate::async_io::AsyncReadSeek; use crate::boxes::{BoxRegistry, default_registry}; -use crate::codec::{CodecBox, CodecError, DynCodecBox, unmarshal_any_with_context}; +use crate::codec::{ + CodecBox, CodecError, DynCodecBox, read_exact_vec_untrusted, unmarshal_any_with_context, +}; +#[cfg(feature = "async")] +use crate::codec::{read_exact_vec_untrusted_async, unmarshal_any_with_context_async}; use crate::header::HeaderError; #[cfg(feature = "async")] use crate::walk::{ @@ -28,6 +32,8 @@ use crate::walk::{ walk_structure_with_registry, }; #[cfg(feature = "async")] +use tokio::io::AsyncWrite; +#[cfg(feature = "async")] use tokio::io::{AsyncReadExt, AsyncSeekExt}; /// Header metadata paired with a decoded runtime box payload. @@ -214,6 +220,84 @@ where extract_boxes_payload_bytes_with_registry(reader, parent, paths, ®istry) } +/// Copies every box that matches `path` to `writer` as exact serialized bytes, including the +/// original box headers, and returns the byte length of each copied match. +pub fn copy_box_bytes_to( + reader: &mut R, + parent: Option<&BoxInfo>, + path: BoxPath, + writer: &mut W, +) -> Result, ExtractError> +where + R: Read + Seek, + W: Write, +{ + let paths = [path]; + copy_boxes_bytes_to(reader, parent, &paths, writer) +} + +/// Copies every box that matches any path in `paths` to `writer` as exact serialized bytes, +/// including the original box headers, and returns the byte length of each copied match. +pub fn copy_boxes_bytes_to( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], + writer: &mut W, +) -> Result, ExtractError> +where + R: Read + Seek, + W: Write, +{ + let registry = default_registry(); + copy_matched_bytes_to( + reader, + parent, + paths, + ®istry, + ExtractedByteRange::FullBox, + writer, + ) +} + +/// Copies every payload that matches `path` to `writer` as exact on-disk bytes and returns the +/// byte length of each copied match. +pub fn copy_box_payload_bytes_to( + reader: &mut R, + parent: Option<&BoxInfo>, + path: BoxPath, + writer: &mut W, +) -> Result, ExtractError> +where + R: Read + Seek, + W: Write, +{ + let paths = [path]; + copy_boxes_payload_bytes_to(reader, parent, &paths, writer) +} + +/// Copies every payload that matches any path in `paths` to `writer` as exact on-disk bytes and +/// returns the byte length of each copied match. +pub fn copy_boxes_payload_bytes_to( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], + writer: &mut W, +) -> Result, ExtractError> +where + R: Read + Seek, + W: Write, +{ + let registry = default_registry(); + copy_matched_bytes_to( + reader, + parent, + paths, + ®istry, + ExtractedByteRange::Payload, + writer, + ) +} + /// Extracts every box that matches `path` through the additive Tokio-based async surface and /// returns the matching header metadata. #[cfg(feature = "async")] @@ -326,17 +410,9 @@ where .map_err(|_| io::Error::other("async match collector remained shared"))? .into_inner() .map_err(|_| io::Error::other("async match collector poisoned"))?; - let mut staged = Vec::with_capacity(matched_boxes.len()); - + let mut matches = Vec::with_capacity(matched_boxes.len()); for matched in matched_boxes { - let payload_bytes = - read_matched_bytes_async(reader, matched.info, ExtractedByteRange::Payload).await?; - staged.push((matched, payload_bytes)); - } - - let mut matches = Vec::with_capacity(staged.len()); - for (matched, payload_bytes) in staged { - let payload = decode_payload_from_bytes(&matched, ®istry, &payload_bytes)?; + let payload = decode_payload_async(reader, &matched, ®istry).await?; matches.push(ExtractedBox { info: matched.info, payload, @@ -402,17 +478,9 @@ where .map_err(|_| io::Error::other("async match collector remained shared"))? .into_inner() .map_err(|_| io::Error::other("async match collector poisoned"))?; - let mut staged = Vec::with_capacity(matched_boxes.len()); - + let mut payloads = Vec::with_capacity(matched_boxes.len()); for matched in matched_boxes { - let payload_bytes = - read_matched_bytes_async(reader, matched.info, ExtractedByteRange::Payload).await?; - staged.push((matched, payload_bytes)); - } - - let mut payloads = Vec::with_capacity(staged.len()); - for (matched, payload_bytes) in staged { - let payload = decode_payload_from_bytes(&matched, ®istry, &payload_bytes)?; + let payload = decode_payload_async(reader, &matched, ®istry).await?; let typed = payload .as_ref() .as_any() @@ -560,6 +628,102 @@ where Ok(extracted) } +/// Copies every box that matches `path` through the additive Tokio-based async surface to +/// `writer` as exact serialized bytes, including the original box header, and returns the byte +/// length of each copied match. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn copy_box_bytes_to_async( + reader: &mut R, + parent: Option<&BoxInfo>, + path: BoxPath, + writer: &mut W, +) -> Result, ExtractError> +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + let parent = parent.copied(); + let paths = [path]; + copy_boxes_bytes_to_async(reader, parent.as_ref(), &paths, writer).await +} + +/// Copies every box that matches any path in `paths` through the additive Tokio-based async +/// surface to `writer` as exact serialized bytes, including the original box headers, and returns +/// the byte length of each copied match. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn copy_boxes_bytes_to_async( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], + writer: &mut W, +) -> Result, ExtractError> +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + let parent = parent.copied(); + let paths = paths.to_vec(); + let registry = default_registry(); + copy_matched_bytes_to_async( + reader, + parent.as_ref(), + &paths, + ®istry, + ExtractedByteRange::FullBox, + writer, + ) + .await +} + +/// Copies every payload that matches `path` through the additive Tokio-based async surface to +/// `writer` as exact on-disk bytes and returns the byte length of each copied match. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn copy_box_payload_bytes_to_async( + reader: &mut R, + parent: Option<&BoxInfo>, + path: BoxPath, + writer: &mut W, +) -> Result, ExtractError> +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + let parent = parent.copied(); + let paths = [path]; + copy_boxes_payload_bytes_to_async(reader, parent.as_ref(), &paths, writer).await +} + +/// Copies every payload that matches any path in `paths` through the additive Tokio-based async +/// surface to `writer` as exact on-disk bytes and returns the byte length of each copied match. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn copy_boxes_payload_bytes_to_async( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], + writer: &mut W, +) -> Result, ExtractError> +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + let parent = parent.copied(); + let paths = paths.to_vec(); + let registry = default_registry(); + copy_matched_bytes_to_async( + reader, + parent.as_ref(), + &paths, + ®istry, + ExtractedByteRange::Payload, + writer, + ) + .await +} + /// Extracts every box that matches `path`, decodes the payloads, and clones them as `T` from an /// in-memory MP4 byte slice. /// @@ -777,17 +941,9 @@ where .map_err(|_| io::Error::other("async match collector remained shared"))? .into_inner() .map_err(|_| io::Error::other("async match collector poisoned"))?; - let mut staged = Vec::with_capacity(matched_boxes.len()); - + let mut matches = Vec::with_capacity(matched_boxes.len()); for matched in matched_boxes { - let payload_bytes = - read_matched_bytes_async(reader, matched.info, ExtractedByteRange::Payload).await?; - staged.push((matched, payload_bytes)); - } - - let mut matches = Vec::with_capacity(staged.len()); - for (matched, payload_bytes) in staged { - let payload = decode_payload_from_bytes(&matched, registry, &payload_bytes)?; + let payload = decode_payload_async(reader, &matched, registry).await?; matches.push(ExtractedBox { info: matched.info, payload, @@ -926,17 +1082,9 @@ where .map_err(|_| io::Error::other("async match collector remained shared"))? .into_inner() .map_err(|_| io::Error::other("async match collector poisoned"))?; - let mut staged = Vec::with_capacity(matched_boxes.len()); - + let mut payloads = Vec::with_capacity(matched_boxes.len()); for matched in matched_boxes { - let payload_bytes = - read_matched_bytes_async(reader, matched.info, ExtractedByteRange::Payload).await?; - staged.push((matched, payload_bytes)); - } - - let mut payloads = Vec::with_capacity(staged.len()); - for (matched, payload_bytes) in staged { - let payload = decode_payload_from_bytes(&matched, registry, &payload_bytes)?; + let payload = decode_payload_async(reader, &matched, registry).await?; let typed = payload .as_ref() .as_any() @@ -1074,6 +1222,41 @@ where Ok(matches) } +#[cfg(feature = "async")] +async fn collect_matches_async( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], + registry: &BoxRegistry, +) -> Result, ExtractError> +where + R: AsyncReadSeek, +{ + validate_paths(paths)?; + if paths.is_empty() { + return Ok(Vec::new()); + } + + let matches = Arc::new(Mutex::new(Vec::new())); + let visitor = AsyncMatchCollector { + has_parent: parent.is_some(), + paths: paths.to_vec(), + matches: Arc::clone(&matches), + }; + + if let Some(parent) = parent { + walk_structure_from_box_with_registry_async(reader, parent, registry, visitor).await?; + } else { + walk_structure_with_registry_async(reader, registry, visitor).await?; + } + + Arc::try_unwrap(matches) + .map_err(|_| io::Error::other("async match collector remained shared"))? + .into_inner() + .map_err(|_| io::Error::other("async match collector poisoned")) + .map_err(ExtractError::Io) +} + fn extract_matched_bytes( reader: &mut R, parent: Option<&BoxInfo>, @@ -1094,6 +1277,33 @@ where Ok(extracted) } +fn copy_matched_bytes_to( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], + registry: &BoxRegistry, + range: ExtractedByteRange, + writer: &mut W, +) -> Result, ExtractError> +where + R: Read + Seek, + W: Write, +{ + let matched_boxes = collect_matches(reader, parent, paths, registry)?; + let mut copied = Vec::with_capacity(matched_boxes.len()); + + for matched in matched_boxes { + copied.push(copy_matched_bytes_range_to( + reader, + &matched.info, + range, + writer, + )?); + } + + Ok(copied) +} + fn decode_payload( reader: &mut R, matched: &MatchedBox, @@ -1104,24 +1314,43 @@ where { matched.info.seek_to_payload(reader)?; let payload_size = matched.info.payload_size()?; - let payload_bytes = read_exact_bytes(reader, payload_size)?; - decode_payload_from_bytes(matched, registry, &payload_bytes) + let (payload, _) = unmarshal_any_with_context( + reader, + payload_size, + matched.info.box_type(), + registry, + matched.info.lookup_context(), + None, + ) + .map_err(|source| ExtractError::PayloadDecode { + path: matched.path.clone(), + box_type: matched.info.box_type(), + offset: matched.info.offset(), + source, + })?; + Ok(payload) } -fn decode_payload_from_bytes( +#[cfg(feature = "async")] +async fn decode_payload_async( + reader: &mut R, matched: &MatchedBox, registry: &BoxRegistry, - payload_bytes: &[u8], -) -> Result, ExtractError> { - let mut payload_reader = Cursor::new(payload_bytes); - let (payload, _) = unmarshal_any_with_context( - &mut payload_reader, - payload_bytes.len() as u64, +) -> Result, ExtractError> +where + R: AsyncReadSeek, +{ + matched.info.seek_to_payload_async(reader).await?; + let payload_size = matched.info.payload_size()?; + let (payload, _) = unmarshal_any_with_context_async( + reader, + payload_size, matched.info.box_type(), registry, matched.info.lookup_context(), None, ) + .await .map_err(|source| ExtractError::PayloadDecode { path: matched.path.clone(), box_type: matched.info.box_type(), @@ -1176,41 +1405,119 @@ where read_exact_bytes_async(reader, len).await } -fn read_exact_bytes(reader: &mut R, len: u64) -> Result, ExtractError> +fn copy_matched_bytes_range_to( + reader: &mut R, + info: &BoxInfo, + range: ExtractedByteRange, + writer: &mut W, +) -> Result where - R: Read, + R: Read + Seek, + W: Write, { - let mut bytes = usize::try_from(len) - .map(Vec::with_capacity) - .unwrap_or_else(|_| Vec::new()); - - // `Read::read_to_end` on a `Take` reader does not error on an early underlying EOF, so the - // copied byte count must be checked explicitly to preserve exact-byte semantics. - let mut limited = reader.take(len); - let copied = limited.read_to_end(&mut bytes)? as u64; + let len = match range { + ExtractedByteRange::FullBox => { + info.seek_to_start(reader)?; + info.size() + } + ExtractedByteRange::Payload => { + info.seek_to_payload(reader)?; + info.payload_size()? + } + }; + let mut limited = (&mut *reader).take(len); + let copied = io::copy(&mut limited, writer)?; if copied != len { - return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into()); + return Err(ExtractError::Io(io::Error::new( + io::ErrorKind::UnexpectedEof, + "extracted byte range was truncated during copy", + ))); } - - Ok(bytes) + Ok(copied) } #[cfg(feature = "async")] -async fn read_exact_bytes_async(reader: &mut R, len: u64) -> Result, ExtractError> +async fn copy_matched_bytes_to_async( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], + registry: &BoxRegistry, + range: ExtractedByteRange, + writer: &mut W, +) -> Result, ExtractError> where R: AsyncReadSeek, + W: AsyncWrite + Unpin, { - let mut bytes = usize::try_from(len) - .map(Vec::with_capacity) - .unwrap_or_else(|_| Vec::new()); + let matched_boxes = collect_matches_async(reader, parent, paths, registry).await?; + let mut copied = Vec::with_capacity(matched_boxes.len()); + for matched in matched_boxes { + copied.push(copy_matched_bytes_range_to_async(reader, matched.info, range, writer).await?); + } + Ok(copied) +} +#[cfg(feature = "async")] +async fn copy_matched_bytes_range_to_async( + reader: &mut R, + info: BoxInfo, + range: ExtractedByteRange, + writer: &mut W, +) -> Result +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + let len = match range { + ExtractedByteRange::FullBox => { + reader.seek(io::SeekFrom::Start(info.offset())).await?; + info.size() + } + ExtractedByteRange::Payload => { + reader + .seek(io::SeekFrom::Start(info.offset() + info.header_size())) + .await?; + info.payload_size()? + } + }; let mut limited = (&mut *reader).take(len); - let copied = limited.read_to_end(&mut bytes).await? as u64; + let copied = tokio::io::copy(&mut limited, writer).await?; if copied != len { - return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into()); + return Err(ExtractError::Io(io::Error::new( + io::ErrorKind::UnexpectedEof, + "extracted byte range was truncated during async copy", + ))); } + Ok(copied) +} + +fn read_exact_bytes(reader: &mut R, len: u64) -> Result, ExtractError> +where + R: Read, +{ + let len = usize::try_from(len).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "requested byte range exceeds the supported in-memory size", + ) + })?; + read_exact_vec_untrusted(reader, len).map_err(ExtractError::Io) +} - Ok(bytes) +#[cfg(feature = "async")] +async fn read_exact_bytes_async(reader: &mut R, len: u64) -> Result, ExtractError> +where + R: AsyncReadSeek, +{ + let len = usize::try_from(len).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "requested byte range exceeds the supported in-memory size", + ) + })?; + read_exact_vec_untrusted_async(reader, len) + .await + .map_err(ExtractError::Io) } fn validate_paths(paths: &[BoxPath]) -> Result<(), ExtractError> { diff --git a/src/lib.rs b/src/lib.rs index b54cea9..68c872e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] + //! MP4 and ISOBMFF toolkit with low-level building blocks and thin ergonomic helpers. //! //! The default surface is synchronous. Enable the optional `async` feature when you want the @@ -12,6 +14,35 @@ //! protected-movie path while keeping the CLI on the synchronous path. Enable both `decrypt` and //! `async` when you want the additive file-backed async decrypt companions on top of the existing //! synchronous in-memory decrypt helpers. +//! +//! Enable the optional `mux` feature when you want the additive mux task surface plus the retained +//! low-level helpers underneath it. The mux surface exposes track-based `MuxRequest` helpers for +//! sync and async real MP4 assembly, path-first repeated track-spec parsing aligned with the +//! sync-only CLI, internal chunk and duration coordination on top of one mux event graph, +//! retained low-level staged payload-copy helpers, the public `mp4forge::mux::sample_reader` +//! module built on staged mux plans, the public `mp4forge::mux::inspect` module for path-first +//! direct-ingest inspection and export plus additive packet-focused reports, and the public +//! `mp4forge::mux::rewrite` module for rewriting extracted AVC/HEVC/VVC sample payloads back into +//! Annex B plus additive AV1, AAC ADTS, and MHAS elementary export helpers. +//! Those sample-reader helpers can also expose stable text or subtitle track identity when you +//! construct them with companion `MuxTrackConfig` values. The current path-first mux surface +//! accepts one repeated input path with optional selector suffixes such as `#video`, `#audio`, +//! `#text`, or `#track:ID`. Path-only MP4 inputs import every supported track from that source, +//! while the landed path-only raw auto-detection currently +//! covers MP4, supported AVI audio streams plus H.263/JPEG/PNG/MPEG-4 Part 2/H.264/AVC1 video streams, supported +//! MPEG-PS MPEG audio streams plus MPEG-4 Part 2/H.264/H.265/VVC video streams, supported +//! MPEG-TS MPEG audio streams plus AAC LATM/MHAS plus AC-3/E-AC-3/AC-4/DTS/TrueHD audio plus MPEG-2/AV1/AVS3/MPEG-4 Part 2/H.264/H.265/VVC +//! video streams, AAC ADTS, AAC LATM, MP3, AC-3, E-AC-3, AC-4, AMR, AMR-WB, QCP voice audio, DTS +//! core audio, Dolby TrueHD, leading-sync MHAS MPEG-H, IAMF, H.263 elementary video, MPEG-2 +//! elementary video, MPEG-4 Part 2 elementary video, H.264 Annex B, H.265 Annex B, VVC Annex B, +//! raw AV1 OBU, raw AV1 Annex B, IVF-backed AV1, IVF-backed VP8, IVF-backed VP9, IVF-backed VP10, JPEG still +//! images, WAVE/AIFF/AIFC PCM, native FLAC, Ogg-backed FLAC, Ogg-backed Opus, Ogg-backed Vorbis, +//! Ogg-backed Speex, Ogg-backed Theora, and CAF-backed ALAC. Broader DTS-family sample-entry variants remain +//! supported through MP4 track import, and broader truthful demux-backed input paths continue to +//! land behind that same public shape. + +#[cfg(test)] +extern crate self as mp4forge; /// Tokio-based async I/O traits for the additive library-side async surface. #[cfg(feature = "async")] @@ -37,8 +68,14 @@ pub mod extract; pub mod fourcc; /// MP4 box header parsing and encoding helpers. pub mod header; +/// Feature-gated mux planning, real container assembly, and staged payload-copy helpers. +#[cfg(feature = "mux")] +#[cfg_attr(docsrs, doc(cfg(feature = "mux")))] +pub mod mux; /// File-summary helpers built on the extraction and box layers. pub mod probe; +#[cfg(any(feature = "decrypt", feature = "mux"))] +pub(crate) mod queue; /// Path-based typed payload rewrite helpers built on the writer layer. pub mod rewrite; /// Fragmented top-level `sidx` analysis, planning, and rewrite helpers. diff --git a/src/mux/coordination.rs b/src/mux/coordination.rs new file mode 100644 index 0000000..3d1309a --- /dev/null +++ b/src/mux/coordination.rs @@ -0,0 +1,724 @@ +use std::collections::BTreeMap; + +use super::{MuxError, MuxTrackPlan}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MuxDurationBoundaryKind { + Segment, + Fragment, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TrackCoordinationDirective { + track_id: u32, + chunk_sample_counts: Vec, + duration_boundary_kind: Option, +} + +impl TrackCoordinationDirective { + pub(crate) fn new(track_id: u32, chunk_sample_counts: Vec) -> Self { + Self { + track_id, + chunk_sample_counts, + duration_boundary_kind: None, + } + } + + pub(crate) fn with_duration_boundaries( + mut self, + duration_boundary_kind: MuxDurationBoundaryKind, + ) -> Self { + self.duration_boundary_kind = Some(duration_boundary_kind); + self + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct MuxCoordinationPlan { + track_plans: BTreeMap, +} + +impl MuxCoordinationPlan { + pub(crate) fn from_track_plans( + track_plans: &[MuxTrackPlan], + directives: Vec, + ) -> Result { + let mut plans = BTreeMap::new(); + for track_plan in track_plans { + plans.insert( + track_plan.track_id(), + TrackCoordinationPlan::default_for_item_count(track_plan.item_count())?, + ); + } + + for directive in directives { + let Some(track_plan) = track_plans + .iter() + .find(|track_plan| track_plan.track_id() == directive.track_id) + else { + return Err(MuxError::MissingTrackId { + track_id: directive.track_id, + }); + }; + + let plan = plans.get_mut(&directive.track_id).unwrap(); + *plan = TrackCoordinationPlan::from_directive(&directive, track_plan.item_count())?; + } + + Ok(Self { track_plans: plans }) + } + + pub(crate) fn chunk_sample_counts(&self, track_id: u32) -> Result<&[u32], MuxError> { + self.track_plans + .get(&track_id) + .map(TrackCoordinationPlan::chunk_sample_counts) + .ok_or(MuxError::MissingTrackId { track_id }) + } + + pub(crate) fn duration_boundary_after_sample( + &self, + track_id: u32, + sample_index_in_stream: u32, + ) -> Option { + self.track_plans + .get(&track_id) + .and_then(|plan| plan.duration_boundary_after_sample(sample_index_in_stream)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct TrackCoordinationPlan { + chunk_sample_counts: Vec, + duration_boundaries: Vec, +} + +impl TrackCoordinationPlan { + fn default_for_item_count(item_count: u32) -> Result { + let chunk_sample_counts = vec![ + 1; + usize::try_from(item_count).map_err(|_| { + MuxError::LayoutOverflow("track chunk item-count conversion") + })? + ]; + Ok(Self { + chunk_sample_counts, + duration_boundaries: Vec::new(), + }) + } + + fn from_directive( + directive: &TrackCoordinationDirective, + item_count: u32, + ) -> Result { + validate_chunk_sample_counts( + directive.track_id, + &directive.chunk_sample_counts, + item_count, + )?; + + let duration_boundaries = if let Some(kind) = directive.duration_boundary_kind { + let mut cumulative_sample_count = 0_u32; + let mut boundaries = Vec::with_capacity(directive.chunk_sample_counts.len()); + for samples_per_chunk in &directive.chunk_sample_counts { + cumulative_sample_count = + cumulative_sample_count + .checked_add(*samples_per_chunk) + .ok_or(MuxError::LayoutOverflow("duration-boundary sample count"))?; + boundaries.push(TrackDurationBoundary { + sample_count: cumulative_sample_count, + kind, + }); + } + boundaries + } else { + Vec::new() + }; + + Ok(Self { + chunk_sample_counts: directive.chunk_sample_counts.clone(), + duration_boundaries, + }) + } + + fn chunk_sample_counts(&self) -> &[u32] { + &self.chunk_sample_counts + } + + fn duration_boundary_after_sample( + &self, + sample_index_in_stream: u32, + ) -> Option { + let sample_count = sample_index_in_stream.checked_add(1)?; + self.duration_boundaries + .iter() + .find(|boundary| boundary.sample_count == sample_count) + .map(|boundary| boundary.kind) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct TrackDurationBoundary { + sample_count: u32, + kind: MuxDurationBoundaryKind, +} + +pub(crate) fn build_duration_chunk_sample_counts( + track_id: u32, + sample_durations: I, + target_ticks: u64, +) -> Result, MuxError> +where + I: IntoIterator, +{ + build_duration_chunk_sample_counts_with_start_time(track_id, sample_durations, target_ticks, 0) +} + +pub(crate) fn build_fragmented_duration_chunk_sample_counts_with_start_time( + track_id: u32, + sample_durations: I, + fragment_target_ticks: u64, + segment_target_ticks: u64, + start_time_ticks: i64, +) -> Result<(Vec, Vec), MuxError> +where + I: IntoIterator, +{ + if fragment_target_ticks == 0 || segment_target_ticks == 0 { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "fragment and segment duration targets must be greater than zero".to_string(), + }); + } + let mut fragment_counts = Vec::new(); + let mut reference_group_fragment_counts = Vec::new(); + let mut current_fragment_sample_count = 0_u32; + let mut current_reference_group_fragment_count = 0_u32; + let mut current_segment_index = 0_i128; + let mut current_subsegment_index = 0_u64; + let mut sample_start_time = 0_u64; + let mut segment_start_time = 0_u64; + let start_time_ticks = i128::from(start_time_ticks); + + for duration in sample_durations { + if current_fragment_sample_count != 0 { + let adjusted_sample_start_time = i128::from(sample_start_time) + .checked_add(start_time_ticks) + .ok_or(MuxError::LayoutOverflow("fragment adjusted start-time"))?; + let segment_index = if adjusted_sample_start_time < 0 { + 0 + } else { + adjusted_sample_start_time / i128::from(segment_target_ticks) + }; + let started_new_segment = segment_index != current_segment_index; + if started_new_segment { + current_segment_index = segment_index; + current_subsegment_index = 0; + fragment_counts.push(current_fragment_sample_count); + current_fragment_sample_count = 0; + current_reference_group_fragment_count = current_reference_group_fragment_count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("fragment reference-group count"))?; + reference_group_fragment_counts.push(current_reference_group_fragment_count); + current_reference_group_fragment_count = 0; + segment_start_time = sample_start_time; + } else if fragment_target_ticks != segment_target_ticks { + let subsegment_index = + (sample_start_time - segment_start_time) / fragment_target_ticks; + if subsegment_index != current_subsegment_index { + current_subsegment_index = subsegment_index; + fragment_counts.push(current_fragment_sample_count); + current_fragment_sample_count = 0; + current_reference_group_fragment_count = current_reference_group_fragment_count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("fragment reference-group count"))?; + } + } + } + + current_fragment_sample_count = current_fragment_sample_count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("fragment sample count"))?; + sample_start_time = sample_start_time + .checked_add(u64::from(duration)) + .ok_or(MuxError::LayoutOverflow("fragment duration"))?; + } + + if current_fragment_sample_count != 0 { + fragment_counts.push(current_fragment_sample_count); + current_reference_group_fragment_count = current_reference_group_fragment_count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("fragment reference-group count"))?; + } + if current_reference_group_fragment_count != 0 { + reference_group_fragment_counts.push(current_reference_group_fragment_count); + } + if fragment_counts.is_empty() || reference_group_fragment_counts.is_empty() { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "no fragment boundaries were produced".to_string(), + }); + } + Ok((fragment_counts, reference_group_fragment_counts)) +} + +pub(crate) fn build_sync_aligned_fragmented_duration_chunk_sample_counts( + track_id: u32, + samples: I, + fragment_target_ticks: u64, + segment_target_ticks: u64, + start_time_ticks: i64, +) -> Result<(Vec, Vec), MuxError> +where + I: IntoIterator, +{ + if fragment_target_ticks == 0 || segment_target_ticks == 0 { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "fragment and segment duration targets must be greater than zero".to_string(), + }); + } + + let mut fragment_counts = Vec::new(); + let mut reference_group_fragment_counts = Vec::new(); + let mut current_fragment_sample_count = 0_u32; + let mut current_reference_group_fragment_count = 0_u32; + let mut decode_start_time = 0_i128; + let start_time_ticks = i128::from(start_time_ticks); + let fragment_target_ticks = i128::from(fragment_target_ticks); + let segment_target_ticks = i128::from(segment_target_ticks); + let mut current_segment_index = 0_i128; + let mut current_subsegment_index = 0_i128; + let mut segment_start_time = 0_i128; + let mut segment_started = false; + + for (duration_ticks, composition_offset_ticks, is_sync_sample) in samples { + let presentation_start_time = decode_start_time + .checked_add(i128::from(composition_offset_ticks)) + .and_then(|value| value.checked_add(start_time_ticks)) + .ok_or(MuxError::LayoutOverflow("fragment presentation start"))?; + + if !segment_started { + current_segment_index = if presentation_start_time < 0 { + 0 + } else { + presentation_start_time / segment_target_ticks + }; + current_subsegment_index = 0; + segment_start_time = presentation_start_time; + segment_started = true; + } else if current_fragment_sample_count != 0 && is_sync_sample { + let segment_index = if presentation_start_time < 0 { + 0 + } else { + presentation_start_time / segment_target_ticks + }; + let started_new_segment = segment_index != current_segment_index; + if started_new_segment { + current_segment_index = segment_index; + current_subsegment_index = 0; + fragment_counts.push(current_fragment_sample_count); + current_fragment_sample_count = 0; + current_reference_group_fragment_count = current_reference_group_fragment_count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("fragment reference-group count"))?; + reference_group_fragment_counts.push(current_reference_group_fragment_count); + current_reference_group_fragment_count = 0; + segment_start_time = presentation_start_time; + } else if fragment_target_ticks != segment_target_ticks { + let subsegment_index = if presentation_start_time < segment_start_time { + 0 + } else { + (presentation_start_time - segment_start_time) / fragment_target_ticks + }; + if subsegment_index != current_subsegment_index { + current_subsegment_index = subsegment_index; + fragment_counts.push(current_fragment_sample_count); + current_fragment_sample_count = 0; + current_reference_group_fragment_count = current_reference_group_fragment_count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("fragment reference-group count"))?; + } + } + } + + current_fragment_sample_count = current_fragment_sample_count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("fragment sample count"))?; + decode_start_time = decode_start_time + .checked_add(i128::from(duration_ticks)) + .ok_or(MuxError::LayoutOverflow("fragment duration"))?; + } + + if current_fragment_sample_count != 0 { + fragment_counts.push(current_fragment_sample_count); + current_reference_group_fragment_count = current_reference_group_fragment_count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("fragment reference-group count"))?; + } + if current_reference_group_fragment_count != 0 { + reference_group_fragment_counts.push(current_reference_group_fragment_count); + } + if fragment_counts.is_empty() || reference_group_fragment_counts.is_empty() { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "no fragment boundaries were produced".to_string(), + }); + } + Ok((fragment_counts, reference_group_fragment_counts)) +} + +pub(crate) fn build_capped_duration_chunk_sample_counts( + track_id: u32, + sample_durations: I, + target_ticks: u64, +) -> Result, MuxError> +where + I: IntoIterator, +{ + let mut counts = Vec::new(); + let mut current_count = 0_u32; + let mut current_duration = 0_u64; + for duration in sample_durations { + let duration = u64::from(duration); + if current_count != 0 + && current_duration + .checked_add(duration) + .ok_or(MuxError::LayoutOverflow("chunk duration"))? + > target_ticks + { + counts.push(current_count); + current_count = 0; + current_duration = 0; + } + current_count = current_count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("chunk sample count"))?; + current_duration = current_duration + .checked_add(duration) + .ok_or(MuxError::LayoutOverflow("chunk duration"))?; + } + if current_count != 0 { + counts.push(current_count); + } + if counts.is_empty() { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "no chunk boundaries were produced".to_string(), + }); + } + Ok(counts) +} + +pub(crate) fn rebalance_small_multi_audio_chunk_sample_counts(chunk_sample_counts: &mut [u32]) { + if chunk_sample_counts.len() != 3 { + return; + } + + let last_index = chunk_sample_counts.len() - 1; + let previous_index = last_index - 1; + if chunk_sample_counts[0] != chunk_sample_counts[previous_index] + || chunk_sample_counts[previous_index] > 4 + { + return; + } + + while chunk_sample_counts[last_index] + 1 < chunk_sample_counts[previous_index] { + chunk_sample_counts[previous_index] -= 1; + chunk_sample_counts[last_index] += 1; + } +} + +pub(crate) fn build_duration_chunk_sample_counts_with_start_time( + track_id: u32, + sample_durations: I, + target_ticks: u64, + start_time_ticks: i64, +) -> Result, MuxError> +where + I: IntoIterator, +{ + let mut counts = Vec::new(); + let mut current_count = 0_u32; + let mut cumulative_end_time = i128::from(start_time_ticks); + let mut next_boundary = i128::from(target_ticks); + for duration in sample_durations { + current_count = current_count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("chunk sample count"))?; + cumulative_end_time = cumulative_end_time + .checked_add(i128::from(duration)) + .ok_or(MuxError::LayoutOverflow("chunk duration"))?; + if cumulative_end_time >= next_boundary { + counts.push(current_count); + current_count = 0; + while cumulative_end_time >= next_boundary { + next_boundary = next_boundary + .checked_add(i128::from(target_ticks)) + .ok_or(MuxError::LayoutOverflow("chunk duration boundary"))?; + } + } + } + if current_count != 0 { + counts.push(current_count); + } + if counts.is_empty() { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "no chunk boundaries were produced".to_string(), + }); + } + Ok(counts) +} + +pub(crate) fn build_sync_aligned_segment_chunk_sample_counts( + track_id: u32, + samples: I, + target_ticks: u64, + start_time_ticks: i64, +) -> Result, MuxError> +where + I: IntoIterator, +{ + let mut counts = Vec::new(); + let mut current_count = 0_u32; + let mut decode_start_time = 0_i128; + let mut next_boundary = i128::from(target_ticks); + let start_time_ticks = i128::from(start_time_ticks); + + for (duration_ticks, composition_offset_ticks, is_sync_sample) in samples { + // Segment boundaries should start on sync samples after the adjusted presentation + // timeline crosses the requested target. + if current_count != 0 && is_sync_sample { + let presentation_start_time = decode_start_time + .checked_add(i128::from(composition_offset_ticks)) + .and_then(|value| value.checked_add(start_time_ticks)) + .ok_or(MuxError::LayoutOverflow("segment presentation start"))?; + if presentation_start_time >= next_boundary { + counts.push(current_count); + current_count = 0; + while presentation_start_time >= next_boundary { + next_boundary = next_boundary + .checked_add(i128::from(target_ticks)) + .ok_or(MuxError::LayoutOverflow("segment duration boundary"))?; + } + } + } + + current_count = current_count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("chunk sample count"))?; + decode_start_time = decode_start_time + .checked_add(i128::from(duration_ticks)) + .ok_or(MuxError::LayoutOverflow("chunk duration"))?; + } + + if current_count != 0 { + counts.push(current_count); + } + if counts.is_empty() { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "no chunk boundaries were produced".to_string(), + }); + } + Ok(counts) +} + +fn validate_chunk_sample_counts( + track_id: u32, + chunk_sample_counts: &[u32], + item_count: u32, +) -> Result<(), MuxError> { + if chunk_sample_counts.is_empty() { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "chunk plans may not be empty".to_string(), + }); + } + + let mut total_samples = 0_u32; + for samples_per_chunk in chunk_sample_counts { + if *samples_per_chunk == 0 { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "chunk plans may not contain zero-length chunks".to_string(), + }); + } + total_samples = total_samples + .checked_add(*samples_per_chunk) + .ok_or(MuxError::LayoutOverflow("chunk sample-count total"))?; + } + + if total_samples != item_count { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: format!( + "chunk plan resolves {total_samples} samples for {item_count} staged samples" + ), + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn duration_chunk_counts_roll_over_at_target_ticks() { + let counts = build_duration_chunk_sample_counts(7, [10_u32, 10, 10], 15).unwrap(); + + assert_eq!(counts, vec![2, 1]); + } + + #[test] + fn capped_duration_chunk_counts_split_before_overshoot() { + let counts = build_capped_duration_chunk_sample_counts( + 7, + std::iter::repeat_n(1_024_u32, 45), + 22_050, + ) + .unwrap(); + + assert_eq!(counts, vec![21, 21, 3]); + } + + #[test] + fn rebalance_small_multi_audio_chunk_counts_only_adjusts_retained_three_chunk_shape() { + let mut counts = vec![4, 4, 2]; + + rebalance_small_multi_audio_chunk_sample_counts(&mut counts); + + assert_eq!(counts, vec![4, 3, 3]); + } + + #[test] + fn duration_chunk_counts_honor_negative_start_time_offsets() { + let counts = build_duration_chunk_sample_counts_with_start_time( + 7, + std::iter::repeat_n(1_024_u32, 120), + 44_100, + -1_024, + ) + .unwrap(); + + assert_eq!(counts, vec![45, 43, 32]); + } + + #[test] + fn sync_aligned_segment_chunk_counts_use_presentation_starts() { + let counts = build_sync_aligned_segment_chunk_sample_counts( + 7, + (0..82).map(|index| { + let composition_offset = if matches!(index, 0 | 30 | 60) { + 2_002_i64 + } else if index % 2 == 1 { + 3_003_i64 + } else { + 1_001_i64 + }; + (1_001_u32, composition_offset, matches!(index, 0 | 30 | 60)) + }), + 30_000, + -2_002, + ) + .unwrap(); + + assert_eq!(counts, vec![30, 30, 22]); + } + + #[test] + fn coordination_plan_applies_duration_boundaries_to_chunk_ends() { + let track_plans = [MuxTrackPlan { + track_id: 7, + item_count: 3, + first_decode_time: 0, + end_decode_time: 30, + }]; + let plan = MuxCoordinationPlan::from_track_plans( + &track_plans, + vec![ + TrackCoordinationDirective::new(7, vec![2, 1]) + .with_duration_boundaries(MuxDurationBoundaryKind::Fragment), + ], + ) + .unwrap(); + + assert_eq!(plan.chunk_sample_counts(7).unwrap(), &[2, 1]); + assert_eq!(plan.duration_boundary_after_sample(7, 0), None); + assert_eq!( + plan.duration_boundary_after_sample(7, 1), + Some(MuxDurationBoundaryKind::Fragment) + ); + assert_eq!( + plan.duration_boundary_after_sample(7, 2), + Some(MuxDurationBoundaryKind::Fragment) + ); + } + + #[test] + fn fragmented_duration_chunk_counts_emit_fragment_and_segment_groups() { + let durations = vec![1_536_u32; 375]; + let (fragment_counts, reference_group_fragment_counts) = + build_fragmented_duration_chunk_sample_counts_with_start_time( + 7, durations, 240_000, 288_000, 0, + ) + .unwrap(); + + assert_eq!(&fragment_counts[..4], &[157, 31, 157, 30]); + assert_eq!(&reference_group_fragment_counts[..2], &[2, 2]); + assert_eq!(fragment_counts.iter().copied().sum::(), 375); + assert_eq!( + reference_group_fragment_counts.iter().copied().sum::() as usize, + fragment_counts.len() + ); + } + + #[test] + fn sync_aligned_fragmented_duration_chunk_counts_wait_for_sync_boundaries() { + let samples = std::iter::repeat_n((2_048_u32, 0_i64, false), 144) + .enumerate() + .map(|(index, (duration, composition_offset, _))| { + (duration, composition_offset, index % 24 == 0) + }); + let (fragment_counts, reference_group_fragment_counts) = + build_sync_aligned_fragmented_duration_chunk_sample_counts( + 7, samples, 240_000, 288_000, 0, + ) + .unwrap(); + + assert_eq!(fragment_counts, vec![120, 24]); + assert_eq!(reference_group_fragment_counts, vec![2]); + } + + #[test] + fn sync_aligned_fragmented_duration_chunk_counts_honor_negative_start_time() { + let samples = std::iter::repeat_n((1_024_u32, 0_i64, false), 300) + .enumerate() + .map(|(index, (duration, composition_offset, _))| { + (duration, composition_offset, index % 25 == 0) + }); + let (fragment_counts, reference_group_fragment_counts) = + build_sync_aligned_fragmented_duration_chunk_sample_counts( + 7, samples, 240_000, 288_000, -3_072, + ) + .unwrap(); + + assert_eq!(fragment_counts, vec![250, 50]); + assert_eq!(reference_group_fragment_counts, vec![2]); + } + + #[test] + fn fragmented_duration_chunk_counts_honor_negative_start_time_for_segment_rollover() { + let durations = std::iter::repeat_n(1_024_u32, 303); + let (fragment_counts, reference_group_fragment_counts) = + build_fragmented_duration_chunk_sample_counts_with_start_time( + 7, durations, 220_500, 264_600, -2_048, + ) + .unwrap(); + + assert_eq!(&fragment_counts[..3], &[216, 45, 42]); + assert_eq!(&reference_group_fragment_counts[..2], &[2, 1]); + } +} diff --git a/src/mux/demux/aac.rs b/src/mux/demux/aac.rs new file mode 100644 index 0000000..36a4596 --- /dev/null +++ b/src/mux/demux/aac.rs @@ -0,0 +1,680 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::AnyTypeBox; +use crate::boxes::iso14496_12::{AudioSampleEntry, SampleEntry}; +use crate::boxes::iso14496_14::{ + DECODER_CONFIG_DESCRIPTOR_TAG, DECODER_SPECIFIC_INFO_TAG, DecoderConfigDescriptor, Descriptor, + ES_DESCRIPTOR_TAG, EsDescriptor, Esds, SL_CONFIG_DESCRIPTOR_TAG, +}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{SegmentedMuxSourceSegment, StagedSample, read_exact_at_sync}; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::read_segmented_bytes_sync; + +pub(in crate::mux) struct ParsedAdtsTrack { + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +pub(in crate::mux) fn scan_adts_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::<(u8, u8, u32, u16)>; + while offset < file_size { + if file_size - offset < 7 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated ADTS header".to_string(), + }); + } + let mut header = [0_u8; 7]; + read_exact_at_sync( + &mut file, + offset, + &mut header, + spec, + "truncated ADTS header", + )?; + if header[0] != 0xFF || header[1] & 0xF0 != 0xF0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing ADTS sync word at byte offset {offset}"), + }); + } + + let protection_absent = header[1] & 0x01 != 0; + let header_length = if protection_absent { 7 } else { 9 }; + if file_size - offset < u64::from(header_length as u32) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated ADTS header".to_string(), + }); + } + let profile = ((header[2] >> 6) & 0x03) + 1; + let sampling_frequency_index = (header[2] >> 2) & 0x0F; + let channel_configuration = u16::from((header[2] & 0x01) << 2 | ((header[3] >> 6) & 0x03)); + let sample_rate = adts_sample_rate(sampling_frequency_index).ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "unsupported ADTS sampling-frequency index {sampling_frequency_index}" + ), + } + })?; + let frame_length = usize::from( + ((u16::from(header[3] & 0x03)) << 11) + | (u16::from(header[4]) << 3) + | u16::from(header[5] >> 5), + ); + let raw_blocks = u32::from(header[6] & 0x03) + 1; + if frame_length < header_length + || offset + .checked_add(u64::try_from(frame_length).unwrap_or(u64::MAX)) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated ADTS frame at byte offset {offset}"), + }); + } + + let descriptor = ( + profile, + sampling_frequency_index, + sample_rate, + channel_configuration, + ); + if let Some(expected) = expected { + if expected != descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "AAC frames changed profile, sample rate, or channel layout mid-stream" + .to_string(), + }); + } + } else { + expected = Some(descriptor); + } + + let payload_size = frame_length - header_length; + samples.push(StagedSample { + data_offset: offset + u64::from(header_length as u32), + data_size: u32::try_from(payload_size) + .map_err(|_| MuxError::LayoutOverflow("AAC frame size"))?, + duration: 1024 * raw_blocks, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add( + u64::try_from(frame_length) + .map_err(|_| MuxError::LayoutOverflow("AAC frame size"))?, + ) + .ok_or(MuxError::LayoutOverflow("AAC frame offset"))?; + } + let (audio_object_type, sampling_frequency_index, sample_rate, channel_configuration) = + expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AAC input contained no ADTS frames".to_string(), + })?; + Ok(ParsedAdtsTrack { + sample_rate, + sample_entry_box: build_aac_sample_entry_box( + audio_object_type, + sampling_frequency_index, + channel_configuration, + sample_rate, + &samples, + )?, + samples, + }) +} + +pub(in crate::mux) fn scan_adts_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::<(u8, u8, u32, u16)>; + while offset < total_size { + if total_size - offset < 7 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated ADTS header".to_string(), + }); + } + let mut header = [0_u8; 7]; + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated ADTS header", + )?; + if header[0] != 0xFF || header[1] & 0xF0 != 0xF0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing ADTS sync word at logical byte offset {offset}"), + }); + } + + let protection_absent = header[1] & 0x01 != 0; + let header_length = if protection_absent { 7 } else { 9 }; + if total_size - offset < u64::from(header_length as u32) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated ADTS header".to_string(), + }); + } + let profile = ((header[2] >> 6) & 0x03) + 1; + let sampling_frequency_index = (header[2] >> 2) & 0x0F; + let channel_configuration = u16::from((header[2] & 0x01) << 2 | ((header[3] >> 6) & 0x03)); + let sample_rate = adts_sample_rate(sampling_frequency_index).ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "unsupported ADTS sampling-frequency index {sampling_frequency_index}" + ), + } + })?; + let frame_length = usize::from( + ((u16::from(header[3] & 0x03)) << 11) + | (u16::from(header[4]) << 3) + | u16::from(header[5] >> 5), + ); + let raw_blocks = u32::from(header[6] & 0x03) + 1; + if frame_length < header_length + || offset + .checked_add(u64::try_from(frame_length).unwrap_or(u64::MAX)) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated ADTS frame at logical byte offset {offset}"), + }); + } + + let descriptor = ( + profile, + sampling_frequency_index, + sample_rate, + channel_configuration, + ); + if let Some(expected) = expected { + if expected != descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "AAC frames changed profile, sample rate, or channel layout mid-stream" + .to_string(), + }); + } + } else { + expected = Some(descriptor); + } + + let payload_size = frame_length - header_length; + samples.push(StagedSample { + data_offset: offset + u64::from(header_length as u32), + data_size: u32::try_from(payload_size) + .map_err(|_| MuxError::LayoutOverflow("AAC frame size"))?, + duration: 1024 * raw_blocks, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add( + u64::try_from(frame_length) + .map_err(|_| MuxError::LayoutOverflow("AAC frame size"))?, + ) + .ok_or(MuxError::LayoutOverflow("AAC frame offset"))?; + } + let (audio_object_type, sampling_frequency_index, sample_rate, channel_configuration) = + expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AAC input contained no ADTS frames".to_string(), + })?; + Ok(ParsedAdtsTrack { + sample_rate, + sample_entry_box: build_aac_sample_entry_box( + audio_object_type, + sampling_frequency_index, + channel_configuration, + sample_rate, + &samples, + )?, + samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_adts_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::<(u8, u8, u32, u16)>; + while offset < file_size { + if file_size - offset < 7 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated ADTS header".to_string(), + }); + } + let mut header = [0_u8; 7]; + read_exact_at_async( + &mut file, + offset, + &mut header, + spec, + "truncated ADTS header", + ) + .await?; + if header[0] != 0xFF || header[1] & 0xF0 != 0xF0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing ADTS sync word at byte offset {offset}"), + }); + } + + let protection_absent = header[1] & 0x01 != 0; + let header_length = if protection_absent { 7 } else { 9 }; + if file_size - offset < u64::from(header_length as u32) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated ADTS header".to_string(), + }); + } + let profile = ((header[2] >> 6) & 0x03) + 1; + let sampling_frequency_index = (header[2] >> 2) & 0x0F; + let channel_configuration = u16::from((header[2] & 0x01) << 2 | ((header[3] >> 6) & 0x03)); + let sample_rate = adts_sample_rate(sampling_frequency_index).ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "unsupported ADTS sampling-frequency index {sampling_frequency_index}" + ), + } + })?; + let frame_length = usize::from( + ((u16::from(header[3] & 0x03)) << 11) + | (u16::from(header[4]) << 3) + | u16::from(header[5] >> 5), + ); + let raw_blocks = u32::from(header[6] & 0x03) + 1; + if frame_length < header_length + || offset + .checked_add(u64::try_from(frame_length).unwrap_or(u64::MAX)) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated ADTS frame at byte offset {offset}"), + }); + } + + let descriptor = ( + profile, + sampling_frequency_index, + sample_rate, + channel_configuration, + ); + if let Some(expected) = expected { + if expected != descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "AAC frames changed profile, sample rate, or channel layout mid-stream" + .to_string(), + }); + } + } else { + expected = Some(descriptor); + } + + let payload_size = frame_length - header_length; + samples.push(StagedSample { + data_offset: offset + u64::from(header_length as u32), + data_size: u32::try_from(payload_size) + .map_err(|_| MuxError::LayoutOverflow("AAC frame size"))?, + duration: 1024 * raw_blocks, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add( + u64::try_from(frame_length) + .map_err(|_| MuxError::LayoutOverflow("AAC frame size"))?, + ) + .ok_or(MuxError::LayoutOverflow("AAC frame offset"))?; + } + let (audio_object_type, sampling_frequency_index, sample_rate, channel_configuration) = + expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AAC input contained no ADTS frames".to_string(), + })?; + Ok(ParsedAdtsTrack { + sample_rate, + sample_entry_box: build_aac_sample_entry_box( + audio_object_type, + sampling_frequency_index, + channel_configuration, + sample_rate, + &samples, + )?, + samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_adts_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::<(u8, u8, u32, u16)>; + while offset < total_size { + if total_size - offset < 7 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated ADTS header".to_string(), + }); + } + let mut header = [0_u8; 7]; + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated ADTS header", + ) + .await?; + if header[0] != 0xFF || header[1] & 0xF0 != 0xF0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing ADTS sync word at logical byte offset {offset}"), + }); + } + + let protection_absent = header[1] & 0x01 != 0; + let header_length = if protection_absent { 7 } else { 9 }; + if total_size - offset < u64::from(header_length as u32) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated ADTS header".to_string(), + }); + } + let profile = ((header[2] >> 6) & 0x03) + 1; + let sampling_frequency_index = (header[2] >> 2) & 0x0F; + let channel_configuration = u16::from((header[2] & 0x01) << 2 | ((header[3] >> 6) & 0x03)); + let sample_rate = adts_sample_rate(sampling_frequency_index).ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "unsupported ADTS sampling-frequency index {sampling_frequency_index}" + ), + } + })?; + let frame_length = usize::from( + ((u16::from(header[3] & 0x03)) << 11) + | (u16::from(header[4]) << 3) + | u16::from(header[5] >> 5), + ); + let raw_blocks = u32::from(header[6] & 0x03) + 1; + if frame_length < header_length + || offset + .checked_add(u64::try_from(frame_length).unwrap_or(u64::MAX)) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated ADTS frame at logical byte offset {offset}"), + }); + } + + let descriptor = ( + profile, + sampling_frequency_index, + sample_rate, + channel_configuration, + ); + if let Some(expected) = expected { + if expected != descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "AAC frames changed profile, sample rate, or channel layout mid-stream" + .to_string(), + }); + } + } else { + expected = Some(descriptor); + } + + let payload_size = frame_length - header_length; + samples.push(StagedSample { + data_offset: offset + u64::from(header_length as u32), + data_size: u32::try_from(payload_size) + .map_err(|_| MuxError::LayoutOverflow("AAC frame size"))?, + duration: 1024 * raw_blocks, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add( + u64::try_from(frame_length) + .map_err(|_| MuxError::LayoutOverflow("AAC frame size"))?, + ) + .ok_or(MuxError::LayoutOverflow("AAC frame offset"))?; + } + let (audio_object_type, sampling_frequency_index, sample_rate, channel_configuration) = + expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AAC input contained no ADTS frames".to_string(), + })?; + Ok(ParsedAdtsTrack { + sample_rate, + sample_entry_box: build_aac_sample_entry_box( + audio_object_type, + sampling_frequency_index, + channel_configuration, + sample_rate, + &samples, + )?, + samples, + }) +} + +fn build_aac_sample_entry_box( + audio_object_type: u8, + sampling_frequency_index: u8, + channel_configuration: u16, + sample_rate: u32, + samples: &[StagedSample], +) -> Result, MuxError> { + let mut mp4a = AudioSampleEntry::default(); + mp4a.set_box_type(FourCc::from_bytes(*b"mp4a")); + mp4a.sample_entry = SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }; + mp4a.channel_count = channel_configuration; + mp4a.sample_size = 16; + mp4a.sample_rate = sample_rate << 16; + + let mut esds = aac_profile_esds( + audio_object_type, + sampling_frequency_index, + channel_configuration, + sample_rate, + samples, + ); + esds.normalize_descriptor_sizes_for_mux() + .map_err(|_| MuxError::LayoutOverflow("AAC esds normalization"))?; + + super::super::mp4::encode_typed_box(&mp4a, &super::super::mp4::encode_typed_box(&esds, &[])?) +} + +pub(in crate::mux) fn build_aac_lc_sample_entry_box( + sample_rate: u32, + channel_configuration: u16, +) -> Result, MuxError> { + let sampling_frequency_index = + aac_sample_rate_index(sample_rate).ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: format!("AAC {sample_rate} Hz"), + message: format!( + "unsupported AAC sample rate {sample_rate} for direct sample-entry signaling" + ), + })?; + build_aac_sample_entry_box( + 2, + sampling_frequency_index, + channel_configuration, + sample_rate, + &[], + ) +} + +const fn adts_sample_rate(index: u8) -> Option { + match index { + 0 => Some(96_000), + 1 => Some(88_200), + 2 => Some(64_000), + 3 => Some(48_000), + 4 => Some(44_100), + 5 => Some(32_000), + 6 => Some(24_000), + 7 => Some(22_050), + 8 => Some(16_000), + 9 => Some(12_000), + 10 => Some(11_025), + 11 => Some(8_000), + 12 => Some(7_350), + _ => None, + } +} + +const fn aac_sample_rate_index(sample_rate: u32) -> Option { + match sample_rate { + 96_000 => Some(0), + 88_200 => Some(1), + 64_000 => Some(2), + 48_000 => Some(3), + 44_100 => Some(4), + 32_000 => Some(5), + 24_000 => Some(6), + 22_050 => Some(7), + 16_000 => Some(8), + 12_000 => Some(9), + 11_025 => Some(10), + 8_000 => Some(11), + 7_350 => Some(12), + _ => None, + } +} + +fn aac_profile_esds( + audio_object_type: u8, + sampling_frequency_index: u8, + channel_configuration: u16, + sample_rate: u32, + samples: &[StagedSample], +) -> Esds { + let audio_specific_config = build_aac_audio_specific_config( + audio_object_type, + sampling_frequency_index, + channel_configuration, + ); + let buffer_size_db = samples + .iter() + .map(|sample| sample.data_size) + .max() + .unwrap_or(0); + let total_payload_bytes = samples + .iter() + .fold(0_u64, |total, sample| total + u64::from(sample.data_size)); + let total_payload_bits = total_payload_bytes.saturating_mul(8); + let total_duration = samples + .iter() + .fold(0_u64, |total, sample| total + u64::from(sample.duration)); + let avg_bitrate = total_payload_bytes + .saturating_mul(8) + .saturating_mul(u64::from(sample_rate)) + .checked_div(total_duration) + .unwrap_or(0); + let max_bitrate = total_payload_bits.max(avg_bitrate); + let mut esds = Esds::default(); + esds.descriptors = vec![ + Descriptor { + tag: ES_DESCRIPTOR_TAG, + es_descriptor: Some(EsDescriptor::default()), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some(DecoderConfigDescriptor { + object_type_indication: 0x40, + stream_type: 5, + buffer_size_db, + max_bitrate: u32::try_from(max_bitrate).unwrap_or(u32::MAX), + avg_bitrate: u32::try_from(avg_bitrate).unwrap_or(u32::MAX), + reserved: true, + ..DecoderConfigDescriptor::default() + }), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_SPECIFIC_INFO_TAG, + size: audio_specific_config.len() as u32, + data: audio_specific_config, + ..Descriptor::default() + }, + Descriptor { + tag: SL_CONFIG_DESCRIPTOR_TAG, + size: 1, + data: vec![0x02], + ..Descriptor::default() + }, + ]; + esds +} + +fn build_aac_audio_specific_config( + audio_object_type: u8, + sampling_frequency_index: u8, + channel_configuration: u16, +) -> Vec { + let config = ((u16::from(audio_object_type) & 0x1F) << 11) + | ((u16::from(sampling_frequency_index) & 0x0F) << 7) + | ((channel_configuration & 0x0F) << 3); + vec![(config >> 8) as u8, (config & 0xFF) as u8] +} diff --git a/src/mux/demux/ac3.rs b/src/mux/demux/ac3.rs new file mode 100644 index 0000000..beffaa5 --- /dev/null +++ b/src/mux/demux/ac3.rs @@ -0,0 +1,660 @@ +use std::fs::File; +use std::io::Cursor; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::bitio::BitReader; +use crate::boxes::AnyTypeBox; +use crate::boxes::etsi_ts_102_366::Dac3; +use crate::boxes::iso14496_12::{AudioSampleEntry, Btrt, SampleEntry}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{SegmentedMuxSourceSegment, StagedSample, read_exact_at_sync}; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::read_segmented_bytes_sync; + +pub(in crate::mux) struct ParsedAc3Track { + pub(in crate::mux) decoder_config: Ac3DecoderConfig, + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +#[derive(Clone, Copy)] +pub(in crate::mux) struct Ac3DecoderConfig { + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) channel_count: u16, + pub(in crate::mux) fscod: u8, + pub(in crate::mux) bsid: u8, + pub(in crate::mux) bsmod: u8, + pub(in crate::mux) acmod: u8, + pub(in crate::mux) lfe_on: u8, + pub(in crate::mux) bit_rate_code: u8, +} + +pub(in crate::mux) fn scan_ac3_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::; + while offset < file_size { + if file_size - offset < 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated AC-3 syncframe header".to_string(), + }); + } + let mut header = [0_u8; 8]; + read_exact_at_sync( + &mut file, + offset, + &mut header, + spec, + "truncated AC-3 syncframe header", + )?; + let (decoder_config, frame_size) = parse_ac3_frame_header(&header, offset, spec)?; + let frame_size_u64 = u64::from(frame_size); + if offset + .checked_add(frame_size_u64) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated AC-3 syncframe at byte offset {offset}"), + }); + } + if let Some(current) = &expected { + if !same_ac3_config(current, &decoder_config) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-3 syncframes changed decoder configuration mid-stream".to_string(), + }); + } + } else { + expected = Some(decoder_config); + } + samples.push(StagedSample { + data_offset: offset, + data_size: frame_size, + duration: 1536, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(frame_size_u64) + .ok_or(MuxError::LayoutOverflow("AC-3 frame offset"))?; + } + + let decoder_config = expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-3 input contained no syncframes".to_string(), + })?; + Ok(ParsedAc3Track { + decoder_config, + sample_rate: decoder_config.sample_rate, + sample_entry_box: build_ac3_sample_entry_box(&decoder_config, &samples)?, + samples, + }) +} + +pub(in crate::mux) fn scan_ac3_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::; + while offset < total_size { + if total_size - offset < 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated AC-3 syncframe header".to_string(), + }); + } + let mut header = [0_u8; 8]; + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated AC-3 syncframe header", + )?; + let (decoder_config, frame_size) = parse_ac3_frame_header(&header, offset, spec)?; + let frame_size_u64 = u64::from(frame_size); + if offset + .checked_add(frame_size_u64) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated AC-3 syncframe at logical byte offset {offset}"), + }); + } + if let Some(current) = &expected { + if !same_ac3_config(current, &decoder_config) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-3 syncframes changed decoder configuration mid-stream".to_string(), + }); + } + } else { + expected = Some(decoder_config); + } + samples.push(StagedSample { + data_offset: offset, + data_size: frame_size, + duration: 1536, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(frame_size_u64) + .ok_or(MuxError::LayoutOverflow("AC-3 frame offset"))?; + } + + let decoder_config = expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-3 input contained no syncframes".to_string(), + })?; + Ok(ParsedAc3Track { + decoder_config, + sample_rate: decoder_config.sample_rate, + sample_entry_box: build_ac3_sample_entry_box(&decoder_config, &samples)?, + samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_ac3_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::; + while offset < file_size { + if file_size - offset < 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated AC-3 syncframe header".to_string(), + }); + } + let mut header = [0_u8; 8]; + read_exact_at_async( + &mut file, + offset, + &mut header, + spec, + "truncated AC-3 syncframe header", + ) + .await?; + let (decoder_config, frame_size) = parse_ac3_frame_header(&header, offset, spec)?; + let frame_size_u64 = u64::from(frame_size); + if offset + .checked_add(frame_size_u64) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated AC-3 syncframe at byte offset {offset}"), + }); + } + if let Some(current) = &expected { + if !same_ac3_config(current, &decoder_config) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-3 syncframes changed decoder configuration mid-stream".to_string(), + }); + } + } else { + expected = Some(decoder_config); + } + samples.push(StagedSample { + data_offset: offset, + data_size: frame_size, + duration: 1536, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(frame_size_u64) + .ok_or(MuxError::LayoutOverflow("AC-3 frame offset"))?; + } + + let decoder_config = expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-3 input contained no syncframes".to_string(), + })?; + Ok(ParsedAc3Track { + decoder_config, + sample_rate: decoder_config.sample_rate, + sample_entry_box: build_ac3_sample_entry_box(&decoder_config, &samples)?, + samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_ac3_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::; + while offset < total_size { + if total_size - offset < 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated AC-3 syncframe header".to_string(), + }); + } + let mut header = [0_u8; 8]; + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated AC-3 syncframe header", + ) + .await?; + let (decoder_config, frame_size) = parse_ac3_frame_header(&header, offset, spec)?; + let frame_size_u64 = u64::from(frame_size); + if offset + .checked_add(frame_size_u64) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated AC-3 syncframe at logical byte offset {offset}"), + }); + } + if let Some(current) = &expected { + if !same_ac3_config(current, &decoder_config) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-3 syncframes changed decoder configuration mid-stream".to_string(), + }); + } + } else { + expected = Some(decoder_config); + } + samples.push(StagedSample { + data_offset: offset, + data_size: frame_size, + duration: 1536, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(frame_size_u64) + .ok_or(MuxError::LayoutOverflow("AC-3 frame offset"))?; + } + + let decoder_config = expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-3 input contained no syncframes".to_string(), + })?; + Ok(ParsedAc3Track { + decoder_config, + sample_rate: decoder_config.sample_rate, + sample_entry_box: build_ac3_sample_entry_box(&decoder_config, &samples)?, + samples, + }) +} + +fn same_ac3_config(left: &Ac3DecoderConfig, right: &Ac3DecoderConfig) -> bool { + left.sample_rate == right.sample_rate + && left.channel_count == right.channel_count + && left.bsid == right.bsid + && left.bsmod == right.bsmod + && left.acmod == right.acmod + && left.lfe_on == right.lfe_on + && left.bit_rate_code == right.bit_rate_code +} + +pub(in crate::mux) fn parse_ac3_frame_header( + header: &[u8; 8], + offset: u64, + spec: &str, +) -> Result<(Ac3DecoderConfig, u32), MuxError> { + if header[0] != 0x0B || header[1] != 0x77 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing AC-3 sync word at byte offset {offset}"), + }); + } + let fscod = header[4] >> 6; + if fscod == 0x03 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "reserved AC-3 sample-rate code".to_string(), + }); + } + let frmsizecod = header[4] & 0x3F; + let frame_size = ac3_frame_size_bytes(fscod, frmsizecod).ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported AC-3 frame-size code {frmsizecod}"), + } + })?; + let bsid = (header[5] >> 3) & 0x1F; + let bsmod = header[5] & 0x07; + let mut reader = BitReader::new(Cursor::new(&header[6..8])); + let acmod = read_bits_u8_labeled(&mut reader, 3, spec, "AC-3")?; + if acmod & 0x01 != 0 && acmod != 0x01 { + skip_bits_labeled(&mut reader, 2, spec, "AC-3")?; + } + if acmod & 0x04 != 0 { + skip_bits_labeled(&mut reader, 2, spec, "AC-3")?; + } + if acmod == 0x02 { + skip_bits_labeled(&mut reader, 2, spec, "AC-3")?; + } + let lfe_on = u8::from(read_bit_labeled(&mut reader, spec, "AC-3")?); + let sample_rate = ac3_sample_rate(fscod).ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported AC-3 sample-rate code {fscod}"), + })?; + let channel_count = ac3_sample_entry_channel_count(acmod, lfe_on != 0).ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported AC-3 channel mode {acmod}"), + } + })?; + Ok(( + Ac3DecoderConfig { + sample_rate, + channel_count, + fscod: match sample_rate { + 48_000 => 0, + 44_100 => 1, + 32_000 => 2, + _ => unreachable!(), + }, + bsid, + bsmod, + acmod, + lfe_on, + bit_rate_code: frmsizecod >> 1, + }, + frame_size, + )) +} + +pub(in crate::mux) fn build_ac3_sample_entry_box( + parsed: &Ac3DecoderConfig, + samples: &[StagedSample], +) -> Result, MuxError> { + build_ac3_sample_entry_box_from_sample_iter( + parsed, + parsed.sample_rate, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + ) +} + +pub(in crate::mux) fn build_ac3_sample_entry_box_with_btrt( + parsed: &Ac3DecoderConfig, + sample_rate: u32, + samples: I, +) -> Result, MuxError> +where + I: IntoIterator, +{ + build_ac3_sample_entry_box_from_sample_iter(parsed, sample_rate, samples) +} + +fn build_ac3_sample_entry_box_from_sample_iter( + parsed: &Ac3DecoderConfig, + sample_rate: u32, + samples: I, +) -> Result, MuxError> +where + I: IntoIterator, +{ + let mut sample_entry = AudioSampleEntry::default(); + sample_entry.set_box_type(FourCc::from_bytes(*b"ac-3")); + sample_entry.sample_entry = SampleEntry { + box_type: FourCc::from_bytes(*b"ac-3"), + data_reference_index: 1, + }; + sample_entry.channel_count = 2; + sample_entry.sample_size = 16; + sample_entry.sample_rate = parsed.sample_rate << 16; + + let dac3 = super::super::mp4::encode_typed_box( + &Dac3 { + fscod: parsed.fscod, + bsid: parsed.bsid, + bsmod: parsed.bsmod, + acmod: parsed.acmod, + lfe_on: parsed.lfe_on, + bit_rate_code: parsed.bit_rate_code, + }, + &[], + )?; + let btrt = super::super::mp4::encode_typed_box(&build_ac3_btrt(samples, sample_rate)?, &[])?; + let mut children = dac3; + children.extend_from_slice(&btrt); + super::super::mp4::encode_typed_box(&sample_entry, &children) +} + +fn build_ac3_btrt(samples: I, sample_rate: u32) -> Result +where + I: IntoIterator, +{ + if sample_rate == 0 { + return Ok(Btrt::default()); + } + + let mut buffer_size_db = 0_u32; + let mut total_payload_bytes = 0_u64; + let mut total_duration = 0_u64; + let mut max_window_payload_bytes = 0_u64; + let mut current_window_payload_bytes = 0_u64; + let mut window_start_decode_time = 0_u64; + let mut sample_decode_time = 0_u64; + let mut saw_sample = false; + + for (data_size, duration) in samples { + saw_sample = true; + buffer_size_db = buffer_size_db.max(data_size); + total_payload_bytes = total_payload_bytes + .checked_add(u64::from(data_size)) + .ok_or(MuxError::LayoutOverflow("AC-3 total payload bytes"))?; + total_duration = total_duration + .checked_add(u64::from(duration)) + .ok_or(MuxError::LayoutOverflow("AC-3 total duration"))?; + current_window_payload_bytes = current_window_payload_bytes + .checked_add(u64::from(data_size)) + .ok_or(MuxError::LayoutOverflow("AC-3 bitrate window payload"))?; + if sample_decode_time > window_start_decode_time.saturating_add(u64::from(sample_rate)) { + max_window_payload_bytes = max_window_payload_bytes.max(current_window_payload_bytes); + window_start_decode_time = sample_decode_time; + current_window_payload_bytes = 0; + } + sample_decode_time = sample_decode_time + .checked_add(u64::from(duration)) + .ok_or(MuxError::LayoutOverflow("AC-3 decode time"))?; + } + + if !saw_sample { + return Ok(Btrt::default()); + } + + if total_duration == 0 { + return Ok(Btrt::default()); + } + + let avg_bitrate = total_payload_bytes + .checked_mul(8) + .and_then(|bits| bits.checked_mul(u64::from(sample_rate))) + .ok_or(MuxError::LayoutOverflow("AC-3 average bitrate"))? + / total_duration; + let avg_bitrate = avg_bitrate & !7; + let max_bitrate = if max_window_payload_bytes == 0 { + avg_bitrate + } else { + max_window_payload_bytes + .checked_mul(8) + .ok_or(MuxError::LayoutOverflow("AC-3 maximum bitrate"))? + }; + + Ok(Btrt { + buffer_size_db, + max_bitrate: u32::try_from(max_bitrate) + .map_err(|_| MuxError::LayoutOverflow("AC-3 maximum bitrate"))?, + avg_bitrate: u32::try_from(avg_bitrate) + .map_err(|_| MuxError::LayoutOverflow("AC-3 average bitrate"))?, + }) +} + +const fn ac3_sample_rate(fscod: u8) -> Option { + match fscod { + 0 => Some(48_000), + 1 => Some(44_100), + 2 => Some(32_000), + _ => None, + } +} + +fn ac3_frame_size_bytes(fscod: u8, frmsizecod: u8) -> Option { + const AC3_FRAME_SIZE_WORDS: [[u16; 3]; 38] = [ + [96, 69, 64], + [96, 70, 64], + [120, 87, 80], + [120, 88, 80], + [144, 104, 96], + [144, 105, 96], + [168, 121, 112], + [168, 122, 112], + [192, 139, 128], + [192, 140, 128], + [240, 174, 160], + [240, 175, 160], + [288, 208, 192], + [288, 209, 192], + [336, 243, 224], + [336, 244, 224], + [384, 278, 256], + [384, 279, 256], + [480, 348, 320], + [480, 349, 320], + [576, 417, 384], + [576, 418, 384], + [672, 487, 448], + [672, 488, 448], + [768, 557, 512], + [768, 558, 512], + [960, 696, 640], + [960, 697, 640], + [1152, 835, 768], + [1152, 836, 768], + [1344, 975, 896], + [1344, 976, 896], + [1536, 1114, 1024], + [1536, 1115, 1024], + [1728, 1253, 1152], + [1728, 1254, 1152], + [1920, 1393, 1280], + [1920, 1394, 1280], + ]; + let frame_words = *AC3_FRAME_SIZE_WORDS.get(usize::from(frmsizecod))?; + let sample_rate_index = match fscod { + 0 => 2, + 1 => 1, + 2 => 0, + _ => return None, + }; + Some(u32::from(frame_words[sample_rate_index]) * 2) +} + +const fn ac3_sample_entry_channel_count(acmod: u8, _lfe_on: bool) -> Option { + Some(match acmod { + 0 => 2, + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 3, + 5 => 4, + 6 => 4, + 7 => 5, + _ => return None, + }) +} + +fn skip_bits_labeled( + reader: &mut BitReader, + width: usize, + spec: &str, + label: &str, +) -> Result<(), MuxError> +where + R: std::io::Read, +{ + let _ = reader + .read_bits(width) + .map_err(|error| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("failed to read {label} bitstream: {error}"), + })?; + Ok(()) +} + +fn read_bit_labeled(reader: &mut BitReader, spec: &str, label: &str) -> Result +where + R: std::io::Read, +{ + reader + .read_bit() + .map_err(|error| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("failed to read {label} bitstream: {error}"), + }) +} + +fn read_bits_u8_labeled( + reader: &mut BitReader, + width: usize, + spec: &str, + label: &str, +) -> Result +where + R: std::io::Read, +{ + let bits = reader + .read_bits(width) + .map_err(|error| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("failed to read {label} bitstream: {error}"), + })?; + let mut value = 0_u16; + for byte in bits { + value = (value << 8) | u16::from(byte); + } + u8::try_from(value).map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("{label} bitfield does not fit in u8"), + }) +} diff --git a/src/mux/demux/ac4.rs b/src/mux/demux/ac4.rs new file mode 100644 index 0000000..6b3382e --- /dev/null +++ b/src/mux/demux/ac4.rs @@ -0,0 +1,1966 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::bitio::BitWriter; +use crate::boxes::AnyTypeBox; +use crate::boxes::etsi_ts_103_190::Dac4; +use crate::boxes::iso14496_12::{AudioSampleEntry, Btrt, SampleEntry}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{SegmentedMuxSourceSegment, StagedSample, read_exact_at_sync}; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::read_segmented_bytes_sync; +pub(in crate::mux) struct ParsedAc4Track { + pub(in crate::mux) media_time_scale: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +#[derive(Clone, Copy, Debug)] +struct Ac4FrameHeader { + header_size: u64, + frame_payload_size: u64, + total_frame_size: u64, +} + +#[derive(Clone, Debug)] +struct ParsedAc4Stream { + bitstream_version: u8, + fs_index: u8, + frame_rate_index: u8, + has_program_id: bool, + short_program_id: u16, + program_uuid: Option<[u8; 16]>, + bit_rate_mode: u8, + presentations: Vec, + sample_rate: u32, + sample_duration: u32, + media_time_scale: u32, + channel_count: u16, +} + +#[derive(Clone, Debug)] +struct ParsedAc4Presentation { + presentation_version: u8, + mdcompat: u8, + has_presentation_id: bool, + presentation_id: u16, + frame_rate_multiply_info: u8, + frame_rate_fraction_info: u8, + emdf_version: u8, + key_id: u16, + has_presentation_filter: bool, + enable_presentation: bool, + group_index: u32, + group: Option, + pre_virtualized: bool, + add_emdf_substreams: Vec, + presentation_channel_mode: u8, + presentation_channel_mask: u32, + four_back_channels_present: bool, + top_channel_pairs: u8, +} + +#[derive(Clone, Debug)] +struct ParsedAc4SubstreamGroup { + substreams_present: bool, + high_sample_rate_extension: bool, + substreams: Vec, + content_type: Option, +} + +#[derive(Clone, Debug)] +struct ParsedAc4Substream { + dsi_sf_multiplier: u8, + has_substream_bitrate_indicator: bool, + substream_bitrate_indicator: u8, + channel_mask: u32, + channel_mode: u8, + four_back_channels_present: bool, + top_channel_pairs: u8, +} + +#[derive(Clone, Debug)] +struct ParsedAc4ContentType { + classifier: u8, + language_tag_bytes: Vec, +} + +#[derive(Clone, Copy, Debug)] +struct ParsedAc4EmdfInfo { + version: u8, + key_id: u16, +} + +struct Ac4BitCursor<'a> { + data: &'a [u8], + bit_offset: usize, +} + +impl<'a> Ac4BitCursor<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + bit_offset: 0, + } + } + + fn read_bits(&mut self, width: usize, spec: &str, context: &str) -> Result { + if width > 32 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("AC-4 parser requested invalid bit width {width} for {context}"), + }); + } + let end = self + .bit_offset + .checked_add(width) + .ok_or(MuxError::LayoutOverflow("AC-4 bit reader position"))?; + if end > self.data.len() * 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated AC-4 data while reading {context}"), + }); + } + + let mut value = 0_u32; + for _ in 0..width { + let byte = self.data[self.bit_offset / 8]; + let shift = 7 - (self.bit_offset % 8); + value = (value << 1) | u32::from((byte >> shift) & 0x01); + self.bit_offset += 1; + } + Ok(value) + } + + fn read_bool(&mut self, spec: &str, context: &str) -> Result { + Ok(self.read_bits(1, spec, context)? != 0) + } + + fn skip_bits(&mut self, width: usize, spec: &str, context: &str) -> Result<(), MuxError> { + let _ = self.read_bits(width, spec, context)?; + Ok(()) + } +} + +const AC4_SAMPLE_RATE_TABLE: [u32; 2] = [44_100, 48_000]; +const AC4_SAMPLE_DELTA_TABLE_48: [u32; 14] = [ + 2002, 2000, 1920, 8008, 1600, 1001, 1000, 960, 4004, 800, 480, 2002, 400, 2048, +]; +const AC4_SAMPLE_DELTA_TABLE_441: [u32; 14] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2048]; +const AC4_MEDIA_TIMESCALE_48: [u32; 14] = [ + 48_000, 48_000, 48_000, 240_000, 48_000, 48_000, 48_000, 48_000, 240_000, 48_000, 48_000, + 240_000, 48_000, 48_000, +]; +const AC4_MEDIA_TIMESCALE_441: [u32; 14] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 44_100]; +const AC4_CHANNEL_MASK_BY_MODE: [u32; 17] = [ + 0x2, 0x1, 0x3, 0x7, 0x47, 0x0f, 0x4f, 0x20007, 0x20047, 0x40007, 0x40047, 0x3f, 0x7f, 0x1003f, + 0x1007f, 0x2ff7f, 0, +]; +pub(in crate::mux) fn scan_ac4_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let first_frame = read_ac4_frame_header_sync(&mut file, file_size, 0, spec)?; + let first_payload = read_ac4_frame_payload_sync(&mut file, 0, first_frame, spec)?; + let parsed_stream = parse_ac4_stream(&first_payload, spec)?; + let first_is_sync_sample = read_ac4_frame_sync_flag(&first_payload, spec)?; + let mut offset = 0_u64; + let mut samples = Vec::new(); + while offset < file_size { + let frame = read_ac4_frame_header_sync(&mut file, file_size, offset, spec)?; + let is_sync_sample = if offset == 0 { + first_is_sync_sample + } else { + read_ac4_frame_sync_flag( + &read_ac4_frame_payload_sync(&mut file, offset, frame, spec)?, + spec, + )? + }; + samples.push(StagedSample { + data_offset: offset + .checked_add(frame.header_size) + .ok_or(MuxError::LayoutOverflow("AC-4 payload offset"))?, + data_size: u32::try_from(frame.frame_payload_size) + .map_err(|_| MuxError::LayoutOverflow("AC-4 frame payload size"))?, + duration: parsed_stream.sample_duration, + composition_time_offset: 0, + is_sync_sample, + }); + offset = offset + .checked_add(frame.total_frame_size) + .ok_or(MuxError::LayoutOverflow("AC-4 frame offset"))?; + } + if samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-4 input contained no syncframes".to_string(), + }); + } + Ok(ParsedAc4Track { + media_time_scale: parsed_stream.media_time_scale, + sample_entry_box: build_ac4_sample_entry_box( + parsed_stream.channel_count, + parsed_stream.sample_rate, + parsed_stream.media_time_scale, + &samples, + &serialize_ac4_dac4(&parsed_stream, spec)?, + )?, + samples, + }) +} + +pub(in crate::mux) fn scan_ac4_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let first_frame = read_ac4_frame_header_segmented_sync(file, segments, total_size, 0, spec)?; + let first_payload = + read_ac4_frame_payload_segmented_sync(file, segments, total_size, 0, first_frame, spec)?; + let parsed_stream = parse_ac4_stream(&first_payload, spec)?; + let first_is_sync_sample = read_ac4_frame_sync_flag(&first_payload, spec)?; + let mut offset = 0_u64; + let mut samples = Vec::new(); + while offset < total_size { + let frame = read_ac4_frame_header_segmented_sync(file, segments, total_size, offset, spec)?; + let is_sync_sample = if offset == 0 { + first_is_sync_sample + } else { + read_ac4_frame_sync_flag( + &read_ac4_frame_payload_segmented_sync( + file, segments, total_size, offset, frame, spec, + )?, + spec, + )? + }; + samples.push(StagedSample { + data_offset: offset + .checked_add(frame.header_size) + .ok_or(MuxError::LayoutOverflow("AC-4 payload offset"))?, + data_size: u32::try_from(frame.frame_payload_size) + .map_err(|_| MuxError::LayoutOverflow("AC-4 frame payload size"))?, + duration: parsed_stream.sample_duration, + composition_time_offset: 0, + is_sync_sample, + }); + offset = offset + .checked_add(frame.total_frame_size) + .ok_or(MuxError::LayoutOverflow("AC-4 frame offset"))?; + } + if samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-4 input contained no syncframes".to_string(), + }); + } + Ok(ParsedAc4Track { + media_time_scale: parsed_stream.media_time_scale, + sample_entry_box: build_ac4_sample_entry_box( + parsed_stream.channel_count, + parsed_stream.sample_rate, + parsed_stream.media_time_scale, + &samples, + &serialize_ac4_dac4(&parsed_stream, spec)?, + )?, + samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_ac4_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let first_frame = read_ac4_frame_header_async(&mut file, file_size, 0, spec).await?; + let first_payload = read_ac4_frame_payload_async(&mut file, 0, first_frame, spec).await?; + let parsed_stream = parse_ac4_stream(&first_payload, spec)?; + let first_is_sync_sample = read_ac4_frame_sync_flag(&first_payload, spec)?; + let mut offset = 0_u64; + let mut samples = Vec::new(); + while offset < file_size { + let frame = read_ac4_frame_header_async(&mut file, file_size, offset, spec).await?; + let is_sync_sample = if offset == 0 { + first_is_sync_sample + } else { + read_ac4_frame_sync_flag( + &read_ac4_frame_payload_async(&mut file, offset, frame, spec).await?, + spec, + )? + }; + samples.push(StagedSample { + data_offset: offset + .checked_add(frame.header_size) + .ok_or(MuxError::LayoutOverflow("AC-4 payload offset"))?, + data_size: u32::try_from(frame.frame_payload_size) + .map_err(|_| MuxError::LayoutOverflow("AC-4 frame payload size"))?, + duration: parsed_stream.sample_duration, + composition_time_offset: 0, + is_sync_sample, + }); + offset = offset + .checked_add(frame.total_frame_size) + .ok_or(MuxError::LayoutOverflow("AC-4 frame offset"))?; + } + if samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-4 input contained no syncframes".to_string(), + }); + } + Ok(ParsedAc4Track { + media_time_scale: parsed_stream.media_time_scale, + sample_entry_box: build_ac4_sample_entry_box( + parsed_stream.channel_count, + parsed_stream.sample_rate, + parsed_stream.media_time_scale, + &samples, + &serialize_ac4_dac4(&parsed_stream, spec)?, + )?, + samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_ac4_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let first_frame = + read_ac4_frame_header_segmented_async(file, segments, total_size, 0, spec).await?; + let first_payload = + read_ac4_frame_payload_segmented_async(file, segments, total_size, 0, first_frame, spec) + .await?; + let parsed_stream = parse_ac4_stream(&first_payload, spec)?; + let first_is_sync_sample = read_ac4_frame_sync_flag(&first_payload, spec)?; + let mut offset = 0_u64; + let mut samples = Vec::new(); + while offset < total_size { + let frame = + read_ac4_frame_header_segmented_async(file, segments, total_size, offset, spec).await?; + let is_sync_sample = if offset == 0 { + first_is_sync_sample + } else { + read_ac4_frame_sync_flag( + &read_ac4_frame_payload_segmented_async( + file, segments, total_size, offset, frame, spec, + ) + .await?, + spec, + )? + }; + samples.push(StagedSample { + data_offset: offset + .checked_add(frame.header_size) + .ok_or(MuxError::LayoutOverflow("AC-4 payload offset"))?, + data_size: u32::try_from(frame.frame_payload_size) + .map_err(|_| MuxError::LayoutOverflow("AC-4 frame payload size"))?, + duration: parsed_stream.sample_duration, + composition_time_offset: 0, + is_sync_sample, + }); + offset = offset + .checked_add(frame.total_frame_size) + .ok_or(MuxError::LayoutOverflow("AC-4 frame offset"))?; + } + if samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-4 input contained no syncframes".to_string(), + }); + } + Ok(ParsedAc4Track { + media_time_scale: parsed_stream.media_time_scale, + sample_entry_box: build_ac4_sample_entry_box( + parsed_stream.channel_count, + parsed_stream.sample_rate, + parsed_stream.media_time_scale, + &samples, + &serialize_ac4_dac4(&parsed_stream, spec)?, + )?, + samples, + }) +} + +fn read_ac4_frame_header_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, +) -> Result { + let mut header = [0_u8; 7]; + if file_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated AC-4 syncframe header".to_string(), + }); + } + read_exact_at_sync( + file, + offset, + &mut header[..4], + spec, + "truncated AC-4 syncframe header", + )?; + if u16::from_be_bytes([header[2], header[3]]) == 0xFFFF { + if file_size - offset < 7 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated extended AC-4 syncframe header".to_string(), + }); + } + read_exact_at_sync( + file, + offset, + &mut header, + spec, + "truncated extended AC-4 syncframe header", + )?; + } + parse_ac4_frame_header(&header, file_size, offset, spec) +} + +fn read_ac4_frame_header_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + offset: u64, + spec: &str, +) -> Result { + let mut header = [0_u8; 7]; + if total_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated AC-4 syncframe header".to_string(), + }); + } + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut header[..4], + spec, + "truncated AC-4 syncframe header", + )?; + if u16::from_be_bytes([header[2], header[3]]) == 0xFFFF { + if total_size - offset < 7 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated extended AC-4 syncframe header".to_string(), + }); + } + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated extended AC-4 syncframe header", + )?; + } + parse_ac4_frame_header(&header, total_size, offset, spec) +} + +#[cfg(feature = "async")] +async fn read_ac4_frame_header_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, +) -> Result { + let mut header = [0_u8; 7]; + if file_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated AC-4 syncframe header".to_string(), + }); + } + read_exact_at_async( + file, + offset, + &mut header[..4], + spec, + "truncated AC-4 syncframe header", + ) + .await?; + if u16::from_be_bytes([header[2], header[3]]) == 0xFFFF { + if file_size - offset < 7 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated extended AC-4 syncframe header".to_string(), + }); + } + read_exact_at_async( + file, + offset, + &mut header, + spec, + "truncated extended AC-4 syncframe header", + ) + .await?; + } + parse_ac4_frame_header(&header, file_size, offset, spec) +} + +#[cfg(feature = "async")] +async fn read_ac4_frame_header_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + offset: u64, + spec: &str, +) -> Result { + let mut header = [0_u8; 7]; + if total_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated AC-4 syncframe header".to_string(), + }); + } + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut header[..4], + spec, + "truncated AC-4 syncframe header", + ) + .await?; + if u16::from_be_bytes([header[2], header[3]]) == 0xFFFF { + if total_size - offset < 7 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated extended AC-4 syncframe header".to_string(), + }); + } + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated extended AC-4 syncframe header", + ) + .await?; + } + parse_ac4_frame_header(&header, total_size, offset, spec) +} + +fn parse_ac4_frame_header( + header: &[u8; 7], + file_size: u64, + offset: u64, + spec: &str, +) -> Result { + let syncword = u16::from_be_bytes([header[0], header[1]]); + if syncword != 0xAC40 && syncword != 0xAC41 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing AC-4 sync word at byte offset {offset}"), + }); + } + let size_code = u16::from_be_bytes([header[2], header[3]]); + let (header_size, frame_payload_size) = if size_code == 0xFFFF { + ( + 7_u64, + u64::from(header[4]) << 16 | u64::from(header[5]) << 8 | u64::from(header[6]), + ) + } else { + (4_u64, u64::from(size_code)) + }; + let crc_size = if syncword == 0xAC41 { 2_u64 } else { 0_u64 }; + let mut total_frame_size = header_size + .checked_add(frame_payload_size) + .and_then(|size| size.checked_add(crc_size)) + .ok_or(MuxError::LayoutOverflow("AC-4 frame size"))?; + if total_frame_size <= header_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-4 syncframes must carry payload bytes".to_string(), + }); + } + if offset + .checked_add(total_frame_size) + .is_none_or(|end| end > file_size) + { + if size_code != 0xFFFF { + let alternate_frame_size = u64::from(size_code) + .checked_add(2) + .and_then(|size| size.checked_add(crc_size)) + .ok_or(MuxError::LayoutOverflow("AC-4 alternate frame size"))?; + if alternate_frame_size > header_size + && offset + .checked_add(alternate_frame_size) + .is_some_and(|end| end <= file_size) + { + total_frame_size = alternate_frame_size; + } else { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated AC-4 syncframe at byte offset {offset}"), + }); + } + } else { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated AC-4 syncframe at byte offset {offset}"), + }); + } + } + Ok(Ac4FrameHeader { + header_size, + frame_payload_size, + total_frame_size, + }) +} + +fn read_ac4_frame_payload_sync( + file: &mut File, + offset: u64, + header: Ac4FrameHeader, + spec: &str, +) -> Result, MuxError> { + let mut payload = vec![ + 0_u8; + usize::try_from(header.frame_payload_size) + .map_err(|_| MuxError::LayoutOverflow("AC-4 frame payload size"))? + ]; + read_exact_at_sync( + file, + offset + .checked_add(header.header_size) + .ok_or(MuxError::LayoutOverflow("AC-4 payload offset"))?, + &mut payload, + spec, + "truncated AC-4 frame payload", + )?; + Ok(payload) +} + +fn read_ac4_frame_payload_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + offset: u64, + header: Ac4FrameHeader, + spec: &str, +) -> Result, MuxError> { + let mut payload = vec![ + 0_u8; + usize::try_from(header.frame_payload_size) + .map_err(|_| MuxError::LayoutOverflow("AC-4 frame payload size"))? + ]; + read_segmented_bytes_sync( + file, + segments, + total_size, + offset + .checked_add(header.header_size) + .ok_or(MuxError::LayoutOverflow("AC-4 payload offset"))?, + &mut payload, + spec, + "truncated AC-4 frame payload", + )?; + Ok(payload) +} + +#[cfg(feature = "async")] +async fn read_ac4_frame_payload_async( + file: &mut TokioFile, + offset: u64, + header: Ac4FrameHeader, + spec: &str, +) -> Result, MuxError> { + let mut payload = vec![ + 0_u8; + usize::try_from(header.frame_payload_size) + .map_err(|_| MuxError::LayoutOverflow("AC-4 frame payload size"))? + ]; + read_exact_at_async( + file, + offset + .checked_add(header.header_size) + .ok_or(MuxError::LayoutOverflow("AC-4 payload offset"))?, + &mut payload, + spec, + "truncated AC-4 frame payload", + ) + .await?; + Ok(payload) +} + +#[cfg(feature = "async")] +async fn read_ac4_frame_payload_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + offset: u64, + header: Ac4FrameHeader, + spec: &str, +) -> Result, MuxError> { + let mut payload = vec![ + 0_u8; + usize::try_from(header.frame_payload_size) + .map_err(|_| MuxError::LayoutOverflow("AC-4 frame payload size"))? + ]; + read_segmented_bytes_async( + file, + segments, + total_size, + offset + .checked_add(header.header_size) + .ok_or(MuxError::LayoutOverflow("AC-4 payload offset"))?, + &mut payload, + spec, + "truncated AC-4 frame payload", + ) + .await?; + Ok(payload) +} + +fn parse_ac4_stream(frame_payload: &[u8], spec: &str) -> Result { + let mut reader = Ac4BitCursor::new(frame_payload); + let bitstream_version = u8::try_from(read_ac4_variable_bits_prefixed( + &mut reader, + spec, + "bitstream_version", + 2, + Some(3), + )?) + .map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-4 bitstream version does not fit in u8".to_string(), + })?; + if bitstream_version <= 1 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "path-only AC-4 import currently requires bitstream_version > 1".to_string(), + }); + } + + let _sequence_counter = reader.read_bits(10, spec, "sequence_counter")?; + let wait_frames = if reader.read_bool(spec, "b_wait_frames")? { + let wait_frames = reader.read_bits(3, spec, "wait_frames")?; + if wait_frames > 0 { + reader.skip_bits(2, spec, "wait_frames reserved bits")?; + } + Some(wait_frames) + } else { + None + }; + let fs_index = u8::try_from(reader.read_bits(1, spec, "fs_index")?).unwrap(); + let frame_rate_index = usize::try_from(reader.read_bits(4, spec, "frame_rate_index")?) + .map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-4 frame_rate_index does not fit in usize".to_string(), + })?; + let _iframe_global = reader.read_bool(spec, "b_iframe_global")?; + let presentation_count = if reader.read_bool(spec, "b_single_presentation")? { + 1_usize + } else if reader.read_bool(spec, "b_more_presentations")? { + usize::try_from(read_ac4_variable_bits(&mut reader, spec, "n_presentations", 2)? + 2) + .map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-4 presentation count does not fit in usize".to_string(), + })? + } else { + 0 + }; + if presentation_count == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-4 inputs without presentations are not supported".to_string(), + }); + } + + if reader.read_bool(spec, "b_payload_base")? { + let payload_base = reader.read_bits(5, spec, "payload_base_minus1")? + 1; + if payload_base == 0x20 { + let _ = read_ac4_variable_bits(&mut reader, spec, "payload_base extension", 3)?; + } + } + + let has_program_id = reader.read_bool(spec, "b_program_id")?; + let (short_program_id, program_uuid) = if has_program_id { + let short_program_id = + u16::try_from(reader.read_bits(16, spec, "short_program_id")?).unwrap(); + let program_uuid = if reader.read_bool(spec, "b_program_uuid_present")? { + let mut uuid = [0_u8; 16]; + for byte in &mut uuid { + *byte = u8::try_from(reader.read_bits(8, spec, "program_uuid")?).unwrap(); + } + Some(uuid) + } else { + None + }; + (short_program_id, program_uuid) + } else { + (0, None) + }; + + let sample_rate = *AC4_SAMPLE_RATE_TABLE + .get(usize::from(fs_index)) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported AC-4 sampling frequency index {fs_index}"), + })?; + let (sample_duration, media_time_scale) = + ac4_timing_from_frame_rate_index(fs_index, frame_rate_index, spec)?; + let bit_rate_mode = match wait_frames { + Some(0) => 1, + Some(1..=6) => 2, + Some(_) => 3, + None => 0, + }; + + let mut presentations = Vec::with_capacity(presentation_count); + for presentation_index in 0..presentation_count { + presentations.push(parse_ac4_presentation( + &mut reader, + spec, + bitstream_version, + fs_index, + frame_rate_index, + presentation_index, + )?); + } + let mut referenced_group_indices = Vec::new(); + for presentation in &presentations { + if !referenced_group_indices.contains(&presentation.group_index) { + referenced_group_indices.push(presentation.group_index); + } + } + let mut parsed_groups = Vec::with_capacity(referenced_group_indices.len()); + for group_index in &referenced_group_indices { + let group_presentation = presentations + .iter() + .find(|presentation| presentation.group_index == *group_index) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "AC-4 substream group {group_index} is not referenced by any presentation" + ), + })?; + let group_frame_rate_factor = match group_presentation.frame_rate_multiply_info { + 0 => 1, + value => u32::from(value) * 2, + }; + let parsed_group = parse_ac4_substream_group( + &mut reader, + spec, + group_frame_rate_factor, + fs_index, + group_presentation.presentation_version, + )?; + parsed_groups.push(parsed_group); + } + let default_speaker_group_mask = presentations + .first() + .and_then(|presentation| { + referenced_group_indices + .iter() + .position(|group_index| *group_index == presentation.group_index) + .and_then(|position| parsed_groups.get(position)) + }) + .map(|group| { + group + .substreams + .iter() + .fold(0_u32, |mask, substream| mask | substream.channel_mask) + }) + .unwrap_or(0); + for presentation in &mut presentations { + let group_position = referenced_group_indices + .iter() + .position(|group_index| *group_index == presentation.group_index) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "AC-4 presentation references unknown substream group {}", + presentation.group_index + ), + })?; + presentation.group = Some(parsed_groups[group_position].clone()); + populate_ac4_presentation_channels(presentation); + normalize_ac4_presentation_for_dsi(presentation); + } + append_legacy_ac4_presentations(&mut presentations); + + let presentation = presentations + .first() + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-4 input contained no supported presentations".to_string(), + })?; + let channel_count = if default_speaker_group_mask == 0 { + ac4_channel_count_from_mask(presentation.presentation_channel_mask)? + } else { + ac4_channel_count_from_mask(default_speaker_group_mask)? + }; + + Ok(ParsedAc4Stream { + bitstream_version, + fs_index, + frame_rate_index: u8::try_from(frame_rate_index).unwrap(), + has_program_id, + short_program_id, + program_uuid, + bit_rate_mode, + presentations, + sample_rate, + sample_duration, + media_time_scale, + channel_count, + }) +} + +fn read_ac4_frame_sync_flag(frame_payload: &[u8], spec: &str) -> Result { + let mut reader = Ac4BitCursor::new(frame_payload); + let bitstream_version = u8::try_from(read_ac4_variable_bits_prefixed( + &mut reader, + spec, + "bitstream_version", + 2, + Some(3), + )?) + .map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-4 bitstream version does not fit in u8".to_string(), + })?; + if bitstream_version <= 1 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "path-only AC-4 import currently requires bitstream_version > 1".to_string(), + }); + } + let _sequence_counter = reader.read_bits(10, spec, "sequence_counter")?; + if reader.read_bool(spec, "b_wait_frames")? { + let wait_frames = reader.read_bits(3, spec, "wait_frames")?; + if wait_frames > 0 { + reader.skip_bits(2, spec, "wait_frames reserved bits")?; + } + } + let _fs_index = reader.read_bits(1, spec, "fs_index")?; + let _frame_rate_index = reader.read_bits(4, spec, "frame_rate_index")?; + reader.read_bool(spec, "b_iframe_global") +} + +fn parse_ac4_presentation( + reader: &mut Ac4BitCursor<'_>, + spec: &str, + bitstream_version: u8, + fs_index: u8, + frame_rate_index: usize, + presentation_index: usize, +) -> Result { + let single_substream_group = reader.read_bool(spec, "b_single_substream_group")?; + if !single_substream_group { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "AC-4 presentation {} uses multiple substream groups; path-only AC-4 import currently supports only single-group presentations", + presentation_index + 1 + ), + }); + } + + let presentation_version = parse_ac4_presentation_version(reader, spec)?; + if presentation_version > 2 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "AC-4 presentation {} uses unsupported presentation_version {}", + presentation_index + 1, + presentation_version + ), + }); + } + + let mdcompat = u8::try_from(reader.read_bits(3, spec, "mdcompat")?).unwrap(); + let has_presentation_id = reader.read_bool(spec, "b_presentation_id")?; + let presentation_id = if has_presentation_id { + u16::try_from(read_ac4_variable_bits(reader, spec, "presentation_id", 2)?).unwrap() + } else { + 0 + }; + if presentation_id > 31 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "AC-4 presentation {} uses presentation_id {} larger than the currently supported path-only DSI writer can represent", + presentation_index + 1, + presentation_id + ), + }); + } + + let frame_rate_multiply_info = + parse_ac4_frame_rate_multiply_info(reader, spec, frame_rate_index)?; + let frame_rate_fraction_info = + parse_ac4_frame_rate_fraction_info(reader, spec, frame_rate_index)?; + let emdf_info = parse_ac4_emdf_info(reader, spec)?; + let has_presentation_filter = reader.read_bool(spec, "b_presentation_filter")?; + let enable_presentation = if has_presentation_filter { + reader.read_bool(spec, "b_enable_presentation")? + } else { + false + }; + let _ = fs_index; + let group_index = parse_ac4_group_index(reader, spec, bitstream_version)?; + let mut pre_virtualized = reader.read_bool(spec, "b_pre_virtualized")?; + if presentation_version == 2 { + pre_virtualized = true; + } + let has_add_emdf_substreams = reader.read_bool(spec, "b_add_emdf_substreams")?; + skip_ac4_presentation_substream_info(reader, spec)?; + + let mut add_emdf_substreams = Vec::new(); + if has_add_emdf_substreams { + let count = { + let raw = reader.read_bits(2, spec, "n_add_emdf_substreams")?; + if raw == 0 { + usize::try_from( + read_ac4_variable_bits(reader, spec, "n_add_emdf_substreams extension", 2)? + 4, + ) + .map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-4 add_emdf_substreams count does not fit in usize".to_string(), + })? + } else { + usize::try_from(raw).unwrap() + } + }; + for _ in 0..count { + add_emdf_substreams.push(parse_ac4_emdf_info(reader, spec)?); + } + } + + Ok(ParsedAc4Presentation { + presentation_version, + mdcompat, + has_presentation_id, + presentation_id, + frame_rate_multiply_info, + frame_rate_fraction_info, + emdf_version: emdf_info.version, + key_id: emdf_info.key_id, + has_presentation_filter, + enable_presentation, + group_index, + group: None, + pre_virtualized, + add_emdf_substreams, + presentation_channel_mode: 0, + presentation_channel_mask: 0, + four_back_channels_present: false, + top_channel_pairs: 0, + }) +} + +fn populate_ac4_presentation_channels(presentation: &mut ParsedAc4Presentation) { + let Some(group) = &presentation.group else { + return; + }; + let mut substreams = group.substreams.iter(); + let Some(first_substream) = substreams.next() else { + return; + }; + let mut presentation_channel_mode = first_substream.channel_mode; + let mut presentation_channel_mask = first_substream.channel_mask; + let mut four_back_channels_present = first_substream.four_back_channels_present; + let mut top_channel_pairs = first_substream.top_channel_pairs; + for substream in substreams { + presentation_channel_mode = presentation_channel_mode.max(substream.channel_mode); + presentation_channel_mask |= substream.channel_mask; + four_back_channels_present |= substream.four_back_channels_present; + top_channel_pairs = top_channel_pairs.max(substream.top_channel_pairs); + } + if presentation_channel_mask == 0x03 { + presentation_channel_mask = 0x01; + } + if (presentation_channel_mask & 0x30) != 0 && (presentation_channel_mask & 0x80) != 0 { + presentation_channel_mask &= !0x80; + } + presentation.presentation_channel_mode = presentation_channel_mode; + presentation.presentation_channel_mask = presentation_channel_mask; + presentation.four_back_channels_present = four_back_channels_present; + presentation.top_channel_pairs = top_channel_pairs; +} + +fn normalize_ac4_presentation_for_dsi(presentation: &mut ParsedAc4Presentation) { + let uses_stereo_fallback = presentation.presentation_channel_mode == 0 + && presentation.presentation_channel_mask == 0x02; + presentation.has_presentation_filter = false; + presentation.enable_presentation = false; + presentation.add_emdf_substreams.clear(); + presentation.presentation_channel_mode = ac4_channel_mode_from_mask( + presentation.presentation_channel_mask, + presentation.presentation_channel_mode, + ); + if presentation.top_channel_pairs == 0 && (presentation.presentation_channel_mask & 0x80) != 0 { + presentation.top_channel_pairs = 1; + } + if uses_stereo_fallback { + presentation.presentation_channel_mode = 1; + presentation.presentation_channel_mask = 0x01; + } + + if let Some(group) = &mut presentation.group { + group.substreams_present = true; + group.high_sample_rate_extension = false; + group.content_type = None; + group.substreams.clear(); + group.substreams.push(ParsedAc4Substream { + dsi_sf_multiplier: 0, + has_substream_bitrate_indicator: false, + substream_bitrate_indicator: 0, + channel_mask: presentation.presentation_channel_mask, + channel_mode: presentation.presentation_channel_mode, + four_back_channels_present: presentation.four_back_channels_present, + top_channel_pairs: presentation.top_channel_pairs, + }); + if uses_stereo_fallback && group.content_type.is_none() { + group.content_type = Some(ParsedAc4ContentType { + classifier: 0, + language_tag_bytes: Vec::new(), + }); + } + } +} + +fn append_legacy_ac4_presentations(presentations: &mut Vec) { + if presentations + .iter() + .any(|presentation| presentation.presentation_version == 1) + { + return; + } + + let legacy = presentations + .iter() + .filter(|presentation| presentation.presentation_version == 2) + .cloned() + .map(|mut presentation| { + presentation.presentation_version = 1; + presentation.pre_virtualized = false; + presentation + }) + .collect::>(); + presentations.extend(legacy); +} + +fn parse_ac4_substream_group( + reader: &mut Ac4BitCursor<'_>, + spec: &str, + frame_rate_factor: u32, + fs_index: u8, + presentation_version: u8, +) -> Result { + let substreams_present = reader.read_bool(spec, "b_substreams_present")?; + let high_sample_rate_extension = reader.read_bool(spec, "b_hsf_ext")?; + let single_substream = reader.read_bool(spec, "b_single_substream")?; + let lf_substream_count = if single_substream { + 1_usize + } else { + let count = reader.read_bits(2, spec, "n_lf_substreams_minus2")? + 2; + if count == 5 { + usize::try_from( + count + read_ac4_variable_bits(reader, spec, "n_lf_substreams extension", 2)?, + ) + .map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-4 lf substream count does not fit in usize".to_string(), + })? + } else { + usize::try_from(count).unwrap() + } + }; + if !reader.read_bool(spec, "b_channel_coded")? { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "path-only AC-4 import currently supports only channel-coded substreams" + .to_string(), + }); + } + + let mut substreams = Vec::with_capacity(lf_substream_count); + for _ in 0..lf_substream_count { + substreams.push(parse_ac4_channel_coded_substream( + reader, + spec, + frame_rate_factor, + substreams_present, + fs_index, + presentation_version, + )?); + if high_sample_rate_extension { + skip_ac4_hsf_ext_substream_info(reader, spec, substreams_present)?; + } + } + let content_type = parse_ac4_content_type(reader, spec)?; + + Ok(ParsedAc4SubstreamGroup { + substreams_present, + high_sample_rate_extension, + substreams, + content_type, + }) +} + +fn skip_ac4_hsf_ext_substream_info( + reader: &mut Ac4BitCursor<'_>, + spec: &str, + substreams_present: bool, +) -> Result<(), MuxError> { + if substreams_present { + let substream_index = reader.read_bits(2, spec, "hsf_ext_substream_index")?; + if substream_index == 3 { + let _ = read_ac4_variable_bits(reader, spec, "hsf_ext_substream_index extension", 2)?; + } + } + Ok(()) +} + +fn parse_ac4_channel_coded_substream( + reader: &mut Ac4BitCursor<'_>, + spec: &str, + frame_rate_factor: u32, + substreams_present: bool, + fs_index: u8, + presentation_version: u8, +) -> Result { + let channel_mode = parse_ac4_channel_mode(reader, spec, presentation_version)?; + let mut channel_mask = *AC4_CHANNEL_MASK_BY_MODE + .get(usize::from(channel_mode)) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported AC-4 channel mode {channel_mode}"), + })?; + let mut four_back_channels_present = false; + let mut top_channel_pairs = 0_u8; + if (11..=14).contains(&channel_mode) { + four_back_channels_present = reader.read_bool(spec, "b_4_back_channels_present")?; + let centre_present = reader.read_bool(spec, "b_centre_present")?; + top_channel_pairs = + u8::try_from(reader.read_bits(2, spec, "top_channels_present")?).unwrap(); + if !four_back_channels_present { + channel_mask &= !0x8; + } + if !centre_present { + channel_mask &= !0x2; + } + match top_channel_pairs { + 0 => { + channel_mask &= !0x30; + } + 1 | 2 => { + channel_mask &= !0x30; + channel_mask |= 0x80; + } + _ => {} + } + } + let dsi_sf_multiplier = if fs_index == 1 && reader.read_bool(spec, "b_sf_multiplier")? { + u8::try_from(reader.read_bits(1, spec, "sf_multiplier")? + 1).unwrap() + } else { + 0 + }; + let has_substream_bitrate_indicator = + reader.read_bool(spec, "b_substream_bitrate_indicator")?; + let substream_bitrate_indicator = if has_substream_bitrate_indicator { + let mut indicator = + u8::try_from(reader.read_bits(3, spec, "substream_bitrate_indicator")?).unwrap(); + if indicator & 0x01 == 1 { + indicator = (indicator << 2) + | u8::try_from(reader.read_bits( + 2, + spec, + "substream_bitrate_indicator extension", + )?) + .unwrap(); + } + indicator + } else { + 0 + }; + if (7..=10).contains(&channel_mode) { + let _ = reader.read_bool(spec, "add_ch_base")?; + } + for _ in 0..frame_rate_factor { + let _ = reader.read_bool(spec, "b_audio_ndot")?; + } + if substreams_present { + let substream_index = reader.read_bits(2, spec, "substream_index")?; + if substream_index == 3 { + let _ = read_ac4_variable_bits(reader, spec, "substream_index extension", 2)?; + } + } + + Ok(ParsedAc4Substream { + dsi_sf_multiplier, + has_substream_bitrate_indicator, + substream_bitrate_indicator, + channel_mask, + channel_mode, + four_back_channels_present, + top_channel_pairs, + }) +} + +fn parse_ac4_channel_mode( + reader: &mut Ac4BitCursor<'_>, + spec: &str, + presentation_version: u8, +) -> Result { + let mut code = reader.read_bits(1, spec, "channel_mode")?; + if code == 0 { + return Ok(0); + } + code = (code << 1) | reader.read_bits(1, spec, "channel_mode")?; + if code == 2 { + return Ok(1); + } + code = (code << 2) | reader.read_bits(2, spec, "channel_mode")?; + match code { + 12 => return Ok(2), + 13 => return Ok(3), + 14 => return Ok(4), + _ => {} + } + code = (code << 3) | reader.read_bits(3, spec, "channel_mode")?; + match code { + 120 if presentation_version == 2 => return Ok(1), + 121 if presentation_version == 2 => return Ok(1), + 120 => return Ok(5), + 121 => return Ok(6), + 122 => return Ok(7), + 123 => return Ok(8), + 124 => return Ok(9), + 125 => return Ok(10), + _ => {} + } + code = (code << 1) | reader.read_bits(1, spec, "channel_mode")?; + match code { + 252 => return Ok(11), + 253 => return Ok(12), + _ => {} + } + code = (code << 1) | reader.read_bits(1, spec, "channel_mode")?; + match code { + 508 => Ok(13), + 509 => Ok(14), + 510 => Ok(15), + _ => Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "unsupported or reserved AC-4 channel mode".to_string(), + }), + } +} + +fn parse_ac4_content_type( + reader: &mut Ac4BitCursor<'_>, + spec: &str, +) -> Result, MuxError> { + if !reader.read_bool(spec, "b_content_type")? { + return Ok(None); + } + let classifier = u8::try_from(reader.read_bits(3, spec, "content_classifier")?).unwrap(); + let language_tag_bytes = if reader.read_bool(spec, "b_language_indicator")? { + if reader.read_bool(spec, "b_serialized_language_tag")? { + let _ = reader.read_bool(spec, "b_start_tag")?; + let _ = reader.read_bits(16, spec, "language_tag_chunk")?; + Vec::new() + } else { + let len = usize::try_from(reader.read_bits(6, spec, "n_language_tag_bytes")?).unwrap(); + let mut bytes = Vec::with_capacity(len); + for _ in 0..len { + bytes.push(u8::try_from(reader.read_bits(8, spec, "language_tag_byte")?).unwrap()); + } + bytes + } + } else { + Vec::new() + }; + Ok(Some(ParsedAc4ContentType { + classifier, + language_tag_bytes, + })) +} + +fn parse_ac4_group_index( + reader: &mut Ac4BitCursor<'_>, + spec: &str, + bitstream_version: u8, +) -> Result { + if bitstream_version == 1 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "path-only AC-4 import does not support bitstream_version 1".to_string(), + }); + } + let group_index = reader.read_bits(3, spec, "group_index")?; + if group_index == 7 { + read_ac4_variable_bits(reader, spec, "group_index extension", 2) + .map(|value| group_index + value) + } else { + Ok(group_index) + } +} + +fn parse_ac4_presentation_version( + reader: &mut Ac4BitCursor<'_>, + spec: &str, +) -> Result { + let mut version = 0_u8; + while reader.read_bool(spec, "presentation_version")? { + version = version + .checked_add(1) + .ok_or(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-4 presentation version overflow".to_string(), + })?; + } + Ok(version) +} + +fn parse_ac4_frame_rate_multiply_info( + reader: &mut Ac4BitCursor<'_>, + spec: &str, + frame_rate_index: usize, +) -> Result { + let value = match frame_rate_index { + 2..=4 if reader.read_bool(spec, "b_multiplier")? => { + if reader.read_bool(spec, "multiplier_bit")? { + 2 + } else { + 1 + } + } + 0 | 1 | 7 | 8 | 9 if reader.read_bool(spec, "b_multiplier")? => 1, + _ => 0, + }; + Ok(value) +} + +fn parse_ac4_frame_rate_fraction_info( + reader: &mut Ac4BitCursor<'_>, + spec: &str, + frame_rate_index: usize, +) -> Result { + let value = match frame_rate_index { + 5..=9 if reader.read_bool(spec, "b_frame_rate_fraction")? => 1, + 10..=12 if reader.read_bool(spec, "b_frame_rate_fraction")? => { + if reader.read_bool(spec, "b_frame_rate_fraction_is_4")? { + 2 + } else { + 1 + } + } + _ => 0, + }; + Ok(value) +} + +fn parse_ac4_emdf_info( + reader: &mut Ac4BitCursor<'_>, + spec: &str, +) -> Result { + let mut version = reader.read_bits(2, spec, "emdf_version")?; + if version == 3 { + version += read_ac4_variable_bits(reader, spec, "emdf_version extension", 2)?; + } + let mut key_id = reader.read_bits(3, spec, "key_id")?; + if key_id == 7 { + key_id += read_ac4_variable_bits(reader, spec, "key_id extension", 3)?; + } + if reader.read_bool(spec, "b_emdf_payloads_substream_info")? { + let substream_index = reader.read_bits(2, spec, "emdf substream_index")?; + if substream_index == 3 { + let _ = read_ac4_variable_bits(reader, spec, "emdf substream_index extension", 2)?; + } + } + skip_ac4_emdf_protection(reader, spec)?; + Ok(ParsedAc4EmdfInfo { + version: u8::try_from(version).unwrap_or(u8::MAX), + key_id: u16::try_from(key_id).unwrap_or(u16::MAX), + }) +} + +fn skip_ac4_emdf_protection(reader: &mut Ac4BitCursor<'_>, spec: &str) -> Result<(), MuxError> { + for label in ["primary", "secondary"] { + let length = reader.read_bits(2, spec, &format!("protection_length_{label}"))?; + let bytes = match length { + 1 => 1, + 2 => 4, + 3 => 16, + _ => 0, + }; + for _ in 0..bytes { + let _ = reader.read_bits(8, spec, &format!("protection_bits_{label}"))?; + } + } + Ok(()) +} + +fn skip_ac4_presentation_substream_info( + reader: &mut Ac4BitCursor<'_>, + spec: &str, +) -> Result<(), MuxError> { + let _ = reader.read_bool(spec, "b_alternative")?; + let _ = reader.read_bool(spec, "b_pres_ndot")?; + let substream_index = reader.read_bits(2, spec, "presentation_substream_index")?; + if substream_index == 3 { + let _ = read_ac4_variable_bits(reader, spec, "presentation_substream_index extension", 2)?; + } + Ok(()) +} + +fn read_ac4_variable_bits( + reader: &mut Ac4BitCursor<'_>, + spec: &str, + context: &str, + bit_width: usize, +) -> Result { + let mut value = 0_u32; + loop { + value = value + .checked_add(reader.read_bits(bit_width, spec, context)?) + .ok_or(MuxError::LayoutOverflow("AC-4 variable-width value"))?; + let more = reader.read_bool(spec, &format!("{context} continuation"))?; + if !more { + return Ok(value); + } + value = (value << bit_width) + .checked_add(1_u32 << bit_width) + .ok_or(MuxError::LayoutOverflow("AC-4 variable-width continuation"))?; + } +} + +fn read_ac4_variable_bits_prefixed( + reader: &mut Ac4BitCursor<'_>, + spec: &str, + context: &str, + bit_width: usize, + extension_trigger: Option, +) -> Result { + let base = reader.read_bits(bit_width, spec, context)?; + if extension_trigger.is_some_and(|trigger| trigger == base) { + Ok(base + read_ac4_variable_bits(reader, spec, context, bit_width)?) + } else { + Ok(base) + } +} + +fn ac4_timing_from_frame_rate_index( + fs_index: u8, + frame_rate_index: usize, + spec: &str, +) -> Result<(u32, u32), MuxError> { + let (durations, timescales) = if fs_index == 0 { + ( + &AC4_SAMPLE_DELTA_TABLE_441[..], + &AC4_MEDIA_TIMESCALE_441[..], + ) + } else { + (&AC4_SAMPLE_DELTA_TABLE_48[..], &AC4_MEDIA_TIMESCALE_48[..]) + }; + let sample_duration = + *durations + .get(frame_rate_index) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported AC-4 frame_rate_index {frame_rate_index}"), + })?; + let media_time_scale = + *timescales + .get(frame_rate_index) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported AC-4 frame_rate_index {frame_rate_index}"), + })?; + if sample_duration == 0 || media_time_scale == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "AC-4 frame_rate_index {frame_rate_index} is reserved for this sampling frequency" + ), + }); + } + Ok((sample_duration, media_time_scale)) +} + +fn ac4_channel_count_from_mask(mask: u32) -> Result { + if mask == 0 { + return Ok(0); + } + let mut count = 0_u16; + for bit in [0_u8, 2, 3, 4, 5, 7, 8, 13, 16, 17, 18] { + if mask & (1_u32 << bit) != 0 { + count = count + .checked_add(2) + .ok_or(MuxError::LayoutOverflow("AC-4 channel count"))?; + } + } + for bit in [1_u8, 6, 9, 10, 11, 12, 14, 15] { + if mask & (1_u32 << bit) != 0 { + count = count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("AC-4 channel count"))?; + } + } + if (mask & 1) != 0 && (mask & 2) != 0 && count == 3 { + count = 2; + } + Ok(count) +} + +fn ac4_channel_mode_from_mask(mask: u32, fallback: u8) -> u8 { + AC4_CHANNEL_MASK_BY_MODE + .iter() + .take(16) + .position(|candidate| *candidate == mask) + .map(|index| u8::try_from(index).unwrap_or(fallback)) + .unwrap_or(fallback) +} + +fn serialize_ac4_dac4(parsed: &ParsedAc4Stream, spec: &str) -> Result, MuxError> { + let mut writer = BitWriter::new(Vec::new()); + write_ac4_bits(&mut writer, 1, 3)?; + write_ac4_bits(&mut writer, u32::from(parsed.bitstream_version), 7)?; + write_ac4_bits(&mut writer, u32::from(parsed.fs_index), 1)?; + write_ac4_bits(&mut writer, u32::from(parsed.frame_rate_index), 4)?; + write_ac4_bits( + &mut writer, + u32::try_from(parsed.presentations.len()).map_err(|_| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AC-4 presentation count does not fit in u32".to_string(), + } + })?, + 9, + )?; + write_ac4_bits(&mut writer, u32::from(parsed.has_program_id), 1)?; + if parsed.has_program_id { + write_ac4_bits(&mut writer, u32::from(parsed.short_program_id), 16)?; + write_ac4_bits(&mut writer, u32::from(parsed.program_uuid.is_some()), 1)?; + if let Some(program_uuid) = &parsed.program_uuid { + for byte in program_uuid { + write_ac4_bits(&mut writer, u32::from(*byte), 8)?; + } + } + } + write_ac4_bits(&mut writer, u32::from(parsed.bit_rate_mode), 2)?; + write_ac4_bits(&mut writer, 0, 32)?; + write_ac4_bits(&mut writer, u32::MAX, 32)?; + align_ac4_writer(&mut writer)?; + for presentation in &parsed.presentations { + let body = serialize_ac4_presentation_body(presentation)?; + write_ac4_bits(&mut writer, u32::from(presentation.presentation_version), 8)?; + if body.len() < 255 { + write_ac4_bits(&mut writer, u32::try_from(body.len()).unwrap(), 8)?; + } else { + write_ac4_bits(&mut writer, 255, 8)?; + write_ac4_bits( + &mut writer, + u32::try_from(body.len() - 255) + .map_err(|_| MuxError::LayoutOverflow("AC-4 presentation body length"))?, + 16, + )?; + } + for byte in body { + write_ac4_bits(&mut writer, u32::from(byte), 8)?; + } + } + + writer.into_inner().map_err(MuxError::Io) +} + +fn serialize_ac4_presentation_body( + presentation: &ParsedAc4Presentation, +) -> Result, MuxError> { + let mut writer = BitWriter::new(Vec::new()); + let group = presentation + .group + .as_ref() + .ok_or(MuxError::LayoutOverflow("AC-4 presentation group"))?; + write_ac4_bits(&mut writer, 0x1f, 5)?; + write_ac4_bits(&mut writer, u32::from(presentation.mdcompat), 3)?; + write_ac4_bits(&mut writer, u32::from(presentation.has_presentation_id), 1)?; + if presentation.has_presentation_id { + write_ac4_bits(&mut writer, u32::from(presentation.presentation_id), 5)?; + } + write_ac4_bits( + &mut writer, + u32::from(presentation.frame_rate_multiply_info), + 2, + )?; + write_ac4_bits( + &mut writer, + u32::from(presentation.frame_rate_fraction_info), + 2, + )?; + write_ac4_bits(&mut writer, u32::from(presentation.emdf_version), 5)?; + write_ac4_bits(&mut writer, u32::from(presentation.key_id), 10)?; + write_ac4_bits(&mut writer, 1, 1)?; + write_ac4_bits( + &mut writer, + u32::from(presentation.presentation_channel_mode), + 5, + )?; + if (11..=14).contains(&presentation.presentation_channel_mode) { + write_ac4_bits( + &mut writer, + u32::from(presentation.four_back_channels_present), + 1, + )?; + write_ac4_bits(&mut writer, u32::from(presentation.top_channel_pairs), 2)?; + } + write_ac4_bits(&mut writer, presentation.presentation_channel_mask, 24)?; + write_ac4_bits(&mut writer, 0, 1)?; + write_ac4_bits( + &mut writer, + u32::from(presentation.has_presentation_filter), + 1, + )?; + if presentation.has_presentation_filter { + write_ac4_bits(&mut writer, u32::from(presentation.enable_presentation), 1)?; + write_ac4_bits(&mut writer, 0, 8)?; + } + serialize_ac4_substream_group(&mut writer, group)?; + write_ac4_bits(&mut writer, u32::from(presentation.pre_virtualized), 1)?; + write_ac4_bits( + &mut writer, + u32::from(!presentation.add_emdf_substreams.is_empty()), + 1, + )?; + if !presentation.add_emdf_substreams.is_empty() { + write_ac4_bits( + &mut writer, + u32::try_from(presentation.add_emdf_substreams.len()).unwrap(), + 7, + )?; + for emdf in &presentation.add_emdf_substreams { + write_ac4_bits(&mut writer, u32::from(emdf.version), 5)?; + write_ac4_bits(&mut writer, u32::from(emdf.key_id), 10)?; + } + } + write_ac4_bits(&mut writer, 0, 1)?; + write_ac4_bits(&mut writer, 0, 1)?; + align_ac4_writer(&mut writer)?; + write_ac4_bits(&mut writer, 1, 1)?; + write_ac4_bits( + &mut writer, + u32::from(presentation.pre_virtualized || presentation.top_channel_pairs != 0), + 1, + )?; + write_ac4_bits(&mut writer, 0, 4)?; + write_ac4_bits(&mut writer, 0, 1)?; + write_ac4_bits(&mut writer, 0, 1)?; + writer.into_inner().map_err(MuxError::Io) +} + +fn serialize_ac4_substream_group( + writer: &mut BitWriter>, + group: &ParsedAc4SubstreamGroup, +) -> Result<(), MuxError> { + write_ac4_bits(writer, u32::from(group.substreams_present), 1)?; + write_ac4_bits(writer, u32::from(group.high_sample_rate_extension), 1)?; + write_ac4_bits(writer, 1, 1)?; + write_ac4_bits( + writer, + u32::try_from(group.substreams.len()) + .map_err(|_| MuxError::LayoutOverflow("AC-4 substream count"))?, + 8, + )?; + for substream in &group.substreams { + serialize_ac4_substream(writer, substream)?; + } + if let Some(content) = &group.content_type { + write_ac4_bits(writer, 1, 1)?; + write_ac4_bits(writer, u32::from(content.classifier), 3)?; + write_ac4_bits(writer, u32::from(!content.language_tag_bytes.is_empty()), 1)?; + if !content.language_tag_bytes.is_empty() { + write_ac4_bits( + writer, + u32::try_from(content.language_tag_bytes.len()).unwrap(), + 6, + )?; + for byte in &content.language_tag_bytes { + write_ac4_bits(writer, u32::from(*byte), 8)?; + } + } + } else { + write_ac4_bits(writer, 0, 1)?; + } + Ok(()) +} + +fn serialize_ac4_substream( + writer: &mut BitWriter>, + substream: &ParsedAc4Substream, +) -> Result<(), MuxError> { + write_ac4_bits(writer, u32::from(substream.dsi_sf_multiplier), 2)?; + write_ac4_bits( + writer, + u32::from(substream.has_substream_bitrate_indicator), + 1, + )?; + if substream.has_substream_bitrate_indicator { + write_ac4_bits(writer, u32::from(substream.substream_bitrate_indicator), 5)?; + } + write_ac4_bits(writer, substream.channel_mask, 24)?; + Ok(()) +} + +fn write_ac4_bits( + writer: &mut BitWriter>, + value: u32, + width: usize, +) -> Result<(), MuxError> { + let bytes = value.to_be_bytes(); + writer.write_bits(&bytes, width).map_err(MuxError::Io) +} + +fn align_ac4_writer(writer: &mut BitWriter>) -> Result<(), MuxError> { + while !writer.is_aligned() { + writer.write_bit(false).map_err(MuxError::Io)?; + } + Ok(()) +} + +fn build_ac4_sample_entry_box( + _channel_count: u16, + sample_rate: u32, + media_time_scale: u32, + samples: &[StagedSample], + dac4_data: &[u8], +) -> Result, MuxError> { + let mut sample_entry = AudioSampleEntry::default(); + sample_entry.set_box_type(FourCc::from_bytes(*b"ac-4")); + sample_entry.sample_entry = SampleEntry { + box_type: FourCc::from_bytes(*b"ac-4"), + data_reference_index: 1, + }; + // AC-4 sample entries keep the authored channel topology in `dac4`; the + // sample-entry header itself stays on the standard two-channel default. + sample_entry.channel_count = 2; + sample_entry.sample_size = 16; + sample_entry.sample_rate = sample_rate << 16; + + let dac4 = super::super::mp4::encode_typed_box( + &Dac4 { + data: dac4_data.to_vec(), + }, + &[], + )?; + let btrt = + super::super::mp4::encode_typed_box(&build_ac4_btrt(samples, media_time_scale)?, &[])?; + let mut children = Vec::with_capacity(dac4.len() + btrt.len()); + children.extend_from_slice(&dac4); + children.extend_from_slice(&btrt); + + super::super::mp4::encode_typed_box(&sample_entry, &children) +} + +fn build_ac4_btrt(samples: &[StagedSample], media_time_scale: u32) -> Result { + if samples.is_empty() || media_time_scale == 0 { + return Ok(Btrt::default()); + } + + let mut buffer_size_db = 0_u32; + let mut total_payload_bytes = 0_u64; + let mut total_duration = 0_u64; + for sample in samples { + buffer_size_db = buffer_size_db.max(sample.data_size); + total_payload_bytes = total_payload_bytes + .checked_add(u64::from(sample.data_size)) + .ok_or(MuxError::LayoutOverflow("AC-4 total payload bytes"))?; + total_duration = total_duration + .checked_add(u64::from(sample.duration)) + .ok_or(MuxError::LayoutOverflow("AC-4 total duration"))?; + } + if total_duration == 0 { + return Ok(Btrt::default()); + } + + let avg_bitrate = total_payload_bytes + .checked_mul(8) + .and_then(|bits| bits.checked_mul(u64::from(media_time_scale))) + .ok_or(MuxError::LayoutOverflow("AC-4 average bitrate"))? + / total_duration; + let avg_bitrate = avg_bitrate & !7; + + Ok(Btrt { + buffer_size_db, + max_bitrate: u32::try_from(avg_bitrate) + .map_err(|_| MuxError::LayoutOverflow("AC-4 maximum bitrate"))?, + avg_bitrate: u32::try_from(avg_bitrate) + .map_err(|_| MuxError::LayoutOverflow("AC-4 average bitrate"))?, + }) +} + +#[cfg(test)] +mod tests { + use super::{parse_ac4_frame_header, parse_ac4_stream, serialize_ac4_dac4}; + + const TEST_AC4_FRAME_HEX: &str = concat!( + "ac41ffff00015cbfcee7984004a7012e2c20304d805c8458d0a0c06013b58354cb613912144b0232be85", + "4b4800025c71fd3eaacd4a86324c1498a4bd6021dfa8b016b42115ba6b684770fd34e31a264f66703f14", + "090541b22397fd7c837ef68f05211a79862d48d5c46d87857bedd9f69bbdb26682bcf49b036bccb100ab84", + "4568e5a54fc32e4302233b9144cb4bd0ca86c64794cf4e7eca5191e8d8c48ccef686868ae56b5f5e416097", + "07ad77775b5bfa5b61bff5f32ed963f6caee5ac968a743e60e578f5a4892c90101e18a7246f88c51161028", + "870564d088f0799f9d11701ecd86f202692868b8649e14e10f0304bc20f4b47d06b3ba58fcd3c950fecd1a", + "137dd410334797b62d82ed35073d1131e2f10a02ce51c269e1248e423c299956b2c53ad26a6c5ddcb1d7cd", + "c999265bb1954775fbc72cd8cf322a47091169f3fff19ff6aca15a5894fe68d2fa20c1f55000000000f010", + "4a51e02094a880a3c134b5ff00", + ); + + fn decode_test_hex_bytes(hex: &str) -> Vec { + assert!(hex.len().is_multiple_of(2)); + let mut bytes = Vec::with_capacity(hex.len() / 2); + for index in (0..hex.len()).step_by(2) { + bytes.push(u8::from_str_radix(&hex[index..index + 2], 16).unwrap()); + } + bytes + } + + #[test] + fn retained_ac4_frame_parses_with_expected_channel_count() { + let mut frame = decode_test_hex_bytes(TEST_AC4_FRAME_HEX); + frame.extend_from_slice(&[0, 0]); + let header = parse_ac4_frame_header( + &frame[..7].try_into().unwrap(), + u64::try_from(frame.len()).unwrap(), + 0, + "test.ac4", + ) + .unwrap(); + let payload = &frame[usize::try_from(header.header_size).unwrap() + ..usize::try_from(header.header_size + header.frame_payload_size).unwrap()]; + let parsed = parse_ac4_stream(payload, "test.ac4").unwrap(); + assert_eq!(parsed.presentations.len(), 1); + assert_eq!( + serialize_ac4_dac4(&parsed, "test.ac4").unwrap(), + decode_test_hex_bytes("20a601400000001fffffffE0010ff88000004200000250100000030080"), + "{parsed:#?}" + ); + } +} diff --git a/src/mux/demux/alac.rs b/src/mux/demux/alac.rs new file mode 100644 index 0000000..a7e6e27 --- /dev/null +++ b/src/mux/demux/alac.rs @@ -0,0 +1,684 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::AnyTypeBox; +use crate::boxes::iso14496_12::{AudioSampleEntry, SampleEntry}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{StagedSample, read_exact_at_sync}; +#[cfg(feature = "async")] +use super::caf_common::read_caf_chunk_header_async; +use super::caf_common::read_caf_chunk_header_sync; + +const ALAC: FourCc = FourCc::from_bytes(*b"alac"); + +pub(in crate::mux) struct ParsedCafAlacTrack { + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +struct ParsedCafDescription { + sample_rate: u32, + bytes_per_packet: u32, + frames_per_packet: u32, + channels_per_frame: u32, + bits_per_channel: u32, +} + +struct ParsedCafPacketTable { + number_packets: u64, + number_valid_frames: u64, + priming_frames: u32, + remainder_frames: u32, + packet_sizes: Vec, +} + +struct ParsedAlacCookieConfig { + frame_length: u32, + bit_depth: u16, + channel_count: u16, + sample_entry_payload: Vec, +} + +pub(in crate::mux) fn scan_caf_alac_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let mut description = None; + let mut cookie = None; + let mut data_chunk = None; + let mut packet_table = None; + let mut offset = 8_u64; + while offset < file_size { + let (chunk_type, chunk_size) = read_caf_chunk_header_sync(&mut file, offset, spec)?; + let chunk_data_offset = offset + 12; + let chunk_end = chunk_data_offset + .checked_add(chunk_size) + .ok_or(MuxError::LayoutOverflow("CAF chunk range"))?; + if chunk_end > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("CAF chunk `{chunk_type}` overruns the input length"), + }); + } + match chunk_type { + value if value == FourCc::from_bytes(*b"desc") => { + description = Some(parse_caf_description_chunk_sync( + &mut file, + chunk_data_offset, + chunk_size, + spec, + )?); + } + value if value == FourCc::from_bytes(*b"kuki") => { + let mut bytes = vec![0_u8; usize::try_from(chunk_size).unwrap()]; + read_exact_at_sync( + &mut file, + chunk_data_offset, + &mut bytes, + spec, + "CAF `kuki` chunk is truncated", + )?; + cookie = Some(bytes); + } + value if value == FourCc::from_bytes(*b"data") => { + data_chunk = Some((chunk_data_offset, chunk_size)); + } + value if value == FourCc::from_bytes(*b"pakt") => { + packet_table = Some(parse_caf_packet_table_sync( + &mut file, + chunk_data_offset, + chunk_size, + spec, + )?); + } + _ => {} + } + offset = chunk_end; + } + finalize_caf_alac_track(spec, description, cookie, data_chunk, packet_table) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_caf_alac_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let mut description = None; + let mut cookie = None; + let mut data_chunk = None; + let mut packet_table = None; + let mut offset = 8_u64; + while offset < file_size { + let (chunk_type, chunk_size) = read_caf_chunk_header_async(&mut file, offset, spec).await?; + let chunk_data_offset = offset + 12; + let chunk_end = chunk_data_offset + .checked_add(chunk_size) + .ok_or(MuxError::LayoutOverflow("CAF chunk range"))?; + if chunk_end > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("CAF chunk `{chunk_type}` overruns the input length"), + }); + } + match chunk_type { + value if value == FourCc::from_bytes(*b"desc") => { + description = Some( + parse_caf_description_chunk_async( + &mut file, + chunk_data_offset, + chunk_size, + spec, + ) + .await?, + ); + } + value if value == FourCc::from_bytes(*b"kuki") => { + let mut bytes = vec![0_u8; usize::try_from(chunk_size).unwrap()]; + read_exact_at_async( + &mut file, + chunk_data_offset, + &mut bytes, + spec, + "CAF `kuki` chunk is truncated", + ) + .await?; + cookie = Some(bytes); + } + value if value == FourCc::from_bytes(*b"data") => { + data_chunk = Some((chunk_data_offset, chunk_size)); + } + value if value == FourCc::from_bytes(*b"pakt") => { + packet_table = Some( + parse_caf_packet_table_async(&mut file, chunk_data_offset, chunk_size, spec) + .await?, + ); + } + _ => {} + } + offset = chunk_end; + } + finalize_caf_alac_track(spec, description, cookie, data_chunk, packet_table) +} + +fn parse_caf_description_chunk_sync( + file: &mut File, + offset: u64, + chunk_size: u64, + spec: &str, +) -> Result { + if chunk_size < 32 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF `desc` chunk is shorter than the required 32-byte payload".to_string(), + }); + } + let mut bytes = [0_u8; 32]; + read_exact_at_sync( + file, + offset, + &mut bytes, + spec, + "CAF `desc` chunk is truncated", + )?; + parse_caf_description_chunk_bytes(&bytes, spec) +} + +#[cfg(feature = "async")] +async fn parse_caf_description_chunk_async( + file: &mut TokioFile, + offset: u64, + chunk_size: u64, + spec: &str, +) -> Result { + if chunk_size < 32 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF `desc` chunk is shorter than the required 32-byte payload".to_string(), + }); + } + let mut bytes = [0_u8; 32]; + read_exact_at_async( + file, + offset, + &mut bytes, + spec, + "CAF `desc` chunk is truncated", + ) + .await?; + parse_caf_description_chunk_bytes(&bytes, spec) +} + +fn parse_caf_description_chunk_bytes( + bytes: &[u8; 32], + spec: &str, +) -> Result { + let sample_rate_f64 = f64::from_bits(u64::from_be_bytes(bytes[..8].try_into().unwrap())); + if !sample_rate_f64.is_finite() || sample_rate_f64 <= 0.0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF `desc` chunk carried an invalid sample rate".to_string(), + }); + } + let sample_rate = sample_rate_f64.round() as u32; + let format_id = FourCc::from_bytes(bytes[8..12].try_into().unwrap()); + if format_id != ALAC { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "CAF `desc` chunk used unsupported format `{format_id}`; only `alac` is supported" + ), + }); + } + let bytes_per_packet = u32::from_be_bytes(bytes[16..20].try_into().unwrap()); + let frames_per_packet = u32::from_be_bytes(bytes[20..24].try_into().unwrap()); + let channels_per_frame = u32::from_be_bytes(bytes[24..28].try_into().unwrap()); + let bits_per_channel = u32::from_be_bytes(bytes[28..32].try_into().unwrap()); + Ok(ParsedCafDescription { + sample_rate, + bytes_per_packet, + frames_per_packet, + channels_per_frame, + bits_per_channel, + }) +} + +fn parse_caf_packet_table_sync( + file: &mut File, + offset: u64, + chunk_size: u64, + spec: &str, +) -> Result { + if chunk_size < 24 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF `pakt` chunk is shorter than the required 24-byte header".to_string(), + }); + } + let mut bytes = vec![0_u8; usize::try_from(chunk_size).unwrap()]; + read_exact_at_sync( + file, + offset, + &mut bytes, + spec, + "CAF `pakt` chunk is truncated", + )?; + parse_caf_packet_table_bytes(&bytes, spec) +} + +#[cfg(feature = "async")] +async fn parse_caf_packet_table_async( + file: &mut TokioFile, + offset: u64, + chunk_size: u64, + spec: &str, +) -> Result { + if chunk_size < 24 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF `pakt` chunk is shorter than the required 24-byte header".to_string(), + }); + } + let mut bytes = vec![0_u8; usize::try_from(chunk_size).unwrap()]; + read_exact_at_async( + file, + offset, + &mut bytes, + spec, + "CAF `pakt` chunk is truncated", + ) + .await?; + parse_caf_packet_table_bytes(&bytes, spec) +} + +fn parse_caf_packet_table_bytes( + bytes: &[u8], + spec: &str, +) -> Result { + let number_packets = u64::from_be_bytes(bytes[..8].try_into().unwrap()); + let number_valid_frames = u64::from_be_bytes(bytes[8..16].try_into().unwrap()); + let priming_frames = u32::from_be_bytes(bytes[16..20].try_into().unwrap()); + let remainder_frames = u32::from_be_bytes(bytes[20..24].try_into().unwrap()); + let packet_sizes = decode_caf_packet_sizes(&bytes[24..], number_packets, spec)?; + Ok(ParsedCafPacketTable { + number_packets, + number_valid_frames, + priming_frames, + remainder_frames, + packet_sizes, + }) +} + +fn decode_caf_packet_sizes( + bytes: &[u8], + number_packets: u64, + spec: &str, +) -> Result, MuxError> { + let packet_count = + usize::try_from(number_packets).map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF `pakt` packet count exceeds the supported in-memory sample table size" + .to_string(), + })?; + let mut sizes = Vec::with_capacity(packet_count); + let mut cursor = 0usize; + while sizes.len() < packet_count { + if cursor >= bytes.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF `pakt` packet-size table is truncated".to_string(), + }); + } + let mut value = 0_u64; + loop { + let byte = bytes[cursor]; + cursor += 1; + value = (value << 7) | u64::from(byte & 0x7F); + if byte & 0x80 == 0 { + break; + } + if cursor >= bytes.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF `pakt` packet-size table ended in the middle of a variable-length integer".to_string(), + }); + } + } + sizes.push( + u32::try_from(value).map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF `pakt` packet size does not fit in the current mux sample model" + .to_string(), + })?, + ); + } + if bytes[cursor..].iter().any(|byte| *byte != 0) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF `pakt` packet-size table carried unexpected trailing bytes".to_string(), + }); + } + Ok(sizes) +} + +fn finalize_caf_alac_track( + spec: &str, + description: Option, + cookie: Option>, + data_chunk: Option<(u64, u64)>, + packet_table: Option, +) -> Result { + let mut description = description.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF input did not contain a required `desc` chunk".to_string(), + })?; + let cookie = cookie.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF ALAC input did not contain a required `kuki` chunk".to_string(), + })?; + let (data_offset, chunk_size) = data_chunk.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF ALAC input did not contain a required `data` chunk".to_string(), + })?; + let parsed_cookie = parse_alac_cookie(&cookie, spec)?; + if description.frames_per_packet == 0 { + description.frames_per_packet = parsed_cookie.frame_length; + } + if description.channels_per_frame == 0 { + description.channels_per_frame = u32::from(parsed_cookie.channel_count); + } + if description.bits_per_channel == 0 { + description.bits_per_channel = u32::from(parsed_cookie.bit_depth); + } + if description.frames_per_packet == 0 + || description.channels_per_frame == 0 + || description.bits_per_channel == 0 + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "CAF ALAC input did not carry enough non-zero audio parameters in `desc` or `kuki`" + .to_string(), + }); + } + if chunk_size < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF `data` chunk is too short to include the edit-count field".to_string(), + }); + } + let payload_offset = data_offset + 4; + let payload_size = chunk_size - 4; + if payload_size == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF ALAC `data` chunk did not contain any encoded packet payload".to_string(), + }); + } + let channel_count = u16::try_from(description.channels_per_frame).map_err(|_| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF ALAC channel count does not fit in the current MP4 sample-entry model" + .to_string(), + } + })?; + let sample_size_bits = u16::try_from(description.bits_per_channel).map_err(|_| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF ALAC bits-per-channel does not fit in the current MP4 sample-entry model" + .to_string(), + } + })?; + let samples = if description.bytes_per_packet != 0 { + build_fixed_packet_alac_samples(spec, payload_offset, payload_size, &description)? + } else { + build_variable_packet_alac_samples( + spec, + payload_offset, + payload_size, + &description, + packet_table.as_ref(), + )? + }; + let sample_entry_box = build_alac_sample_entry_box( + description.sample_rate, + channel_count, + sample_size_bits, + &parsed_cookie.sample_entry_payload, + )?; + Ok(ParsedCafAlacTrack { + sample_rate: description.sample_rate, + sample_entry_box, + samples, + }) +} + +fn build_fixed_packet_alac_samples( + spec: &str, + payload_offset: u64, + payload_size: u64, + description: &ParsedCafDescription, +) -> Result, MuxError> { + if !payload_size.is_multiple_of(u64::from(description.bytes_per_packet)) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "CAF ALAC `data` chunk size is not a whole-number multiple of `bytes_per_packet`" + .to_string(), + }); + } + let packet_count = payload_size / u64::from(description.bytes_per_packet); + let packet_count_u32 = + u32::try_from(packet_count).map_err(|_| MuxError::LayoutOverflow("CAF packet count"))?; + let mut samples = Vec::with_capacity(usize::try_from(packet_count).unwrap_or(0)); + for index in 0..packet_count_u32 { + let packet_offset = payload_offset + .checked_add(u64::from(index) * u64::from(description.bytes_per_packet)) + .ok_or(MuxError::LayoutOverflow("CAF packet offset"))?; + samples.push(StagedSample { + data_offset: packet_offset, + data_size: description.bytes_per_packet, + duration: description.frames_per_packet, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + Ok(samples) +} + +fn build_variable_packet_alac_samples( + spec: &str, + payload_offset: u64, + payload_size: u64, + description: &ParsedCafDescription, + packet_table: Option<&ParsedCafPacketTable>, +) -> Result, MuxError> { + let packet_table = packet_table.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "CAF ALAC input used variable packet sizing but did not provide a required `pakt` chunk" + .to_string(), + })?; + if packet_table.priming_frames != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "CAF ALAC `pakt` chunk declared priming frames; encoder-delay trimming is not landed yet" + .to_string(), + }); + } + if packet_table.remainder_frames >= description.frames_per_packet + && description.frames_per_packet != 0 + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "CAF ALAC `pakt` chunk declared a remainder frame count that is not smaller than `frames_per_packet`" + .to_string(), + }); + } + if usize::try_from(packet_table.number_packets).ok() != Some(packet_table.packet_sizes.len()) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "CAF ALAC `pakt` packet count did not match the number of decoded packet sizes" + .to_string(), + }); + } + let total_size: u64 = packet_table + .packet_sizes + .iter() + .map(|size| u64::from(*size)) + .sum(); + if total_size != payload_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "CAF ALAC packet-table sizes did not add up to the `data` chunk payload length" + .to_string(), + }); + } + let mut packet_offset = payload_offset; + let mut samples = Vec::with_capacity(packet_table.packet_sizes.len()); + let packet_count = u64::try_from(packet_table.packet_sizes.len()).unwrap(); + for (index, packet_size) in packet_table.packet_sizes.iter().copied().enumerate() { + let index_u64 = u64::try_from(index).unwrap(); + let duration = if index_u64 + 1 == packet_count { + derive_last_packet_duration(spec, packet_table, description.frames_per_packet)? + } else { + description.frames_per_packet + }; + samples.push(StagedSample { + data_offset: packet_offset, + data_size: packet_size, + duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + packet_offset = packet_offset + .checked_add(u64::from(packet_size)) + .ok_or(MuxError::LayoutOverflow("CAF packet offset"))?; + } + Ok(samples) +} + +fn derive_last_packet_duration( + spec: &str, + packet_table: &ParsedCafPacketTable, + frames_per_packet: u32, +) -> Result { + if packet_table.number_packets == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF ALAC `pakt` chunk declared zero packets".to_string(), + }); + } + if packet_table.remainder_frames != 0 { + return Ok(packet_table.remainder_frames); + } + if packet_table.number_packets == 1 { + return u32::try_from(packet_table.number_valid_frames) + .map_err(|_| MuxError::LayoutOverflow("CAF valid frame count")); + } + let preceding_frames = u64::from(frames_per_packet) + .checked_mul(packet_table.number_packets.saturating_sub(1)) + .ok_or(MuxError::LayoutOverflow("CAF preceding packet frames"))?; + let remaining = packet_table + .number_valid_frames + .saturating_sub(preceding_frames); + if remaining == 0 { + Ok(frames_per_packet) + } else { + u32::try_from(remaining) + .map_err(|_| MuxError::LayoutOverflow("CAF trailing packet duration")) + } +} + +fn parse_alac_cookie(cookie: &[u8], spec: &str) -> Result { + let sample_entry_payload = extract_alac_sample_entry_payload(cookie, spec)?; + if sample_entry_payload.len() < 28 { + return Ok(ParsedAlacCookieConfig { + frame_length: 0, + bit_depth: 0, + channel_count: 0, + sample_entry_payload, + }); + } + let config = if sample_entry_payload.len() == 28 { + &sample_entry_payload[..] + } else { + &sample_entry_payload[sample_entry_payload.len() - 28..] + }; + Ok(ParsedAlacCookieConfig { + frame_length: u32::from_be_bytes(config[4..8].try_into().unwrap()), + bit_depth: u16::from(config[9]), + channel_count: u16::from(config[13]), + sample_entry_payload, + }) +} + +fn extract_alac_sample_entry_payload(cookie: &[u8], spec: &str) -> Result, MuxError> { + let mut offset = 0usize; + let mut saw_box = false; + while cookie.len().saturating_sub(offset) >= 8 { + let size = u32::from_be_bytes(cookie[offset..offset + 4].try_into().unwrap()) as usize; + if size < 8 + || offset + .checked_add(size) + .is_none_or(|end| end > cookie.len()) + { + if !saw_box && offset == 0 { + return Ok(cookie.to_vec()); + } + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF ALAC `kuki` box layout is malformed".to_string(), + }); + } + saw_box = true; + let box_type = FourCc::from_bytes(cookie[offset + 4..offset + 8].try_into().unwrap()); + if box_type == ALAC { + return Ok(cookie[offset + 8..offset + size].to_vec()); + } + offset += size; + } + if saw_box { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF ALAC `kuki` did not contain a required inner `alac` box".to_string(), + }); + } + Ok(cookie.to_vec()) +} + +fn build_alac_sample_entry_box( + sample_rate: u32, + channel_count: u16, + sample_size: u16, + cookie: &[u8], +) -> Result, MuxError> { + let mut sample_entry = AudioSampleEntry::default(); + sample_entry.set_box_type(ALAC); + sample_entry.sample_entry = SampleEntry { + box_type: ALAC, + data_reference_index: 1, + }; + sample_entry.channel_count = channel_count; + sample_entry.sample_size = sample_size; + sample_entry.sample_rate = sample_rate << 16; + + let child_boxes = [super::super::mp4::encode_raw_box(ALAC, cookie)?]; + + super::super::mp4::encode_typed_box(&sample_entry, &child_boxes.concat()) +} diff --git a/src/mux/demux/amr.rs b/src/mux/demux/amr.rs new file mode 100644 index 0000000..dfe973b --- /dev/null +++ b/src/mux/demux/amr.rs @@ -0,0 +1,378 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::threegpp::Damr; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{ + StagedSample, build_generic_audio_sample_entry_box, read_exact_at_sync, +}; + +const AMR_MAGIC: &[u8; 6] = b"#!AMR\n"; +const AMR_WB_MAGIC: &[u8; 9] = b"#!AMR-WB\n"; +const SAMPLE_ENTRY_SAMR: FourCc = FourCc::from_bytes(*b"samr"); +const SAMPLE_ENTRY_SAWB: FourCc = FourCc::from_bytes(*b"sawb"); +const AMR_SAMPLE_RATE: u32 = 8_000; +const AMR_WB_SAMPLE_RATE: u32 = 16_000; +const AMR_SAMPLES_PER_FRAME: u32 = 160; +const AMR_WB_SAMPLES_PER_FRAME: u32 = 320; +const AMR_FRAME_SIZES: [u8; 16] = [12, 13, 15, 17, 19, 20, 26, 31, 5, 0, 0, 0, 0, 0, 0, 0]; +const AMR_WB_FRAME_SIZES: [u8; 16] = [17, 23, 32, 36, 40, 46, 50, 58, 60, 5, 5, 0, 0, 0, 0, 0]; + +pub(in crate::mux) struct ParsedAmrTrack { + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, + pub(in crate::mux) handler_label: &'static str, +} + +pub(in crate::mux) fn scan_amr_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + parse_amr_stream_sync( + &mut file, + file_size, + spec, + AmrStreamKind { + magic: AMR_MAGIC, + sample_entry_type: SAMPLE_ENTRY_SAMR, + sample_rate: AMR_SAMPLE_RATE, + sample_duration: AMR_SAMPLES_PER_FRAME, + frame_sizes: &AMR_FRAME_SIZES, + handler_label: "amr", + format_label: "AMR", + }, + ) +} + +pub(in crate::mux) fn scan_amr_wb_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + parse_amr_stream_sync( + &mut file, + file_size, + spec, + AmrStreamKind { + magic: AMR_WB_MAGIC, + sample_entry_type: SAMPLE_ENTRY_SAWB, + sample_rate: AMR_WB_SAMPLE_RATE, + sample_duration: AMR_WB_SAMPLES_PER_FRAME, + frame_sizes: &AMR_WB_FRAME_SIZES, + handler_label: "amr-wb", + format_label: "AMR-WB", + }, + ) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_amr_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + parse_amr_stream_async( + &mut file, + file_size, + spec, + AmrStreamKind { + magic: AMR_MAGIC, + sample_entry_type: SAMPLE_ENTRY_SAMR, + sample_rate: AMR_SAMPLE_RATE, + sample_duration: AMR_SAMPLES_PER_FRAME, + frame_sizes: &AMR_FRAME_SIZES, + handler_label: "amr", + format_label: "AMR", + }, + ) + .await +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_amr_wb_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + parse_amr_stream_async( + &mut file, + file_size, + spec, + AmrStreamKind { + magic: AMR_WB_MAGIC, + sample_entry_type: SAMPLE_ENTRY_SAWB, + sample_rate: AMR_WB_SAMPLE_RATE, + sample_duration: AMR_WB_SAMPLES_PER_FRAME, + frame_sizes: &AMR_WB_FRAME_SIZES, + handler_label: "amr-wb", + format_label: "AMR-WB", + }, + ) + .await +} + +#[derive(Clone, Copy)] +struct AmrStreamKind { + magic: &'static [u8], + sample_entry_type: FourCc, + sample_rate: u32, + sample_duration: u32, + frame_sizes: &'static [u8; 16], + handler_label: &'static str, + format_label: &'static str, +} + +fn parse_amr_stream_sync( + file: &mut File, + file_size: u64, + spec: &str, + stream: AmrStreamKind, +) -> Result { + validate_amr_magic_sync(file, file_size, spec, stream)?; + + let mut offset = u64::try_from(stream.magic.len()) + .map_err(|_| MuxError::LayoutOverflow("AMR magic size"))?; + let mut samples = Vec::new(); + let mut mode_set = 0_u16; + while offset < file_size { + let mut toc = [0_u8; 1]; + read_exact_at_sync(file, offset, &mut toc, spec, "truncated AMR frame header")?; + if toc[0] == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{} input carried a zero TOC byte at byte offset {}", + stream.format_label, offset + ), + }); + } + let frame_type = usize::from((toc[0] >> 3) & 0x0F); + let payload_size = u32::from(stream.frame_sizes[frame_type]); + if payload_size == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{} input carried unsupported frame type {} at byte offset {}", + stream.format_label, frame_type, offset + ), + }); + } + let frame_size = payload_size + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("AMR frame size"))?; + if offset + .checked_add(u64::from(frame_size)) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "truncated {} frame at byte offset {}", + stream.format_label, offset + ), + }); + } + mode_set |= 1_u16 << frame_type; + samples.push(StagedSample { + data_offset: offset, + data_size: frame_size, + duration: stream.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(u64::from(frame_size)) + .ok_or(MuxError::LayoutOverflow("AMR frame offset"))?; + } + + if samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{} input contained no codec frames after the magic header", + stream.format_label + ), + }); + } + + Ok(ParsedAmrTrack { + sample_rate: stream.sample_rate, + sample_entry_box: build_amr_sample_entry_box(stream, mode_set)?, + samples, + handler_label: stream.handler_label, + }) +} + +#[cfg(feature = "async")] +async fn parse_amr_stream_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, + stream: AmrStreamKind, +) -> Result { + validate_amr_magic_async(file, file_size, spec, stream).await?; + + let mut offset = u64::try_from(stream.magic.len()) + .map_err(|_| MuxError::LayoutOverflow("AMR magic size"))?; + let mut samples = Vec::new(); + let mut mode_set = 0_u16; + while offset < file_size { + let mut toc = [0_u8; 1]; + read_exact_at_async(file, offset, &mut toc, spec, "truncated AMR frame header").await?; + if toc[0] == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{} input carried a zero TOC byte at byte offset {}", + stream.format_label, offset + ), + }); + } + let frame_type = usize::from((toc[0] >> 3) & 0x0F); + let payload_size = u32::from(stream.frame_sizes[frame_type]); + if payload_size == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{} input carried unsupported frame type {} at byte offset {}", + stream.format_label, frame_type, offset + ), + }); + } + let frame_size = payload_size + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("AMR frame size"))?; + if offset + .checked_add(u64::from(frame_size)) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "truncated {} frame at byte offset {}", + stream.format_label, offset + ), + }); + } + mode_set |= 1_u16 << frame_type; + samples.push(StagedSample { + data_offset: offset, + data_size: frame_size, + duration: stream.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(u64::from(frame_size)) + .ok_or(MuxError::LayoutOverflow("AMR frame offset"))?; + } + + if samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{} input contained no codec frames after the magic header", + stream.format_label + ), + }); + } + + Ok(ParsedAmrTrack { + sample_rate: stream.sample_rate, + sample_entry_box: build_amr_sample_entry_box(stream, mode_set)?, + samples, + handler_label: stream.handler_label, + }) +} + +fn validate_amr_magic_sync( + file: &mut File, + file_size: u64, + spec: &str, + stream: AmrStreamKind, +) -> Result<(), MuxError> { + if file_size < u64::try_from(stream.magic.len()).unwrap() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{} input is truncated before the magic header", + stream.format_label + ), + }); + } + let mut magic = vec![0_u8; stream.magic.len()]; + read_exact_at_sync(file, 0, &mut magic, spec, "truncated AMR magic header")?; + if magic != stream.magic { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{} input did not start with the expected magic header", + stream.format_label + ), + }); + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn validate_amr_magic_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, + stream: AmrStreamKind, +) -> Result<(), MuxError> { + if file_size < u64::try_from(stream.magic.len()).unwrap() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{} input is truncated before the magic header", + stream.format_label + ), + }); + } + let mut magic = vec![0_u8; stream.magic.len()]; + read_exact_at_async(file, 0, &mut magic, spec, "truncated AMR magic header").await?; + if magic != stream.magic { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{} input did not start with the expected magic header", + stream.format_label + ), + }); + } + Ok(()) +} + +fn build_amr_sample_entry_box(stream: AmrStreamKind, mode_set: u16) -> Result, MuxError> { + let damr_box = super::super::mp4::encode_typed_box( + &Damr { + vendor: 0, + decoder_version: 0, + mode_set, + mode_change_period: 0, + frames_per_sample: 1, + }, + &[], + )?; + build_generic_audio_sample_entry_box( + stream.sample_entry_type, + stream.sample_rate, + 1, + 16, + &[damr_box], + ) +} diff --git a/src/mux/demux/annexb_common.rs b/src/mux/demux/annexb_common.rs new file mode 100644 index 0000000..65e8a46 --- /dev/null +++ b/src/mux/demux/annexb_common.rs @@ -0,0 +1,317 @@ +use std::io::Read; + +use crate::bitio::BitReader; + +use super::super::MuxError; +use super::super::import::{SegmentedMuxSourceSpec, StagedSample}; + +pub(in crate::mux) struct IndexedAnnexBTrack { + pub(in crate::mux) segmented_source: SegmentedMuxSourceSpec, + pub(in crate::mux) track_width: u16, + pub(in crate::mux) track_height: u16, + pub(in crate::mux) timescale: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) source_edit_media_time: Option, + pub(in crate::mux) samples: Vec, +} + +#[derive(Clone)] +pub(in crate::mux) struct AnnexBNal { + pub(in crate::mux) source_offset: u64, + pub(in crate::mux) bytes: Vec, +} + +#[derive(Default)] +pub(in crate::mux) struct AnnexBNalScanner { + buffer: Vec, + buffer_start_offset: u64, + next_input_offset: u64, +} + +impl AnnexBNalScanner { + pub(in crate::mux) fn push(&mut self, chunk: &[u8], mut on_nal: F) -> Result<(), MuxError> + where + F: FnMut(AnnexBNal) -> Result<(), MuxError>, + { + for nal in self.collect(chunk) { + on_nal(nal)?; + } + Ok(()) + } + + pub(in crate::mux) fn finish(&mut self, mut on_nal: F) -> Result<(), MuxError> + where + F: FnMut(AnnexBNal) -> Result<(), MuxError>, + { + for nal in self.finish_collect() { + on_nal(nal)?; + } + Ok(()) + } + + pub(in crate::mux) fn collect(&mut self, chunk: &[u8]) -> Vec { + if self.buffer.is_empty() { + self.buffer_start_offset = self.next_input_offset; + } + self.buffer.extend_from_slice(chunk); + self.next_input_offset = self + .next_input_offset + .saturating_add(u64::try_from(chunk.len()).unwrap()); + self.drain_available() + } + + pub(in crate::mux) fn finish_collect(&mut self) -> Vec { + let mut nals = self.drain_available(); + if let Some((start, start_len)) = find_annex_b_start_code(&self.buffer) { + let data_start = start + start_len; + if data_start < self.buffer.len() { + let mut data_end = self.buffer.len(); + while data_end > data_start && self.buffer[data_end - 1] == 0 { + data_end -= 1; + } + if data_end > data_start { + nals.push(AnnexBNal { + source_offset: self.buffer_start_offset + + u64::try_from(data_start).unwrap(), + bytes: self.buffer[data_start..data_end].to_vec(), + }); + } + } + } + self.buffer.clear(); + nals + } + + fn drain_available(&mut self) -> Vec { + let mut nals = Vec::new(); + loop { + let Some((first_start, first_len)) = find_annex_b_start_code(&self.buffer) else { + if self.buffer.len() > 3 { + let retain_from = self.buffer.len() - 3; + self.buffer.drain(..retain_from); + self.buffer_start_offset += u64::try_from(retain_from).unwrap(); + } + break; + }; + if first_start > 0 { + self.buffer.drain(..first_start); + self.buffer_start_offset += u64::try_from(first_start).unwrap(); + continue; + } + let Some((next_start, _)) = find_annex_b_start_code(&self.buffer[first_len..]) + .map(|(start, len)| (start + first_len, len)) + else { + break; + }; + let data_start = first_len; + let mut data_end = next_start; + while data_end > data_start && self.buffer[data_end - 1] == 0 { + data_end -= 1; + } + if data_end > data_start { + nals.push(AnnexBNal { + source_offset: self.buffer_start_offset + u64::try_from(data_start).unwrap(), + bytes: self.buffer[data_start..data_end].to_vec(), + }); + } + self.buffer.drain(..next_start); + self.buffer_start_offset += u64::try_from(next_start).unwrap(); + } + nals + } +} + +pub(in crate::mux) fn find_annex_b_start_code(bytes: &[u8]) -> Option<(usize, usize)> { + let mut index = 0usize; + while index + 2 < bytes.len() { + if index + 3 < bytes.len() && bytes[index..].starts_with(&[0, 0, 0, 1]) { + return Some((index, 4)); + } + if bytes[index..].starts_with(&[0, 0, 1]) { + return Some((index, 3)); + } + index += 1; + } + None +} + +pub(in crate::mux) fn push_unique_nal(existing: &mut Vec>, nal: Vec) { + if !existing.iter().any(|entry| entry == &nal) { + existing.push(nal); + } +} + +pub(in crate::mux) fn nal_to_rbsp(nal: &[u8]) -> Vec { + let mut rbsp = Vec::with_capacity(nal.len()); + let mut zero_count = 0_u8; + for &byte in nal { + if zero_count == 2 && byte == 0x03 { + zero_count = 0; + continue; + } + rbsp.push(byte); + if byte == 0 { + zero_count = zero_count.saturating_add(1); + } else { + zero_count = 0; + } + } + rbsp +} + +pub(in crate::mux) fn skip_bits_labeled( + reader: &mut BitReader, + width: usize, + spec: &str, + label: &str, +) -> Result<(), MuxError> +where + R: Read, +{ + let _ = reader + .read_bits(width) + .map_err(|error| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("failed to read {label} bitstream: {error}"), + })?; + Ok(()) +} + +pub(in crate::mux) fn read_bit_labeled( + reader: &mut BitReader, + spec: &str, + label: &str, +) -> Result +where + R: Read, +{ + reader + .read_bit() + .map_err(|error| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("failed to read {label} bitstream: {error}"), + }) +} + +pub(in crate::mux) fn read_bits_u8_labeled( + reader: &mut BitReader, + width: usize, + spec: &str, + label: &str, +) -> Result +where + R: Read, +{ + let bits = reader + .read_bits(width) + .map_err(|error| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("failed to read {label} bitstream: {error}"), + })?; + let mut value = 0_u16; + for byte in bits { + value = (value << 8) | u16::from(byte); + } + u8::try_from(value).map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("{label} bitfield does not fit in u8"), + }) +} + +pub(in crate::mux) fn read_bits_u16_labeled( + reader: &mut BitReader, + width: usize, + spec: &str, + label: &str, +) -> Result +where + R: Read, +{ + let bits = reader + .read_bits(width) + .map_err(|error| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("failed to read {label} bitstream: {error}"), + })?; + let mut value = 0_u32; + for byte in bits { + value = (value << 8) | u32::from(byte); + } + u16::try_from(value).map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("{label} bitfield does not fit in u16"), + }) +} + +pub(in crate::mux) fn read_bits_u32_labeled( + reader: &mut BitReader, + width: usize, + spec: &str, + label: &str, +) -> Result +where + R: Read, +{ + let bits = reader + .read_bits(width) + .map_err(|error| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("failed to read {label} bitstream: {error}"), + })?; + let mut value = 0_u64; + for byte in bits { + value = (value << 8) | u64::from(byte); + } + u32::try_from(value).map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("{label} bitfield does not fit in u32"), + }) +} + +pub(in crate::mux) fn read_ue_labeled( + reader: &mut BitReader, + spec: &str, + label: &str, +) -> Result +where + R: Read, +{ + let mut leading_zero_bits = 0_u32; + while !read_bit_labeled(reader, spec, label)? { + leading_zero_bits = leading_zero_bits + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("Exp-Golomb prefix"))?; + if leading_zero_bits > 31 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("{label} Exp-Golomb prefix is too large"), + }); + } + } + if leading_zero_bits == 0 { + return Ok(0); + } + let suffix = read_bits_u32_labeled(reader, leading_zero_bits as usize, spec, label)?; + Ok((1_u32 << leading_zero_bits) - 1 + suffix) +} + +pub(in crate::mux) fn read_se_labeled( + reader: &mut BitReader, + spec: &str, + label: &str, +) -> Result +where + R: Read, +{ + let code_num = read_ue_labeled(reader, spec, label)?; + let magnitude = + i32::try_from(code_num.div_ceil(2)).map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("{label} signed Exp-Golomb value is too large"), + })?; + if code_num % 2 == 0 { + Ok(-magnitude) + } else { + Ok(magnitude) + } +} diff --git a/src/mux/demux/av1.rs b/src/mux/demux/av1.rs new file mode 100644 index 0000000..abc8ded --- /dev/null +++ b/src/mux/demux/av1.rs @@ -0,0 +1,2575 @@ +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +use crate::FourCc; +use crate::boxes::av1::AV1CodecConfiguration; +use crate::boxes::iso14496_12::{Colr, Pasp}; + +use super::super::import::{ + SegmentedMuxSourceSegment, SegmentedMuxSourceSegmentData, SegmentedMuxSourceSpec, StagedSample, +}; +use super::super::import::{build_btrt_from_sample_sizes, build_visual_sample_entry_box}; +use super::super::{MuxError, MuxRawCodec}; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::read_segmented_bytes_sync; +#[cfg(feature = "async")] +use super::ivf_common::read_indexed_sample_async; +#[cfg(feature = "async")] +use super::ivf_common::scan_ivf_video_file_async; +use super::ivf_common::{read_indexed_sample_sync, scan_ivf_video_file_sync}; + +const AV1_COLOUR_TYPE_NCLX: FourCc = FourCc::from_bytes(*b"nclx"); +const OBU_SEQUENCE_HEADER: u8 = 1; +const OBU_TEMPORAL_DELIMITER: u8 = 2; +const OBU_FRAME_HEADER: u8 = 3; +const OBU_FRAME: u8 = 6; +const RAW_AV1_OBU_TIMESCALE: u32 = 1_200_000; +const RAW_AV1_OBU_SAMPLE_DURATION: u32 = 48_000; +const RAW_AV1_ANNEX_B_TIMESCALE: u32 = 25_000; +const RAW_AV1_ANNEX_B_SAMPLE_DURATION: u32 = 1_000; +const TRANSPORT_AV1_TIMESCALE: u32 = 90_000; + +pub(in crate::mux) enum ParsedAv1TrackSource { + File, + Segmented(SegmentedMuxSourceSpec), +} + +pub(in crate::mux) struct ParsedAv1Track { + pub(in crate::mux) width: u16, + pub(in crate::mux) height: u16, + pub(in crate::mux) timescale: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, + pub(in crate::mux) source: ParsedAv1TrackSource, +} + +pub(in crate::mux) fn scan_av1_file_sync( + path: &Path, + spec: &str, +) -> Result { + if path_starts_with(path, b"DKIF")? { + return scan_av1_ivf_sync(path, spec); + } + match av1_non_ivf_input_form(path) { + Some(Av1InputForm::Section5Obu) => scan_av1_section5_sync(path, spec), + Some(Av1InputForm::AnnexB) => scan_av1_annex_b_sync(path, spec), + Some(Av1InputForm::GenericAv1) => match scan_av1_annex_b_sync(path, spec) { + Ok(track) => Ok(track), + Err(MuxError::UnsupportedTrackImport { .. }) => scan_av1_section5_sync(path, spec), + Err(error) => Err(error), + }, + None => Err(unsupported( + spec, + "raw AV1 direct ingest currently expects IVF, `.obu`, `.av1`, or `.av1b` inputs", + )), + } +} + +pub(in crate::mux) fn scan_transport_av1_segmented_sync( + path: &Path, + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + sample_offsets: &[u64], + carried_descriptor: [u8; 4], + spec: &str, +) -> Result { + scan_transport_av1_segmented_inner( + path, + total_size, + sample_offsets, + carried_descriptor, + spec, + |offset, bytes, message| { + read_segmented_bytes_sync(file, segments, total_size, offset, bytes, spec, message) + }, + ) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_av1_file_async( + path: &Path, + spec: &str, +) -> Result { + if path_starts_with_async(path, b"DKIF").await? { + return scan_av1_ivf_async(path, spec).await; + } + match av1_non_ivf_input_form(path) { + Some(Av1InputForm::Section5Obu) => scan_av1_section5_async(path, spec).await, + Some(Av1InputForm::AnnexB) => scan_av1_annex_b_async(path, spec).await, + Some(Av1InputForm::GenericAv1) => match scan_av1_annex_b_async(path, spec).await { + Ok(track) => Ok(track), + Err(MuxError::UnsupportedTrackImport { .. }) => { + scan_av1_section5_async(path, spec).await + } + Err(error) => Err(error), + }, + None => Err(unsupported( + spec, + "raw AV1 direct ingest currently expects IVF, `.obu`, `.av1`, or `.av1b` inputs", + )), + } +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_transport_av1_segmented_async( + path: &Path, + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + sample_offsets: &[u64], + carried_descriptor: [u8; 4], + spec: &str, +) -> Result { + if sample_offsets.is_empty() { + return Err(unsupported( + spec, + "transport-stream AV1 carriage did not expose any PES access-unit boundaries", + )); + } + + let mut logical_size = 0_u64; + let mut transformed_segments = Vec::with_capacity(sample_offsets.len()); + let mut samples = Vec::with_capacity(sample_offsets.len()); + let mut first_sample_bytes = None::>; + + for (index, &sample_offset) in sample_offsets.iter().enumerate() { + let sample_end = sample_offsets.get(index + 1).copied().unwrap_or(total_size); + if sample_end <= sample_offset || sample_end > total_size { + return Err(unsupported( + spec, + "transport-stream AV1 PES access-unit boundaries were malformed", + )); + } + let sample_size = usize::try_from(sample_end - sample_offset) + .map_err(|_| MuxError::LayoutOverflow("transport-stream AV1 sample size"))?; + let mut sample_bytes = vec![0_u8; sample_size]; + read_segmented_bytes_async( + file, + segments, + total_size, + sample_offset, + &mut sample_bytes, + spec, + "transport-stream AV1 sample payload is truncated", + ) + .await?; + let normalized = normalize_transport_av1_sample(&sample_bytes, spec)?; + if normalized.is_empty() { + return Err(unsupported( + spec, + "transport-stream AV1 sample payload did not contain any decodable OBUs", + )); + } + if first_sample_bytes.is_none() { + first_sample_bytes = Some(normalized.clone()); + } + let normalized_size = u32::try_from(normalized.len()) + .map_err(|_| MuxError::LayoutOverflow("transport-stream AV1 normalized sample"))?; + transformed_segments.push(SegmentedMuxSourceSegment { + logical_offset: logical_size, + data: SegmentedMuxSourceSegmentData::Bytes(normalized), + }); + samples.push(StagedSample { + data_offset: logical_size, + data_size: normalized_size, + duration: 0, + composition_time_offset: 0, + is_sync_sample: true, + }); + logical_size = logical_size.checked_add(u64::from(normalized_size)).ok_or( + MuxError::LayoutOverflow("transport-stream AV1 transformed payload"), + )?; + } + + let first_sample_bytes = first_sample_bytes.ok_or_else(|| { + unsupported( + spec, + "transport-stream AV1 input did not produce any decodable sample payloads", + ) + })?; + let (sample_entry_box, width, height) = build_transport_av1_sample_entry_box_from_sample( + &first_sample_bytes, + carried_descriptor, + spec, + )?; + Ok(ParsedAv1Track { + width, + height, + timescale: TRANSPORT_AV1_TIMESCALE, + sample_entry_box, + samples, + source: ParsedAv1TrackSource::Segmented(SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: transformed_segments, + total_size: logical_size, + }), + }) +} + +#[derive(Clone, Copy)] +enum Av1InputForm { + Section5Obu, + AnnexB, + GenericAv1, +} + +#[derive(Clone, Copy)] +enum RawAv1TrackProfile { + Section5Obu, + AnnexB, +} + +fn av1_non_ivf_input_form(path: &Path) -> Option { + let extension = path.extension()?.to_str()?; + if extension.eq_ignore_ascii_case("obu") { + return Some(Av1InputForm::Section5Obu); + } + if extension.eq_ignore_ascii_case("av1b") { + return Some(Av1InputForm::AnnexB); + } + if extension.eq_ignore_ascii_case("av1") { + return Some(Av1InputForm::GenericAv1); + } + None +} + +fn path_starts_with(path: &Path, signature: &[u8]) -> Result { + let mut file = File::open(path)?; + let mut prefix = vec![0_u8; signature.len()]; + let read = file.read(&mut prefix)?; + Ok(read == signature.len() && prefix == signature) +} + +#[cfg(feature = "async")] +async fn path_starts_with_async(path: &Path, signature: &[u8]) -> Result { + let mut file = TokioFile::open(path).await?; + let mut prefix = vec![0_u8; signature.len()]; + let read = file.read(&mut prefix).await?; + Ok(read == signature.len() && prefix == signature) +} + +fn scan_av1_ivf_sync(path: &Path, spec: &str) -> Result { + let mut indexed = scan_ivf_video_file_sync(path, MuxRawCodec::Av1, spec)?; + normalize_av1_ivf_sample_spans_sync(path, &mut indexed, spec)?; + let first_sample = read_indexed_sample_sync( + path, + indexed.first_sample_span, + spec, + "IVF AV1 sample payload is truncated", + )?; + let first_sequence_header = parse_av1_sequence_header_from_sample(&first_sample, spec)?; + annotate_av1_ivf_sync_samples_sync( + path, + spec, + &mut indexed.samples, + first_sequence_header.reduced_still_picture_header, + )?; + let (sample_entry_box, width, height) = build_av1_sample_entry_box_from_sample( + &first_sample, + &indexed.samples, + indexed.timescale, + spec, + )?; + Ok(ParsedAv1Track { + width, + height, + timescale: indexed.timescale, + sample_entry_box, + samples: indexed.samples, + source: ParsedAv1TrackSource::File, + }) +} + +#[cfg(feature = "async")] +async fn scan_av1_ivf_async(path: &Path, spec: &str) -> Result { + let mut indexed = scan_ivf_video_file_async(path, MuxRawCodec::Av1, spec).await?; + normalize_av1_ivf_sample_spans_async(path, &mut indexed, spec).await?; + let first_sample = read_indexed_sample_async( + path, + indexed.first_sample_span, + spec, + "IVF AV1 sample payload is truncated", + ) + .await?; + let first_sequence_header = parse_av1_sequence_header_from_sample(&first_sample, spec)?; + annotate_av1_ivf_sync_samples_async( + path, + spec, + &mut indexed.samples, + first_sequence_header.reduced_still_picture_header, + ) + .await?; + let (sample_entry_box, width, height) = build_av1_sample_entry_box_from_sample( + &first_sample, + &indexed.samples, + indexed.timescale, + spec, + )?; + Ok(ParsedAv1Track { + width, + height, + timescale: indexed.timescale, + sample_entry_box, + samples: indexed.samples, + source: ParsedAv1TrackSource::File, + }) +} + +fn scan_av1_section5_sync(path: &Path, spec: &str) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let mut offset = 0_u64; + let mut saw_temporal_delimiter = false; + let mut first_sample_bytes = Vec::new(); + let mut current_sample_offset = None::; + let mut current_sample_size = 0_u32; + let mut samples = Vec::new(); + + while offset < file_size { + let obu = read_section5_obu_sync(&mut file, file_size, offset, spec, samples.is_empty())?; + offset = offset + .checked_add(u64::from(obu.total_size)) + .ok_or(MuxError::LayoutOverflow("raw AV1 OBU offset"))?; + if obu.obu_type == OBU_TEMPORAL_DELIMITER { + saw_temporal_delimiter = true; + if let Some(sample_offset) = current_sample_offset.take() { + if current_sample_size == 0 { + return Err(unsupported( + spec, + "raw AV1 OBU streams contained an empty temporal unit", + )); + } + samples.push(StagedSample { + data_offset: sample_offset, + data_size: current_sample_size, + duration: RAW_AV1_OBU_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: true, + }); + current_sample_size = 0; + } + continue; + } + if !saw_temporal_delimiter && current_sample_offset.is_none() && samples.is_empty() { + return Err(unsupported( + spec, + "raw AV1 OBU streams must begin each temporal unit with a temporal-delimiter OBU", + )); + } + if current_sample_offset.is_none() { + current_sample_offset = Some(obu.file_offset); + } + current_sample_size = current_sample_size + .checked_add(obu.total_size) + .ok_or(MuxError::LayoutOverflow("raw AV1 sample size"))?; + if samples.is_empty() { + first_sample_bytes.extend_from_slice(&obu.normalized_bytes); + } + } + + if let Some(sample_offset) = current_sample_offset.take() { + if current_sample_size == 0 { + return Err(unsupported( + spec, + "raw AV1 OBU streams contained an empty trailing temporal unit", + )); + } + samples.push(StagedSample { + data_offset: sample_offset, + data_size: current_sample_size, + duration: RAW_AV1_OBU_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + + finalize_raw_av1_track( + path, + spec, + RawAv1TrackProfile::Section5Obu, + RAW_AV1_OBU_TIMESCALE, + first_sample_bytes, + samples, + ParsedAv1TrackSource::File, + ) +} + +#[cfg(feature = "async")] +async fn scan_av1_section5_async(path: &Path, spec: &str) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let mut offset = 0_u64; + let mut saw_temporal_delimiter = false; + let mut first_sample_bytes = Vec::new(); + let mut current_sample_offset = None::; + let mut current_sample_size = 0_u32; + let mut samples = Vec::new(); + + while offset < file_size { + let obu = + read_section5_obu_async(&mut file, file_size, offset, spec, samples.is_empty()).await?; + offset = offset + .checked_add(u64::from(obu.total_size)) + .ok_or(MuxError::LayoutOverflow("raw AV1 OBU offset"))?; + if obu.obu_type == OBU_TEMPORAL_DELIMITER { + saw_temporal_delimiter = true; + if let Some(sample_offset) = current_sample_offset.take() { + if current_sample_size == 0 { + return Err(unsupported( + spec, + "raw AV1 OBU streams contained an empty temporal unit", + )); + } + samples.push(StagedSample { + data_offset: sample_offset, + data_size: current_sample_size, + duration: RAW_AV1_OBU_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: true, + }); + current_sample_size = 0; + } + continue; + } + if !saw_temporal_delimiter && current_sample_offset.is_none() && samples.is_empty() { + return Err(unsupported( + spec, + "raw AV1 OBU streams must begin each temporal unit with a temporal-delimiter OBU", + )); + } + if current_sample_offset.is_none() { + current_sample_offset = Some(obu.file_offset); + } + current_sample_size = current_sample_size + .checked_add(obu.total_size) + .ok_or(MuxError::LayoutOverflow("raw AV1 sample size"))?; + if samples.is_empty() { + first_sample_bytes.extend_from_slice(&obu.normalized_bytes); + } + } + + if let Some(sample_offset) = current_sample_offset.take() { + if current_sample_size == 0 { + return Err(unsupported( + spec, + "raw AV1 OBU streams contained an empty trailing temporal unit", + )); + } + samples.push(StagedSample { + data_offset: sample_offset, + data_size: current_sample_size, + duration: RAW_AV1_OBU_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + + finalize_raw_av1_track( + path, + spec, + RawAv1TrackProfile::Section5Obu, + RAW_AV1_OBU_TIMESCALE, + first_sample_bytes, + samples, + ParsedAv1TrackSource::File, + ) +} + +fn scan_av1_annex_b_sync(path: &Path, spec: &str) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let mut offset = 0_u64; + let mut logical_size = 0_u64; + let mut segments = Vec::new(); + let mut samples = Vec::new(); + let mut first_sample_bytes = Vec::new(); + + while offset < file_size { + let (temporal_unit_size, temporal_unit_leb_size) = + read_leb128_from_file_sync(&mut file, offset, spec, "AV1 temporal-unit size")?; + offset = offset + .checked_add(u64::try_from(temporal_unit_leb_size).unwrap()) + .ok_or(MuxError::LayoutOverflow("AV1 temporal-unit offset"))?; + if temporal_unit_size == 0 { + return Err(unsupported( + spec, + "AV1 Annex B temporal units must have non-zero sizes", + )); + } + let temporal_unit_end = offset + .checked_add(temporal_unit_size) + .ok_or(MuxError::LayoutOverflow("AV1 temporal-unit size"))?; + if temporal_unit_end > file_size { + return Err(unsupported( + spec, + "AV1 Annex B temporal-unit payload overruns the input length", + )); + } + + let sample_offset = logical_size; + let mut sample_size = 0_u32; + let capture_first_sample = samples.is_empty(); + + while offset < temporal_unit_end { + let (frame_unit_size, frame_unit_leb_size) = + read_leb128_from_file_sync(&mut file, offset, spec, "AV1 frame-unit size")?; + offset = offset + .checked_add(u64::try_from(frame_unit_leb_size).unwrap()) + .ok_or(MuxError::LayoutOverflow("AV1 frame-unit offset"))?; + if frame_unit_size == 0 { + return Err(unsupported( + spec, + "AV1 Annex B frame units must have non-zero sizes", + )); + } + let frame_unit_end = offset + .checked_add(frame_unit_size) + .ok_or(MuxError::LayoutOverflow("AV1 frame-unit size"))?; + if frame_unit_end > temporal_unit_end { + return Err(unsupported( + spec, + "AV1 Annex B frame-unit payload overruns its temporal unit", + )); + } + while offset < frame_unit_end { + let (obu_size, obu_leb_size) = + read_leb128_from_file_sync(&mut file, offset, spec, "AV1 OBU size")?; + offset = offset + .checked_add(u64::try_from(obu_leb_size).unwrap()) + .ok_or(MuxError::LayoutOverflow("AV1 OBU offset"))?; + if obu_size == 0 { + return Err(unsupported( + spec, + "AV1 Annex B OBUs must have non-zero sizes", + )); + } + let obu_end = offset + .checked_add(obu_size) + .ok_or(MuxError::LayoutOverflow("AV1 OBU size"))?; + if obu_end > frame_unit_end { + return Err(unsupported( + spec, + "AV1 Annex B OBU payload overruns its frame unit", + )); + } + let parsed_obu = read_annex_b_obu_sync( + &mut file, + offset, + u32::try_from(obu_size) + .map_err(|_| MuxError::LayoutOverflow("AV1 Annex B OBU size"))?, + spec, + capture_first_sample, + )?; + offset = obu_end; + if parsed_obu.obu_type == OBU_TEMPORAL_DELIMITER { + continue; + } + if sample_size == 0 && logical_size != sample_offset { + return Err(MuxError::LayoutOverflow("AV1 sample logical offset")); + } + append_segmented_av1_bytes( + &mut segments, + &mut logical_size, + &mut sample_size, + parsed_obu.segment_prefix, + parsed_obu.file_range, + )?; + if capture_first_sample { + first_sample_bytes.extend_from_slice(&parsed_obu.normalized_bytes); + } + } + } + if offset != temporal_unit_end || sample_size == 0 { + return Err(unsupported( + spec, + "AV1 Annex B temporal units must contribute at least one non-delimiter OBU sample payload", + )); + } + samples.push(StagedSample { + data_offset: sample_offset, + data_size: sample_size, + duration: RAW_AV1_ANNEX_B_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + + finalize_raw_av1_track( + path, + spec, + RawAv1TrackProfile::AnnexB, + RAW_AV1_ANNEX_B_TIMESCALE, + first_sample_bytes, + samples, + ParsedAv1TrackSource::Segmented(SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments, + total_size: logical_size, + }), + ) +} + +#[cfg(feature = "async")] +async fn scan_av1_annex_b_async(path: &Path, spec: &str) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let mut offset = 0_u64; + let mut logical_size = 0_u64; + let mut segments = Vec::new(); + let mut samples = Vec::new(); + let mut first_sample_bytes = Vec::new(); + + while offset < file_size { + let (temporal_unit_size, temporal_unit_leb_size) = + read_leb128_from_file_async(&mut file, offset, spec, "AV1 temporal-unit size").await?; + offset = offset + .checked_add(u64::try_from(temporal_unit_leb_size).unwrap()) + .ok_or(MuxError::LayoutOverflow("AV1 temporal-unit offset"))?; + if temporal_unit_size == 0 { + return Err(unsupported( + spec, + "AV1 Annex B temporal units must have non-zero sizes", + )); + } + let temporal_unit_end = offset + .checked_add(temporal_unit_size) + .ok_or(MuxError::LayoutOverflow("AV1 temporal-unit size"))?; + if temporal_unit_end > file_size { + return Err(unsupported( + spec, + "AV1 Annex B temporal-unit payload overruns the input length", + )); + } + + let sample_offset = logical_size; + let mut sample_size = 0_u32; + let capture_first_sample = samples.is_empty(); + + while offset < temporal_unit_end { + let (frame_unit_size, frame_unit_leb_size) = + read_leb128_from_file_async(&mut file, offset, spec, "AV1 frame-unit size").await?; + offset = offset + .checked_add(u64::try_from(frame_unit_leb_size).unwrap()) + .ok_or(MuxError::LayoutOverflow("AV1 frame-unit offset"))?; + if frame_unit_size == 0 { + return Err(unsupported( + spec, + "AV1 Annex B frame units must have non-zero sizes", + )); + } + let frame_unit_end = offset + .checked_add(frame_unit_size) + .ok_or(MuxError::LayoutOverflow("AV1 frame-unit size"))?; + if frame_unit_end > temporal_unit_end { + return Err(unsupported( + spec, + "AV1 Annex B frame-unit payload overruns its temporal unit", + )); + } + while offset < frame_unit_end { + let (obu_size, obu_leb_size) = + read_leb128_from_file_async(&mut file, offset, spec, "AV1 OBU size").await?; + offset = offset + .checked_add(u64::try_from(obu_leb_size).unwrap()) + .ok_or(MuxError::LayoutOverflow("AV1 OBU offset"))?; + if obu_size == 0 { + return Err(unsupported( + spec, + "AV1 Annex B OBUs must have non-zero sizes", + )); + } + let obu_end = offset + .checked_add(obu_size) + .ok_or(MuxError::LayoutOverflow("AV1 OBU size"))?; + if obu_end > frame_unit_end { + return Err(unsupported( + spec, + "AV1 Annex B OBU payload overruns its frame unit", + )); + } + let parsed_obu = read_annex_b_obu_async( + &mut file, + offset, + u32::try_from(obu_size) + .map_err(|_| MuxError::LayoutOverflow("AV1 Annex B OBU size"))?, + spec, + capture_first_sample, + ) + .await?; + offset = obu_end; + if parsed_obu.obu_type == OBU_TEMPORAL_DELIMITER { + continue; + } + append_segmented_av1_bytes( + &mut segments, + &mut logical_size, + &mut sample_size, + parsed_obu.segment_prefix, + parsed_obu.file_range, + )?; + if capture_first_sample { + first_sample_bytes.extend_from_slice(&parsed_obu.normalized_bytes); + } + } + } + if offset != temporal_unit_end || sample_size == 0 { + return Err(unsupported( + spec, + "AV1 Annex B temporal units must contribute at least one non-delimiter OBU sample payload", + )); + } + samples.push(StagedSample { + data_offset: sample_offset, + data_size: sample_size, + duration: RAW_AV1_ANNEX_B_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + + finalize_raw_av1_track( + path, + spec, + RawAv1TrackProfile::AnnexB, + RAW_AV1_ANNEX_B_TIMESCALE, + first_sample_bytes, + samples, + ParsedAv1TrackSource::Segmented(SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments, + total_size: logical_size, + }), + ) +} + +fn finalize_raw_av1_track( + _path: &Path, + spec: &str, + profile: RawAv1TrackProfile, + timescale: u32, + first_sample_bytes: Vec, + samples: Vec, + source: ParsedAv1TrackSource, +) -> Result { + if samples.is_empty() || first_sample_bytes.is_empty() { + return Err(unsupported( + spec, + "raw AV1 input did not produce any decodable sample payloads", + )); + } + let (sample_entry_box, width, height) = build_raw_av1_sample_entry_box_from_sample( + profile, + &first_sample_bytes, + &samples, + timescale, + spec, + )?; + Ok(ParsedAv1Track { + width, + height, + timescale, + sample_entry_box, + samples, + source, + }) +} + +fn scan_transport_av1_segmented_inner( + path: &Path, + total_size: u64, + sample_offsets: &[u64], + carried_descriptor: [u8; 4], + spec: &str, + mut read_exact: F, +) -> Result +where + F: FnMut(u64, &mut [u8], &'static str) -> Result<(), MuxError>, +{ + if sample_offsets.is_empty() { + return Err(unsupported( + spec, + "transport-stream AV1 carriage did not expose any PES access-unit boundaries", + )); + } + + let mut logical_size = 0_u64; + let mut transformed_segments = Vec::with_capacity(sample_offsets.len()); + let mut samples = Vec::with_capacity(sample_offsets.len()); + let mut first_sample_bytes = None::>; + + for (index, &sample_offset) in sample_offsets.iter().enumerate() { + let sample_end = sample_offsets.get(index + 1).copied().unwrap_or(total_size); + if sample_end <= sample_offset || sample_end > total_size { + return Err(unsupported( + spec, + "transport-stream AV1 PES access-unit boundaries were malformed", + )); + } + let sample_size = usize::try_from(sample_end - sample_offset) + .map_err(|_| MuxError::LayoutOverflow("transport-stream AV1 sample size"))?; + let mut sample_bytes = vec![0_u8; sample_size]; + read_exact( + sample_offset, + &mut sample_bytes, + "transport-stream AV1 sample payload is truncated", + )?; + let normalized = normalize_transport_av1_sample(&sample_bytes, spec)?; + if normalized.is_empty() { + return Err(unsupported( + spec, + "transport-stream AV1 sample payload did not contain any decodable OBUs", + )); + } + if first_sample_bytes.is_none() { + first_sample_bytes = Some(normalized.clone()); + } + let normalized_size = u32::try_from(normalized.len()) + .map_err(|_| MuxError::LayoutOverflow("transport-stream AV1 normalized sample"))?; + transformed_segments.push(SegmentedMuxSourceSegment { + logical_offset: logical_size, + data: SegmentedMuxSourceSegmentData::Bytes(normalized), + }); + samples.push(StagedSample { + data_offset: logical_size, + data_size: normalized_size, + duration: 0, + composition_time_offset: 0, + is_sync_sample: true, + }); + logical_size = logical_size.checked_add(u64::from(normalized_size)).ok_or( + MuxError::LayoutOverflow("transport-stream AV1 transformed payload"), + )?; + } + + let first_sample_bytes = first_sample_bytes.ok_or_else(|| { + unsupported( + spec, + "transport-stream AV1 input did not produce any decodable sample payloads", + ) + })?; + let (sample_entry_box, width, height) = build_transport_av1_sample_entry_box_from_sample( + &first_sample_bytes, + carried_descriptor, + spec, + )?; + Ok(ParsedAv1Track { + width, + height, + timescale: TRANSPORT_AV1_TIMESCALE, + sample_entry_box, + samples, + source: ParsedAv1TrackSource::Segmented(SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: transformed_segments, + total_size: logical_size, + }), + }) +} + +fn build_av1_sample_entry_box_from_sample( + sample: &[u8], + samples: &[StagedSample], + timescale: u32, + spec: &str, +) -> Result<(Vec, u16, u16), MuxError> { + let (config, colr, width, height) = parse_av1_sample_entry_details(sample, spec)?; + let mut child_boxes = vec![ + super::super::mp4::encode_typed_box(&config, &[])?, + super::super::mp4::encode_typed_box(&colr, &[])?, + ]; + if samples.len() > 1 { + let btrt = build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + timescale, + )?; + child_boxes.push(super::super::mp4::encode_typed_box(&btrt, &[])?); + } + let sample_entry_box = + build_visual_sample_entry_box(FourCc::from_bytes(*b"av01"), width, height, &child_boxes)?; + Ok((sample_entry_box, width, height)) +} + +fn build_transport_av1_sample_entry_box_from_sample( + sample: &[u8], + _carried_descriptor: [u8; 4], + spec: &str, +) -> Result<(Vec, u16, u16), MuxError> { + let (config, colr, width, height) = parse_av1_sample_entry_details(sample, spec)?; + let child_boxes = vec![ + super::super::mp4::encode_typed_box(&config, &[])?, + super::super::mp4::encode_typed_box(&colr, &[])?, + ]; + let sample_entry_box = + build_visual_sample_entry_box(FourCc::from_bytes(*b"av01"), width, height, &child_boxes)?; + Ok((sample_entry_box, width, height)) +} + +fn build_raw_av1_sample_entry_box_from_sample( + profile: RawAv1TrackProfile, + sample: &[u8], + samples: &[StagedSample], + timescale: u32, + spec: &str, +) -> Result<(Vec, u16, u16), MuxError> { + let (config, colr, width, height) = parse_av1_sample_entry_details(sample, spec)?; + let mut child_boxes = vec![super::super::mp4::encode_typed_box(&config, &[])?]; + if matches!(profile, RawAv1TrackProfile::Section5Obu) { + child_boxes.push(super::super::mp4::encode_typed_box( + &Pasp { + h_spacing: 1, + v_spacing: 1, + }, + &[], + )?); + } + child_boxes.push(super::super::mp4::encode_typed_box(&colr, &[])?); + if samples.len() > 1 { + let btrt = build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + timescale, + )?; + child_boxes.push(super::super::mp4::encode_typed_box(&btrt, &[])?); + } + let sample_entry_box = + build_visual_sample_entry_box(FourCc::from_bytes(*b"av01"), width, height, &child_boxes)?; + Ok((sample_entry_box, width, height)) +} + +fn normalize_transport_av1_sample(sample: &[u8], spec: &str) -> Result, MuxError> { + let mut offset = 0usize; + let mut normalized = Vec::new(); + while offset < sample.len() { + let start = find_transport_av1_start_code(sample, offset).ok_or_else(|| { + unsupported( + spec, + "transport-stream AV1 sample payload did not begin with MPEG-TS start-code framing", + ) + })?; + let payload_start = start + 3; + let next_start = + find_transport_av1_start_code(sample, payload_start).unwrap_or(sample.len()); + if next_start <= payload_start { + return Err(unsupported( + spec, + "transport-stream AV1 sample payload carried an empty start-code framed OBU", + )); + } + let encoded_obu = &sample[payload_start..next_start]; + let obu = remove_transport_av1_emulation_bytes(encoded_obu); + validate_transport_av1_obu(&obu, spec)?; + normalized.extend_from_slice(&obu); + offset = next_start; + } + Ok(normalized) +} + +fn find_transport_av1_start_code(bytes: &[u8], offset: usize) -> Option { + let mut cursor = offset; + while cursor + 3 <= bytes.len() { + if bytes[cursor..].starts_with(&[0x00, 0x00, 0x01]) { + return Some(cursor); + } + cursor += 1; + } + None +} + +fn remove_transport_av1_emulation_bytes(bytes: &[u8]) -> Vec { + let mut normalized = Vec::with_capacity(bytes.len()); + let mut index = 0usize; + while index < bytes.len() { + if index + 2 < bytes.len() + && bytes[index] == 0x00 + && bytes[index + 1] == 0x00 + && bytes[index + 2] == 0x03 + && bytes.get(index + 3).is_some_and(|next| *next <= 0x03) + { + normalized.push(0x00); + normalized.push(0x00); + index += 3; + continue; + } + normalized.push(bytes[index]); + index += 1; + } + normalized +} + +fn validate_transport_av1_obu(obu: &[u8], spec: &str) -> Result<(), MuxError> { + if obu.is_empty() { + return Err(unsupported( + spec, + "transport-stream AV1 carried an empty OBU after removing MPEG-TS framing", + )); + } + let header = obu[0]; + let extension_flag = (header >> 2) & 0x01 != 0; + let has_size_field = (header >> 1) & 0x01 != 0; + if !has_size_field { + return Err(unsupported( + spec, + "transport-stream AV1 OBUs must keep explicit size fields on the native direct-ingest path", + )); + } + let payload_size_offset = if extension_flag { 2 } else { 1 }; + if payload_size_offset >= obu.len() { + return Err(unsupported( + spec, + "transport-stream AV1 OBU header was truncated before the payload-size field", + )); + } + let (payload_size, leb_size) = read_leb128_from_slice( + &obu[payload_size_offset..], + spec, + "transport-stream AV1 OBU size", + u64::try_from(payload_size_offset).unwrap(), + )?; + let payload_offset = + payload_size_offset + .checked_add(leb_size) + .ok_or(MuxError::LayoutOverflow( + "transport-stream AV1 OBU payload offset", + ))?; + let payload_end = payload_offset + .checked_add(usize::try_from(payload_size).unwrap()) + .ok_or(MuxError::LayoutOverflow( + "transport-stream AV1 OBU payload length", + ))?; + if payload_end != obu.len() { + return Err(unsupported( + spec, + "transport-stream AV1 OBU size fields did not match the start-code framed payload length", + )); + } + Ok(()) +} + +struct ParsedSection5Obu { + file_offset: u64, + total_size: u32, + obu_type: u8, + normalized_bytes: Vec, +} + +struct ParsedAnnexBObu { + obu_type: u8, + segment_prefix: Option>, + file_range: Option<(u64, u32)>, + normalized_bytes: Vec, +} + +fn parse_av1_sample_entry_details( + sample: &[u8], + spec: &str, +) -> Result<(AV1CodecConfiguration, Colr, u16, u16), MuxError> { + let (config_obus, sequence_header) = find_av1_sequence_header_obu(sample, spec)?; + let mut config = AV1CodecConfiguration { + seq_profile: sequence_header.seq_profile, + seq_level_idx_0: sequence_header.seq_level_idx_0, + seq_tier_0: sequence_header.seq_tier_0, + high_bitdepth: u8::from(sequence_header.high_bitdepth), + twelve_bit: u8::from(sequence_header.twelve_bit), + monochrome: u8::from(sequence_header.monochrome), + chroma_subsampling_x: sequence_header.chroma_subsampling_x, + chroma_subsampling_y: sequence_header.chroma_subsampling_y, + chroma_sample_position: sequence_header.chroma_sample_position, + initial_presentation_delay_present: u8::from( + sequence_header + .initial_presentation_delay_minus_one + .is_some(), + ), + initial_presentation_delay_minus_one: sequence_header + .initial_presentation_delay_minus_one + .unwrap_or(0), + config_obus, + }; + if config.initial_presentation_delay_present == 0 { + config.initial_presentation_delay_minus_one = 0; + } + let colr = Colr { + colour_type: AV1_COLOUR_TYPE_NCLX, + colour_primaries: sequence_header.colour_primaries, + transfer_characteristics: sequence_header.transfer_characteristics, + matrix_coefficients: sequence_header.matrix_coefficients, + full_range_flag: sequence_header.full_range_flag, + reserved: 0, + profile: Vec::new(), + unknown: Vec::new(), + }; + Ok((config, colr, sequence_header.width, sequence_header.height)) +} + +fn parse_av1_sequence_header_from_sample( + sample: &[u8], + spec: &str, +) -> Result { + find_av1_sequence_header_obu(sample, spec).map(|(_, sequence_header)| sequence_header) +} + +fn av1_sample_is_sync( + sample: &[u8], + reduced_still_picture_header: bool, + spec: &str, +) -> Result { + let mut offset = 0usize; + while offset < sample.len() { + let header = *sample + .get(offset) + .ok_or_else(|| unsupported(spec, "AV1 OBU header is truncated"))?; + offset += 1; + if header >> 7 != 0 { + return Err(unsupported( + spec, + "AV1 OBU header used a non-zero forbidden bit", + )); + } + let obu_type = (header >> 3) & 0x0F; + let extension_flag = (header >> 2) & 0x01 != 0; + let has_size_field = (header >> 1) & 0x01 != 0; + if header & 0x01 != 0 { + return Err(unsupported( + spec, + "AV1 OBU header used a non-zero reserved bit", + )); + } + if extension_flag { + if sample.get(offset).is_none() { + return Err(unsupported(spec, "AV1 OBU extension header is truncated")); + } + offset += 1; + } + if !has_size_field { + return Err(unsupported( + spec, + "AV1 frame OBUs without explicit size fields are not supported", + )); + } + let (obu_size, leb_bytes) = read_leb128_from_slice( + sample.get(offset..).unwrap_or_default(), + spec, + "AV1 OBU size", + u64::try_from(offset).unwrap_or(u64::MAX), + )?; + offset = offset + .checked_add(leb_bytes) + .ok_or(MuxError::LayoutOverflow("AV1 OBU header size"))?; + let payload_end = offset + .checked_add( + usize::try_from(obu_size).map_err(|_| MuxError::LayoutOverflow("AV1 OBU size"))?, + ) + .ok_or(MuxError::LayoutOverflow("AV1 OBU size"))?; + if payload_end > sample.len() { + return Err(unsupported( + spec, + "AV1 OBU payload overruns the sample payload", + )); + } + if obu_type == OBU_FRAME_HEADER || obu_type == OBU_FRAME { + return av1_frame_header_is_sync( + &sample[offset..payload_end], + reduced_still_picture_header, + spec, + ); + } + offset = payload_end; + } + Ok(false) +} + +fn av1_frame_header_is_sync( + payload: &[u8], + reduced_still_picture_header: bool, + spec: &str, +) -> Result { + if reduced_still_picture_header { + return Ok(true); + } + let mut bits = BitCursor::new(payload); + if bits.read_bit(spec, "AV1 show_existing_frame")? { + return Ok(false); + } + let frame_type = bits.read_bits_u8(2, spec, "AV1 frame_type")?; + Ok(frame_type == 0) +} + +fn normalize_av1_ivf_sample_spans_sync( + path: &Path, + indexed: &mut super::ivf_common::IndexedIvfTrack, + spec: &str, +) -> Result<(), MuxError> { + let mut file = File::open(path)?; + for sample in &mut indexed.samples { + let trim = scan_leading_temporal_delimiter_bytes_sync( + &mut file, + sample.data_offset, + sample.data_size, + spec, + )?; + apply_av1_sample_trim(sample, trim, spec)?; + } + let trim = scan_leading_temporal_delimiter_bytes_sync( + &mut file, + indexed.first_sample_span.data_offset, + indexed.first_sample_span.data_size, + spec, + )?; + apply_av1_ivf_span_trim(&mut indexed.first_sample_span, trim, spec)?; + Ok(()) +} + +#[cfg(feature = "async")] +async fn normalize_av1_ivf_sample_spans_async( + path: &Path, + indexed: &mut super::ivf_common::IndexedIvfTrack, + spec: &str, +) -> Result<(), MuxError> { + let mut file = TokioFile::open(path).await?; + for sample in &mut indexed.samples { + let trim = scan_leading_temporal_delimiter_bytes_async( + &mut file, + sample.data_offset, + sample.data_size, + spec, + ) + .await?; + apply_av1_sample_trim(sample, trim, spec)?; + } + let trim = scan_leading_temporal_delimiter_bytes_async( + &mut file, + indexed.first_sample_span.data_offset, + indexed.first_sample_span.data_size, + spec, + ) + .await?; + apply_av1_ivf_span_trim(&mut indexed.first_sample_span, trim, spec)?; + Ok(()) +} + +fn annotate_av1_ivf_sync_samples_sync( + path: &Path, + spec: &str, + samples: &mut [StagedSample], + reduced_still_picture_header: bool, +) -> Result<(), MuxError> { + let mut file = File::open(path)?; + for sample in samples { + file.seek(SeekFrom::Start(sample.data_offset))?; + let mut sample_bytes = vec![ + 0_u8; + usize::try_from(sample.data_size).map_err(|_| { + MuxError::LayoutOverflow("IVF AV1 sample size") + })? + ]; + file.read_exact(&mut sample_bytes).map_err(|error| { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + unsupported(spec, "IVF AV1 sample payload is truncated") + } else { + MuxError::Io(error) + } + })?; + sample.is_sync_sample = + av1_sample_is_sync(&sample_bytes, reduced_still_picture_header, spec)?; + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn annotate_av1_ivf_sync_samples_async( + path: &Path, + spec: &str, + samples: &mut [StagedSample], + reduced_still_picture_header: bool, +) -> Result<(), MuxError> { + let mut file = TokioFile::open(path).await?; + for sample in samples { + file.seek(SeekFrom::Start(sample.data_offset)).await?; + let mut sample_bytes = vec![ + 0_u8; + usize::try_from(sample.data_size).map_err(|_| { + MuxError::LayoutOverflow("IVF AV1 sample size") + })? + ]; + file.read_exact(&mut sample_bytes).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + unsupported(spec, "IVF AV1 sample payload is truncated") + } else { + MuxError::Io(error) + } + })?; + sample.is_sync_sample = + av1_sample_is_sync(&sample_bytes, reduced_still_picture_header, spec)?; + } + Ok(()) +} + +fn apply_av1_sample_trim(sample: &mut StagedSample, trim: u32, spec: &str) -> Result<(), MuxError> { + if trim == 0 { + return Ok(()); + } + if trim >= sample.data_size { + return Err(unsupported( + spec, + "AV1 sample payload only contained temporal-delimiter OBUs", + )); + } + sample.data_offset = sample + .data_offset + .checked_add(u64::from(trim)) + .ok_or(MuxError::LayoutOverflow("AV1 sample trim offset"))?; + sample.data_size -= trim; + Ok(()) +} + +fn apply_av1_ivf_span_trim( + sample: &mut super::ivf_common::IndexedIvfSample, + trim: u32, + spec: &str, +) -> Result<(), MuxError> { + if trim == 0 { + return Ok(()); + } + if trim >= sample.data_size { + return Err(unsupported( + spec, + "AV1 sample payload only contained temporal-delimiter OBUs", + )); + } + sample.data_offset = sample + .data_offset + .checked_add(u64::from(trim)) + .ok_or(MuxError::LayoutOverflow("AV1 sample trim offset"))?; + sample.data_size -= trim; + Ok(()) +} + +fn read_section5_obu_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, + capture_bytes: bool, +) -> Result { + let (parsed, total_size) = read_av1_obu_header_sync(file, file_size, offset, spec)?; + if parsed.payload_end > file_size { + return Err(unsupported( + spec, + "raw AV1 OBU payload overruns the input length", + )); + } + let normalized_bytes = if capture_bytes { + read_bytes_at_sync( + file, + offset, + total_size, + spec, + "raw AV1 OBU payload is truncated", + )? + } else { + Vec::new() + }; + Ok(ParsedSection5Obu { + file_offset: offset, + total_size, + obu_type: parsed.obu_type, + normalized_bytes, + }) +} + +#[cfg(feature = "async")] +async fn read_section5_obu_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, + capture_bytes: bool, +) -> Result { + let (parsed, total_size) = read_av1_obu_header_async(file, file_size, offset, spec).await?; + if parsed.payload_end > file_size { + return Err(unsupported( + spec, + "raw AV1 OBU payload overruns the input length", + )); + } + let normalized_bytes = if capture_bytes { + read_bytes_at_async( + file, + offset, + total_size, + spec, + "raw AV1 OBU payload is truncated", + ) + .await? + } else { + Vec::new() + }; + Ok(ParsedSection5Obu { + file_offset: offset, + total_size, + obu_type: parsed.obu_type, + normalized_bytes, + }) +} + +fn read_annex_b_obu_sync( + file: &mut File, + obu_offset: u64, + obu_size: u32, + spec: &str, + capture_bytes: bool, +) -> Result { + let obu_end = obu_offset + .checked_add(u64::from(obu_size)) + .ok_or(MuxError::LayoutOverflow("AV1 Annex B OBU end"))?; + let (parsed, _) = read_av1_obu_header_sync(file, obu_end, obu_offset, spec)?; + if parsed.payload_end != obu_end { + return Err(unsupported( + spec, + "AV1 Annex B OBU used an internal size field that disagrees with the wrapper size", + )); + } + let normalized_bytes = if capture_bytes { + if parsed.has_size_field { + read_bytes_at_sync( + file, + obu_offset, + obu_size, + spec, + "AV1 Annex B OBU payload is truncated", + )? + } else { + let mut bytes = parsed.normalized_prefix.clone(); + bytes.extend_from_slice(&read_bytes_at_sync( + file, + parsed.payload_offset, + parsed.payload_size, + spec, + "AV1 Annex B OBU payload is truncated", + )?); + bytes + } + } else { + Vec::new() + }; + let (segment_prefix, file_range) = if parsed.obu_type == OBU_TEMPORAL_DELIMITER { + (None, None) + } else if parsed.has_size_field { + (None, Some((obu_offset, obu_size))) + } else { + ( + Some(parsed.normalized_prefix), + Some((parsed.payload_offset, parsed.payload_size)), + ) + }; + Ok(ParsedAnnexBObu { + obu_type: parsed.obu_type, + segment_prefix, + file_range, + normalized_bytes, + }) +} + +#[cfg(feature = "async")] +async fn read_annex_b_obu_async( + file: &mut TokioFile, + obu_offset: u64, + obu_size: u32, + spec: &str, + capture_bytes: bool, +) -> Result { + let obu_end = obu_offset + .checked_add(u64::from(obu_size)) + .ok_or(MuxError::LayoutOverflow("AV1 Annex B OBU end"))?; + let (parsed, _) = read_av1_obu_header_async(file, obu_end, obu_offset, spec).await?; + if parsed.payload_end != obu_end { + return Err(unsupported( + spec, + "AV1 Annex B OBU used an internal size field that disagrees with the wrapper size", + )); + } + let normalized_bytes = if capture_bytes { + if parsed.has_size_field { + read_bytes_at_async( + file, + obu_offset, + obu_size, + spec, + "AV1 Annex B OBU payload is truncated", + ) + .await? + } else { + let mut bytes = parsed.normalized_prefix.clone(); + bytes.extend_from_slice( + &read_bytes_at_async( + file, + parsed.payload_offset, + parsed.payload_size, + spec, + "AV1 Annex B OBU payload is truncated", + ) + .await?, + ); + bytes + } + } else { + Vec::new() + }; + let (segment_prefix, file_range) = if parsed.obu_type == OBU_TEMPORAL_DELIMITER { + (None, None) + } else if parsed.has_size_field { + (None, Some((obu_offset, obu_size))) + } else { + ( + Some(parsed.normalized_prefix), + Some((parsed.payload_offset, parsed.payload_size)), + ) + }; + Ok(ParsedAnnexBObu { + obu_type: parsed.obu_type, + segment_prefix, + file_range, + normalized_bytes, + }) +} + +struct ParsedAv1ObuHeader { + obu_type: u8, + has_size_field: bool, + payload_offset: u64, + payload_size: u32, + payload_end: u64, + normalized_prefix: Vec, +} + +fn read_av1_obu_header_sync( + file: &mut File, + limit_end: u64, + obu_offset: u64, + spec: &str, +) -> Result<(ParsedAv1ObuHeader, u32), MuxError> { + file.seek(SeekFrom::Start(obu_offset))?; + let header = read_byte_sync(file, spec, "AV1 OBU header is truncated")?; + parse_av1_obu_header_prefix_sync(file, limit_end, obu_offset, header, spec) +} + +#[cfg(feature = "async")] +async fn read_av1_obu_header_async( + file: &mut TokioFile, + limit_end: u64, + obu_offset: u64, + spec: &str, +) -> Result<(ParsedAv1ObuHeader, u32), MuxError> { + file.seek(SeekFrom::Start(obu_offset)).await?; + let header = read_byte_async(file, spec, "AV1 OBU header is truncated").await?; + parse_av1_obu_header_prefix_async(file, limit_end, obu_offset, header, spec).await +} + +fn parse_av1_obu_header_prefix_sync( + file: &mut File, + limit_end: u64, + obu_offset: u64, + header: u8, + spec: &str, +) -> Result<(ParsedAv1ObuHeader, u32), MuxError> { + let (obu_type, extension_flag, has_size_field) = parse_av1_obu_header_bits(header, spec)?; + let mut normalized_prefix = vec![header]; + let mut cursor = obu_offset + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("AV1 OBU header cursor"))?; + if extension_flag { + let extension = read_byte_sync(file, spec, "AV1 OBU extension header is truncated")?; + normalized_prefix.push(extension); + cursor = cursor + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("AV1 OBU extension cursor"))?; + } + let (payload_offset, payload_size) = if has_size_field { + let (obu_payload_size, leb_size, leb_bytes) = + read_leb128_from_current_sync(file, spec, "AV1 OBU size")?; + normalized_prefix.extend_from_slice(&leb_bytes); + let payload_offset = cursor + .checked_add(u64::try_from(leb_size).unwrap()) + .ok_or(MuxError::LayoutOverflow("AV1 OBU payload offset"))?; + let payload_size = u32::try_from(obu_payload_size) + .map_err(|_| MuxError::LayoutOverflow("AV1 OBU payload size"))?; + (payload_offset, payload_size) + } else { + let payload_size = u32::try_from(limit_end.saturating_sub(cursor)) + .map_err(|_| MuxError::LayoutOverflow("AV1 OBU payload size"))?; + normalized_prefix[0] |= 0x02; + normalized_prefix.extend_from_slice(&encode_leb128(payload_size)); + (cursor, payload_size) + }; + validate_av1_temporal_delimiter_payload(obu_type, payload_size, spec)?; + let payload_end = payload_offset + .checked_add(u64::from(payload_size)) + .ok_or(MuxError::LayoutOverflow("AV1 OBU payload end"))?; + let total_size = u32::try_from(payload_end.saturating_sub(obu_offset)) + .map_err(|_| MuxError::LayoutOverflow("AV1 OBU total size"))?; + Ok(( + ParsedAv1ObuHeader { + obu_type, + has_size_field, + payload_offset, + payload_size, + payload_end, + normalized_prefix, + }, + total_size, + )) +} + +#[cfg(feature = "async")] +async fn parse_av1_obu_header_prefix_async( + file: &mut TokioFile, + limit_end: u64, + obu_offset: u64, + header: u8, + spec: &str, +) -> Result<(ParsedAv1ObuHeader, u32), MuxError> { + let (obu_type, extension_flag, has_size_field) = parse_av1_obu_header_bits(header, spec)?; + let mut normalized_prefix = vec![header]; + let mut cursor = obu_offset + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("AV1 OBU header cursor"))?; + if extension_flag { + let extension = + read_byte_async(file, spec, "AV1 OBU extension header is truncated").await?; + normalized_prefix.push(extension); + cursor = cursor + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("AV1 OBU extension cursor"))?; + } + let (payload_offset, payload_size) = if has_size_field { + let (obu_payload_size, leb_size, leb_bytes) = + read_leb128_from_current_async(file, spec, "AV1 OBU size").await?; + normalized_prefix.extend_from_slice(&leb_bytes); + let payload_offset = cursor + .checked_add(u64::try_from(leb_size).unwrap()) + .ok_or(MuxError::LayoutOverflow("AV1 OBU payload offset"))?; + let payload_size = u32::try_from(obu_payload_size) + .map_err(|_| MuxError::LayoutOverflow("AV1 OBU payload size"))?; + (payload_offset, payload_size) + } else { + let payload_size = u32::try_from(limit_end.saturating_sub(cursor)) + .map_err(|_| MuxError::LayoutOverflow("AV1 OBU payload size"))?; + normalized_prefix[0] |= 0x02; + normalized_prefix.extend_from_slice(&encode_leb128(payload_size)); + (cursor, payload_size) + }; + validate_av1_temporal_delimiter_payload(obu_type, payload_size, spec)?; + let payload_end = payload_offset + .checked_add(u64::from(payload_size)) + .ok_or(MuxError::LayoutOverflow("AV1 OBU payload end"))?; + let total_size = u32::try_from(payload_end.saturating_sub(obu_offset)) + .map_err(|_| MuxError::LayoutOverflow("AV1 OBU total size"))?; + Ok(( + ParsedAv1ObuHeader { + obu_type, + has_size_field, + payload_offset, + payload_size, + payload_end, + normalized_prefix, + }, + total_size, + )) +} + +fn parse_av1_obu_header_bits(header: u8, spec: &str) -> Result<(u8, bool, bool), MuxError> { + if header >> 7 != 0 { + return Err(unsupported( + spec, + "AV1 OBU header used a non-zero forbidden bit", + )); + } + if header & 0x01 != 0 { + return Err(unsupported( + spec, + "AV1 OBU header used a non-zero reserved bit", + )); + } + Ok(( + (header >> 3) & 0x0F, + (header >> 2) & 0x01 != 0, + (header >> 1) & 0x01 != 0, + )) +} + +fn validate_av1_temporal_delimiter_payload( + obu_type: u8, + payload_size: u32, + spec: &str, +) -> Result<(), MuxError> { + if obu_type == OBU_TEMPORAL_DELIMITER && payload_size != 0 { + return Err(unsupported( + spec, + "AV1 temporal-delimiter OBU payloads must have zero length", + )); + } + Ok(()) +} + +fn append_segmented_av1_bytes( + segments: &mut Vec, + logical_size: &mut u64, + sample_size: &mut u32, + segment_prefix: Option>, + file_range: Option<(u64, u32)>, +) -> Result<(), MuxError> { + if let Some(prefix) = segment_prefix + && !prefix.is_empty() + { + let prefix_len = u64::try_from(prefix.len()) + .map_err(|_| MuxError::LayoutOverflow("AV1 prefix length"))?; + *sample_size = sample_size + .checked_add(u32::try_from(prefix.len()).unwrap()) + .ok_or(MuxError::LayoutOverflow("AV1 segmented sample size"))?; + segments.push(SegmentedMuxSourceSegment { + logical_offset: *logical_size, + data: SegmentedMuxSourceSegmentData::Bytes(prefix), + }); + *logical_size = logical_size + .checked_add(prefix_len) + .ok_or(MuxError::LayoutOverflow("AV1 segmented logical size"))?; + } + if let Some((source_offset, size)) = file_range { + *sample_size = sample_size + .checked_add(size) + .ok_or(MuxError::LayoutOverflow("AV1 segmented sample size"))?; + segments.push(SegmentedMuxSourceSegment { + logical_offset: *logical_size, + data: SegmentedMuxSourceSegmentData::FileRange { + source_offset, + size, + }, + }); + *logical_size = logical_size + .checked_add(u64::from(size)) + .ok_or(MuxError::LayoutOverflow("AV1 segmented logical size"))?; + } + Ok(()) +} + +fn read_leb128_from_file_sync( + file: &mut File, + offset: u64, + spec: &str, + field_name: &str, +) -> Result<(u64, usize), MuxError> { + file.seek(SeekFrom::Start(offset))?; + let (value, size, _) = read_leb128_from_current_sync(file, spec, field_name)?; + Ok((value, size)) +} + +#[cfg(feature = "async")] +async fn read_leb128_from_file_async( + file: &mut TokioFile, + offset: u64, + spec: &str, + field_name: &str, +) -> Result<(u64, usize), MuxError> { + file.seek(SeekFrom::Start(offset)).await?; + let (value, size, _) = read_leb128_from_current_async(file, spec, field_name).await?; + Ok((value, size)) +} + +fn read_leb128_from_current_sync( + file: &mut File, + spec: &str, + field_name: &str, +) -> Result<(u64, usize, Vec), MuxError> { + let mut value = 0_u64; + let mut shift = 0_u32; + let mut bytes = Vec::new(); + loop { + let byte = read_byte_sync(file, spec, "AV1 leb128 field is truncated")?; + bytes.push(byte); + value |= u64::from(byte & 0x7F) << shift; + if byte & 0x80 == 0 { + return Ok((value, bytes.len(), bytes)); + } + shift = shift + .checked_add(7) + .ok_or(MuxError::LayoutOverflow("AV1 leb128 shift"))?; + if shift >= 63 { + return Err(unsupported( + spec, + &format!("{field_name} used an unterminated or unsupported leb128 value"), + )); + } + } +} + +#[cfg(feature = "async")] +async fn read_leb128_from_current_async( + file: &mut TokioFile, + spec: &str, + field_name: &str, +) -> Result<(u64, usize, Vec), MuxError> { + let mut value = 0_u64; + let mut shift = 0_u32; + let mut bytes = Vec::new(); + loop { + let byte = read_byte_async(file, spec, "AV1 leb128 field is truncated").await?; + bytes.push(byte); + value |= u64::from(byte & 0x7F) << shift; + if byte & 0x80 == 0 { + return Ok((value, bytes.len(), bytes)); + } + shift = shift + .checked_add(7) + .ok_or(MuxError::LayoutOverflow("AV1 leb128 shift"))?; + if shift >= 63 { + return Err(unsupported( + spec, + &format!("{field_name} used an unterminated or unsupported leb128 value"), + )); + } + } +} + +fn read_bytes_at_sync( + file: &mut File, + offset: u64, + size: u32, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + file.seek(SeekFrom::Start(offset))?; + let mut bytes = vec![ + 0_u8; + usize::try_from(size) + .map_err(|_| MuxError::LayoutOverflow("AV1 byte range size"))? + ]; + file.read_exact(&mut bytes) + .map_err(|error| map_temporal_delimiter_io_error(error, spec, truncated_message))?; + Ok(bytes) +} + +#[cfg(feature = "async")] +async fn read_bytes_at_async( + file: &mut TokioFile, + offset: u64, + size: u32, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + file.seek(SeekFrom::Start(offset)).await?; + let mut bytes = vec![ + 0_u8; + usize::try_from(size) + .map_err(|_| MuxError::LayoutOverflow("AV1 byte range size"))? + ]; + file.read_exact(&mut bytes) + .await + .map_err(|error| map_temporal_delimiter_io_error(error, spec, truncated_message))?; + Ok(bytes) +} + +fn read_byte_sync( + file: &mut File, + spec: &str, + truncated_message: &'static str, +) -> Result { + let mut byte = [0_u8; 1]; + file.read_exact(&mut byte) + .map_err(|error| map_temporal_delimiter_io_error(error, spec, truncated_message))?; + Ok(byte[0]) +} + +#[cfg(feature = "async")] +async fn read_byte_async( + file: &mut TokioFile, + spec: &str, + truncated_message: &'static str, +) -> Result { + let mut byte = [0_u8; 1]; + file.read_exact(&mut byte) + .await + .map_err(|error| map_temporal_delimiter_io_error(error, spec, truncated_message))?; + Ok(byte[0]) +} + +fn encode_leb128(mut value: u32) -> Vec { + let mut bytes = Vec::new(); + loop { + let mut byte = u8::try_from(value & 0x7F).unwrap(); + value >>= 7; + if value != 0 { + byte |= 0x80; + } + bytes.push(byte); + if value == 0 { + return bytes; + } + } +} + +fn scan_leading_temporal_delimiter_bytes_sync( + file: &mut File, + sample_offset: u64, + sample_size: u32, + spec: &str, +) -> Result { + let mut trimmed = 0_u32; + loop { + let remaining = sample_size + .checked_sub(trimmed) + .ok_or(MuxError::LayoutOverflow("AV1 sample trim remainder"))?; + if remaining == 0 { + return Err(unsupported( + spec, + "AV1 sample payload only contained temporal-delimiter OBUs", + )); + } + file.seek(SeekFrom::Start( + sample_offset + .checked_add(u64::from(trimmed)) + .ok_or(MuxError::LayoutOverflow("AV1 sample trim seek"))?, + ))?; + let prefix_len = usize::try_from(remaining.min(8)) + .map_err(|_| MuxError::LayoutOverflow("AV1 prefix read size"))?; + let mut prefix = vec![0_u8; prefix_len]; + file.read_exact(&mut prefix).map_err(|error| { + map_temporal_delimiter_io_error( + error, + spec, + "AV1 sample payload is truncated while reading a temporal-delimiter prefix", + ) + })?; + match leading_temporal_delimiter_len(&prefix, spec, sample_offset + u64::from(trimmed))? { + Some(length) => { + trimmed = trimmed + .checked_add(length) + .ok_or(MuxError::LayoutOverflow("AV1 sample trim length"))?; + } + None => return Ok(trimmed), + } + } +} + +#[cfg(feature = "async")] +async fn scan_leading_temporal_delimiter_bytes_async( + file: &mut TokioFile, + sample_offset: u64, + sample_size: u32, + spec: &str, +) -> Result { + let mut trimmed = 0_u32; + loop { + let remaining = sample_size + .checked_sub(trimmed) + .ok_or(MuxError::LayoutOverflow("AV1 sample trim remainder"))?; + if remaining == 0 { + return Err(unsupported( + spec, + "AV1 sample payload only contained temporal-delimiter OBUs", + )); + } + file.seek(SeekFrom::Start( + sample_offset + .checked_add(u64::from(trimmed)) + .ok_or(MuxError::LayoutOverflow("AV1 sample trim seek"))?, + )) + .await?; + let prefix_len = usize::try_from(remaining.min(8)) + .map_err(|_| MuxError::LayoutOverflow("AV1 prefix read size"))?; + let mut prefix = vec![0_u8; prefix_len]; + file.read_exact(&mut prefix).await.map_err(|error| { + map_temporal_delimiter_io_error( + error, + spec, + "AV1 sample payload is truncated while reading a temporal-delimiter prefix", + ) + })?; + match leading_temporal_delimiter_len(&prefix, spec, sample_offset + u64::from(trimmed))? { + Some(length) => { + trimmed = trimmed + .checked_add(length) + .ok_or(MuxError::LayoutOverflow("AV1 sample trim length"))?; + } + None => return Ok(trimmed), + } + } +} + +fn leading_temporal_delimiter_len( + prefix: &[u8], + spec: &str, + offset: u64, +) -> Result, MuxError> { + let mut cursor = 0usize; + let header = *prefix.get(cursor).ok_or_else(|| { + unsupported( + spec, + "AV1 temporal-delimiter prefix is truncated before the OBU header", + ) + })?; + if header >> 7 != 0 { + return Err(unsupported( + spec, + "AV1 OBU header used a non-zero forbidden bit", + )); + } + let obu_type = (header >> 3) & 0x0F; + if obu_type != OBU_TEMPORAL_DELIMITER { + return Ok(None); + } + cursor += 1; + let extension_flag = (header >> 2) & 0x01 != 0; + let has_size_field = (header >> 1) & 0x01 != 0; + if header & 0x01 != 0 { + return Err(unsupported( + spec, + "AV1 OBU header used a non-zero reserved bit", + )); + } + if extension_flag { + if prefix.get(cursor).is_none() { + return Err(unsupported( + spec, + "AV1 temporal-delimiter OBU extension header is truncated", + )); + } + cursor += 1; + } + if !has_size_field { + return Err(unsupported( + spec, + "AV1 temporal-delimiter OBUs without explicit size fields are not supported", + )); + } + let (obu_size, leb_bytes) = read_leb128_from_slice( + prefix.get(cursor..).unwrap_or_default(), + spec, + "AV1 temporal-delimiter OBU size", + offset + u64::try_from(cursor).unwrap_or(u64::MAX), + )?; + if obu_size != 0 { + return Err(unsupported( + spec, + "AV1 temporal-delimiter OBU payloads must have zero length", + )); + } + cursor = cursor + .checked_add(leb_bytes) + .ok_or(MuxError::LayoutOverflow( + "AV1 temporal-delimiter size field", + ))?; + Ok(Some(u32::try_from(cursor).map_err(|_| { + MuxError::LayoutOverflow("AV1 temporal delimiter") + })?)) +} + +fn map_temporal_delimiter_io_error( + error: std::io::Error, + spec: &str, + truncated_message: &'static str, +) -> MuxError { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + unsupported(spec, truncated_message) + } else { + MuxError::Io(error) + } +} + +fn find_av1_sequence_header_obu( + sample: &[u8], + spec: &str, +) -> Result<(Vec, ParsedAv1SequenceHeader), MuxError> { + let mut offset = 0usize; + while offset < sample.len() { + let start = offset; + let header = *sample + .get(offset) + .ok_or_else(|| unsupported(spec, "AV1 OBU header is truncated"))?; + offset += 1; + if header >> 7 != 0 { + return Err(unsupported( + spec, + "AV1 OBU header used a non-zero forbidden bit", + )); + } + let obu_type = (header >> 3) & 0x0F; + let extension_flag = (header >> 2) & 0x01 != 0; + let has_size_field = (header >> 1) & 0x01 != 0; + if header & 0x01 != 0 { + return Err(unsupported( + spec, + "AV1 OBU header used a non-zero reserved bit", + )); + } + if extension_flag { + if sample.get(offset).is_none() { + return Err(unsupported(spec, "AV1 OBU extension header is truncated")); + } + offset += 1; + } + if !has_size_field { + return Err(unsupported( + spec, + "AV1 sequence OBUs without explicit size fields are not supported", + )); + } + let (obu_size, leb_bytes) = read_leb128_from_slice( + sample.get(offset..).unwrap_or_default(), + spec, + "AV1 OBU size", + u64::try_from(offset).unwrap_or(u64::MAX), + )?; + offset = offset + .checked_add(leb_bytes) + .ok_or(MuxError::LayoutOverflow("AV1 OBU header size"))?; + let payload_end = offset + .checked_add( + usize::try_from(obu_size).map_err(|_| MuxError::LayoutOverflow("AV1 OBU size"))?, + ) + .ok_or(MuxError::LayoutOverflow("AV1 OBU size"))?; + if payload_end > sample.len() { + return Err(unsupported( + spec, + "AV1 OBU payload overruns the sample payload", + )); + } + if obu_type == OBU_SEQUENCE_HEADER { + let obu_bytes = sample[start..payload_end].to_vec(); + let sequence_header = parse_av1_sequence_header(&sample[offset..payload_end], spec)?; + return Ok((obu_bytes, sequence_header)); + } + offset = payload_end; + } + Err(unsupported( + spec, + "AV1 input did not contain a sequence-header OBU in its first sample", + )) +} + +#[derive(Clone, Copy)] +struct ParsedAv1SequenceHeader { + seq_profile: u8, + seq_level_idx_0: u8, + seq_tier_0: u8, + reduced_still_picture_header: bool, + width: u16, + height: u16, + high_bitdepth: bool, + twelve_bit: bool, + monochrome: bool, + chroma_subsampling_x: u8, + chroma_subsampling_y: u8, + chroma_sample_position: u8, + initial_presentation_delay_minus_one: Option, + colour_primaries: u16, + transfer_characteristics: u16, + matrix_coefficients: u16, + full_range_flag: bool, +} + +fn parse_av1_sequence_header( + bytes: &[u8], + spec: &str, +) -> Result { + let mut bits = BitCursor::new(bytes); + let seq_profile = bits.read_bits_u8(3, spec, "AV1 seq_profile")?; + let still_picture = bits.read_bit(spec, "AV1 still_picture")?; + let reduced_still_picture_header = bits.read_bit(spec, "AV1 reduced_still_picture_header")?; + if reduced_still_picture_header && !still_picture { + return Err(unsupported( + spec, + "AV1 reduced still-picture headers must also set the still-picture flag", + )); + } + + let mut seq_tier_0 = 0; + let mut initial_presentation_delay_minus_one = None; + let seq_level_idx_0; + let decoder_model_info = if reduced_still_picture_header { + seq_level_idx_0 = bits.read_bits_u8(5, spec, "AV1 seq_level_idx_0")?; + None + } else { + let timing_info_present_flag = bits.read_bit(spec, "AV1 timing_info_present_flag")?; + let decoder_model_info_present_flag = if timing_info_present_flag { + bits.read_bit(spec, "AV1 decoder_model_info_present_flag")? + } else { + false + }; + let decoder_model_info = if timing_info_present_flag && decoder_model_info_present_flag { + skip_timing_info_and_decoder_model(&mut bits, spec)? + } else if timing_info_present_flag { + skip_timing_info_only(&mut bits, spec)?; + None + } else { + None + }; + let initial_display_delay_present_flag = + bits.read_bit(spec, "AV1 initial_display_delay_present_flag")?; + let operating_points_cnt_minus_1 = + bits.read_bits_u8(5, spec, "AV1 operating_points_cnt_minus_1")?; + let mut seq_level = 0; + let mut seq_tier = 0; + let mut initial_delay = None; + for index in 0..=operating_points_cnt_minus_1 { + bits.skip_bits(12, spec, "AV1 operating_point_idc")?; + let level = bits.read_bits_u8(5, spec, "AV1 seq_level_idx")?; + let tier = if level > 7 { + u8::from(bits.read_bit(spec, "AV1 seq_tier")?) + } else { + 0 + }; + if let Some(info) = decoder_model_info + && bits.read_bit(spec, "AV1 decoder_model_present_for_this_op")? + { + bits.skip_bits( + usize::from(info.buffer_delay_length_minus_one) + 1, + spec, + "AV1 decoder_buffer_delay", + )?; + bits.skip_bits( + usize::from(info.buffer_delay_length_minus_one) + 1, + spec, + "AV1 encoder_buffer_delay", + )?; + bits.skip_bits(1, spec, "AV1 low_delay_mode_flag")?; + } + let op_delay = if initial_display_delay_present_flag + && bits.read_bit(spec, "AV1 initial_display_delay_present_for_this_op")? + { + Some(bits.read_bits_u8(4, spec, "AV1 initial_display_delay_minus_one")?) + } else { + None + }; + if index == 0 { + seq_level = level; + seq_tier = tier; + initial_delay = op_delay; + } + } + seq_level_idx_0 = seq_level; + seq_tier_0 = seq_tier; + initial_presentation_delay_minus_one = initial_delay; + decoder_model_info + }; + + let frame_width_bits_minus_1 = bits.read_bits_u8(4, spec, "AV1 frame_width_bits_minus_1")?; + let frame_height_bits_minus_1 = bits.read_bits_u8(4, spec, "AV1 frame_height_bits_minus_1")?; + let width = u16::try_from( + bits.read_bits_u32( + usize::from(frame_width_bits_minus_1) + 1, + spec, + "AV1 max_frame_width_minus_1", + )? + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("AV1 frame width"))?, + ) + .map_err(|_| MuxError::LayoutOverflow("AV1 frame width"))?; + let height = u16::try_from( + bits.read_bits_u32( + usize::from(frame_height_bits_minus_1) + 1, + spec, + "AV1 max_frame_height_minus_one", + )? + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("AV1 frame height"))?, + ) + .map_err(|_| MuxError::LayoutOverflow("AV1 frame height"))?; + if width == 0 || height == 0 { + return Err(unsupported( + spec, + "AV1 sequence header signaled a zero-sized coded frame", + )); + } + if !reduced_still_picture_header && bits.read_bit(spec, "AV1 frame_id_numbers_present_flag")? { + bits.skip_bits(4, spec, "AV1 delta_frame_id_length_minus_2")?; + bits.skip_bits(3, spec, "AV1 additional_frame_id_length_minus_1")?; + } + + bits.skip_bits(1, spec, "AV1 use_128x128_superblock")?; + bits.skip_bits(1, spec, "AV1 enable_filter_intra")?; + bits.skip_bits(1, spec, "AV1 enable_intra_edge_filter")?; + if !reduced_still_picture_header { + bits.skip_bits(1, spec, "AV1 enable_interintra_compound")?; + bits.skip_bits(1, spec, "AV1 enable_masked_compound")?; + bits.skip_bits(1, spec, "AV1 enable_warped_motion")?; + let enable_dual_filter = bits.read_bit(spec, "AV1 enable_dual_filter")?; + let enable_order_hint = bits.read_bit(spec, "AV1 enable_order_hint")?; + if enable_order_hint { + bits.skip_bits(1, spec, "AV1 enable_jnt_comp")?; + bits.skip_bits(1, spec, "AV1 enable_ref_frame_mvs")?; + } + let seq_choose_screen_content_tools = + bits.read_bit(spec, "AV1 seq_choose_screen_content_tools")?; + let seq_force_screen_content_tools = if seq_choose_screen_content_tools { + 2_u8 + } else { + u8::from(bits.read_bit(spec, "AV1 seq_force_screen_content_tools")?) + }; + if seq_force_screen_content_tools > 0 { + let seq_choose_integer_mv = bits.read_bit(spec, "AV1 seq_choose_integer_mv")?; + if !seq_choose_integer_mv { + bits.skip_bits(1, spec, "AV1 seq_force_integer_mv")?; + } + } + if enable_order_hint || enable_dual_filter { + let _ = decoder_model_info; + } + if enable_order_hint { + bits.skip_bits(3, spec, "AV1 order_hint_bits_minus_1")?; + } + } + bits.skip_bits(1, spec, "AV1 enable_superres")?; + bits.skip_bits(1, spec, "AV1 enable_cdef")?; + bits.skip_bits(1, spec, "AV1 enable_restoration")?; + + let color_info = parse_av1_color_config(&mut bits, seq_profile, spec)?; + bits.skip_bits(1, spec, "AV1 film_grain_params_present")?; + + Ok(ParsedAv1SequenceHeader { + seq_profile, + seq_level_idx_0, + seq_tier_0, + reduced_still_picture_header, + width, + height, + high_bitdepth: color_info.high_bitdepth, + twelve_bit: color_info.twelve_bit, + monochrome: color_info.monochrome, + chroma_subsampling_x: color_info.chroma_subsampling_x, + chroma_subsampling_y: color_info.chroma_subsampling_y, + chroma_sample_position: color_info.chroma_sample_position, + initial_presentation_delay_minus_one, + colour_primaries: color_info.colour_primaries, + transfer_characteristics: color_info.transfer_characteristics, + matrix_coefficients: color_info.matrix_coefficients, + full_range_flag: color_info.full_range_flag, + }) +} + +#[derive(Clone, Copy)] +struct Av1DecoderModelInfo { + buffer_delay_length_minus_one: u8, +} + +fn skip_timing_info_and_decoder_model( + bits: &mut BitCursor<'_>, + spec: &str, +) -> Result, MuxError> { + skip_timing_info_only(bits, spec)?; + let buffer_delay_length_minus_one = + bits.read_bits_u8(5, spec, "AV1 buffer_delay_length_minus_one")?; + bits.skip_bits(32, spec, "AV1 num_units_in_decoding_tick")?; + bits.skip_bits(5, spec, "AV1 buffer_removal_time_length_minus_1")?; + bits.skip_bits(5, spec, "AV1 frame_presentation_time_length_minus_1")?; + Ok(Some(Av1DecoderModelInfo { + buffer_delay_length_minus_one, + })) +} + +fn skip_timing_info_only(bits: &mut BitCursor<'_>, spec: &str) -> Result<(), MuxError> { + bits.skip_bits(32, spec, "AV1 num_units_in_display_tick")?; + bits.skip_bits(32, spec, "AV1 time_scale")?; + if bits.read_bit(spec, "AV1 equal_picture_interval")? { + let _ = read_uvlc(bits, spec, "AV1 num_ticks_per_picture_minus_1")?; + } + Ok(()) +} + +#[derive(Clone, Copy)] +struct ParsedAv1ColorInfo { + high_bitdepth: bool, + twelve_bit: bool, + monochrome: bool, + chroma_subsampling_x: u8, + chroma_subsampling_y: u8, + chroma_sample_position: u8, + colour_primaries: u16, + transfer_characteristics: u16, + matrix_coefficients: u16, + full_range_flag: bool, +} + +fn parse_av1_color_config( + bits: &mut BitCursor<'_>, + seq_profile: u8, + spec: &str, +) -> Result { + let high_bitdepth = bits.read_bit(spec, "AV1 high_bitdepth")?; + let twelve_bit = seq_profile == 2 && high_bitdepth && bits.read_bit(spec, "AV1 twelve_bit")?; + let monochrome = if seq_profile == 1 { + false + } else { + bits.read_bit(spec, "AV1 monochrome")? + }; + let mut colour_primaries = 2_u16; + let mut transfer_characteristics = 2_u16; + let mut matrix_coefficients = 2_u16; + if bits.read_bit(spec, "AV1 color_description_present_flag")? { + colour_primaries = u16::from(bits.read_bits_u8(8, spec, "AV1 colour_primaries")?); + transfer_characteristics = + u16::from(bits.read_bits_u8(8, spec, "AV1 transfer_characteristics")?); + matrix_coefficients = u16::from(bits.read_bits_u8(8, spec, "AV1 matrix_coefficients")?); + } + + let full_range_flag; + let (chroma_subsampling_x, chroma_subsampling_y, chroma_sample_position) = if monochrome { + full_range_flag = bits.read_bit(spec, "AV1 color_range")?; + (1, 1, 0) + } else if colour_primaries == 1 && transfer_characteristics == 13 && matrix_coefficients == 0 { + full_range_flag = true; + (0, 0, 0) + } else { + full_range_flag = bits.read_bit(spec, "AV1 color_range")?; + let chroma = if seq_profile == 0 { + (1, 1) + } else if seq_profile == 1 { + (0, 0) + } else if twelve_bit { + let chroma_x = u8::from(bits.read_bit(spec, "AV1 chroma_subsampling_x")?); + let chroma_y = if chroma_x == 1 { + u8::from(bits.read_bit(spec, "AV1 chroma_subsampling_y")?) + } else { + 0 + }; + (chroma_x, chroma_y) + } else { + (1, 0) + }; + let chroma_sample_position = if chroma.0 == 1 && chroma.1 == 1 { + bits.read_bits_u8(2, spec, "AV1 chroma_sample_position")? + } else { + 0 + }; + bits.skip_bits(1, spec, "AV1 separate_uv_delta_q")?; + return Ok(ParsedAv1ColorInfo { + high_bitdepth, + twelve_bit, + monochrome, + chroma_subsampling_x: chroma.0, + chroma_subsampling_y: chroma.1, + chroma_sample_position, + colour_primaries, + transfer_characteristics, + matrix_coefficients, + full_range_flag, + }); + }; + bits.skip_bits(1, spec, "AV1 separate_uv_delta_q")?; + Ok(ParsedAv1ColorInfo { + high_bitdepth, + twelve_bit, + monochrome, + chroma_subsampling_x, + chroma_subsampling_y, + chroma_sample_position, + colour_primaries, + transfer_characteristics, + matrix_coefficients, + full_range_flag, + }) +} + +fn read_leb128_from_slice( + bytes: &[u8], + spec: &str, + field_name: &str, + offset: u64, +) -> Result<(u64, usize), MuxError> { + let mut value = 0_u64; + let mut shift = 0_u32; + for (index, byte) in bytes.iter().copied().enumerate() { + value |= u64::from(byte & 0x7F) << shift; + if byte & 0x80 == 0 { + return Ok((value, index + 1)); + } + shift += 7; + if shift >= 63 { + break; + } + } + Err(unsupported( + spec, + &format!( + "{field_name} at byte offset {offset} used an unterminated or unsupported leb128 value" + ), + )) +} + +fn read_uvlc(bits: &mut BitCursor<'_>, spec: &str, label: &str) -> Result { + let mut leading_zeroes = 0usize; + while !bits.read_bit(spec, label)? { + leading_zeroes += 1; + } + if leading_zeroes == 0 { + return Ok(0); + } + let remainder = bits.read_bits_u32(leading_zeroes, spec, label)?; + Ok((1_u32 << leading_zeroes) - 1 + remainder) +} + +struct BitCursor<'a> { + data: &'a [u8], + bit_offset: usize, +} + +impl<'a> BitCursor<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + bit_offset: 0, + } + } + + fn read_bit(&mut self, spec: &str, label: &str) -> Result { + Ok(self.read_bits_u32(1, spec, label)? != 0) + } + + fn read_bits_u8(&mut self, width: usize, spec: &str, label: &str) -> Result { + u8::try_from(self.read_bits_u32(width, spec, label)?) + .map_err(|_| MuxError::LayoutOverflow("AV1 bit width conversion")) + } + + fn read_bits_u32(&mut self, width: usize, spec: &str, label: &str) -> Result { + let end = self + .bit_offset + .checked_add(width) + .ok_or(MuxError::LayoutOverflow("AV1 bit reader position"))?; + if end > self.data.len() * 8 { + return Err(unsupported( + spec, + &format!("{label} is truncated in the AV1 sequence header"), + )); + } + + let mut value = 0_u32; + for _ in 0..width { + let byte = self.data[self.bit_offset / 8]; + let shift = 7 - (self.bit_offset % 8); + value = (value << 1) | u32::from((byte >> shift) & 1); + self.bit_offset += 1; + } + Ok(value) + } + + fn skip_bits(&mut self, width: usize, spec: &str, label: &str) -> Result<(), MuxError> { + let _ = self.read_bits_u32(width, spec, label)?; + Ok(()) + } +} + +fn unsupported(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} diff --git a/src/mux/demux/avi.rs b/src/mux/demux/avi.rs new file mode 100644 index 0000000..4230f7c --- /dev/null +++ b/src/mux/demux/avi.rs @@ -0,0 +1,3736 @@ +use std::fs::File; +use std::io::Cursor; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use super::super::MuxError; +use super::super::MuxTrackKind; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{ + CandidateSample, CompositeTrackCandidate, SegmentedMuxSourceSegment, SegmentedMuxSourceSpec, + StagedSample, TrackCandidate, build_btrt_from_sample_sizes, + build_generic_audio_sample_entry_box, direct_ingest_handler_name, direct_ingest_mux_policy, + read_exact_at_sync, with_force_empty_sync_sample_table, +}; +#[cfg(feature = "async")] +use super::aac::scan_adts_segmented_async; +use super::aac::{build_aac_lc_sample_entry_box, scan_adts_segmented_sync}; +#[cfg(feature = "async")] +use super::ac3::scan_ac3_segmented_async; +use super::ac3::scan_ac3_segmented_sync; +use super::h263::{build_avi_h263_sample_entry_box, parse_h263_picture_bytes}; +#[cfg(feature = "async")] +use super::h264::stage_annex_b_h264_segmented_async; +use super::h264::{ + build_h264_sample_entry_from_avc_config_with_options, retune_carried_h264_sample_entry_box, + stage_annex_b_h264_segmented_sync, +}; +use super::jpeg::{build_avi_jpeg_sample_entry_box, parse_jpeg_bytes}; +#[cfg(feature = "async")] +use super::mp3::scan_mp3_segmented_async; +use super::mp3::scan_mp3_segmented_sync; +#[cfg(feature = "async")] +use super::mp4v::scan_mp4v_segmented_async; +use super::mp4v::{build_direct_mp4v_sample_entry_box, scan_mp4v_segmented_sync}; +use super::pcm::build_pcm_sample_entry_box; +use super::png::{build_avi_png_sample_entry_box, parse_png_bytes}; +use crate::FourCc; +use crate::boxes::iso14496_12::{AVCDecoderConfiguration, SampleEntry, VisualSampleEntry}; +use crate::codec::unmarshal; + +const RIFF: &[u8; 4] = b"RIFF"; +const LIST: FourCc = FourCc::from_bytes(*b"LIST"); +const AVI_FORM: FourCc = FourCc::from_bytes(*b"AVI "); +const HDRL: FourCc = FourCc::from_bytes(*b"hdrl"); +const STRL: FourCc = FourCc::from_bytes(*b"strl"); +const STRH: FourCc = FourCc::from_bytes(*b"strh"); +const STRF: FourCc = FourCc::from_bytes(*b"strf"); +const MOVI: FourCc = FourCc::from_bytes(*b"movi"); +const RECL: FourCc = FourCc::from_bytes(*b"rec "); +const AUDS: FourCc = FourCc::from_bytes(*b"auds"); +const VIDS: FourCc = FourCc::from_bytes(*b"vids"); +const WAVE_FORMAT_PCM: u16 = 0x0001; +const WAVE_FORMAT_ADPCM: u16 = 0x0002; +const WAVE_FORMAT_IEEE_FLOAT: u16 = 0x0003; +const IBM_FORMAT_CVSD: u16 = 0x0005; +const WAVE_FORMAT_ALAW: u16 = 0x0006; +const WAVE_FORMAT_MULAW: u16 = 0x0007; +const WAVE_FORMAT_OKI_ADPCM: u16 = 0x0010; +const WAVE_FORMAT_DVI_ADPCM: u16 = 0x0011; +const WAVE_FORMAT_DIGISTD: u16 = 0x0015; +const WAVE_FORMAT_YAMAHA_ADPCM: u16 = 0x0020; +const WAVE_FORMAT_DSP_TRUESPEECH: u16 = 0x0022; +const WAVE_FORMAT_GSM610: u16 = 0x0031; +const WAVE_FORMAT_MP3: u16 = 0x0055; +const WAVE_FORMAT_AAC_ADTS: u16 = 0x706D; +const IBM_FORMAT_MULAW: u16 = 0x0101; +const IBM_FORMAT_ALAW: u16 = 0x0102; +const IBM_FORMAT_ADPCM: u16 = 0x0103; +const WAVE_FORMAT_AAC: u16 = 0x00FF; +const WAVE_FORMAT_AC3: u16 = 0x2000; +const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE; +const SAMPLE_ENTRY_IPCM: FourCc = FourCc::from_bytes(*b"ipcm"); +const SAMPLE_ENTRY_FPCM: FourCc = FourCc::from_bytes(*b"fpcm"); +const SAMPLE_ENTRY_ALAW: FourCc = FourCc::from_bytes(*b"alaw"); +const SAMPLE_ENTRY_MLAW: FourCc = FourCc::from_bytes(*b"MLAW"); +const SAMPLE_ENTRY_MS_ADPCM: FourCc = FourCc::from_bytes([0x6D, 0x73, 0x00, 0x02]); +const SAMPLE_ENTRY_IMA_ADPCM: FourCc = FourCc::from_bytes([0x6D, 0x73, 0x00, 0x11]); +const SAMPLE_ENTRY_IBM_CVSD: FourCc = FourCc::from_bytes(*b"CSVD"); +const SAMPLE_ENTRY_OKI_ADPCM: FourCc = FourCc::from_bytes(*b"OPCM"); +const SAMPLE_ENTRY_DIGISTD: FourCc = FourCc::from_bytes(*b"DSTD"); +const SAMPLE_ENTRY_YAMAHA_ADPCM: FourCc = FourCc::from_bytes(*b"YPCM"); +const SAMPLE_ENTRY_DSP_TRUESPEECH: FourCc = FourCc::from_bytes(*b"TSPE"); +const SAMPLE_ENTRY_GSM610: FourCc = FourCc::from_bytes(*b"G610"); +const SAMPLE_ENTRY_IBM_ADPCM: FourCc = FourCc::from_bytes(*b"IPCM"); +const SAMPLE_ENTRY_DIV3: FourCc = FourCc::from_bytes(*b"DIV3"); +const SAMPLE_ENTRY_DIV4: FourCc = FourCc::from_bytes(*b"DIV4"); +const SAMPLE_ENTRY_UNCV: FourCc = FourCc::from_bytes(*b"uncv"); +const AVC1: FourCc = FourCc::from_bytes(*b"AVC1"); +const CMPD: FourCc = FourCc::from_bytes(*b"cmpd"); +const UNCC: FourCc = FourCc::from_bytes(*b"uncC"); +const AVI_RAW_VIDEO_COMPRESSOR_NAME: &[u8] = b"RawVideo"; +const AVI_MS_MPEG4_V3_COMPRESSOR_NAME: &[u8] = b"MS-MPEG4 V3"; +const AVI_GENERIC_UNSUPPORTED_COMPRESSOR_NAME: &[u8] = b"Codec Not Supported"; +const SUPPORTED_AVI_AUDIO_TAGS: &str = "PCM, extensible PCM, IEEE float PCM, extensible IEEE float PCM, A-law, extensible A-law, IBM A-law, mu-law, extensible mu-law, IBM mu-law, Microsoft ADPCM, IMA ADPCM, IBM CVSD, OKI ADPCM, DIGISTD, Yamaha ADPCM, DSP TrueSpeech, GSM 610, IBM ADPCM, AAC ADTS, AAC, MP3, and AC-3"; +const KSDATAFORMAT_SUBTYPE_PCM: [u8; 16] = [ + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71, +]; +const KSDATAFORMAT_SUBTYPE_IEEE_FLOAT: [u8; 16] = [ + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71, +]; +const KSDATAFORMAT_SUBTYPE_ALAW: [u8; 16] = [ + 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71, +]; +const KSDATAFORMAT_SUBTYPE_MULAW: [u8; 16] = [ + 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71, +]; + +pub(in crate::mux) struct ScannedAviSource { + pub(in crate::mux) tracks: Vec, + pub(in crate::mux) composite_tracks: Vec, +} + +pub(in crate::mux) fn scan_avi_source_sync( + path: &Path, + spec: &str, + source_index: usize, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + parse_avi_source_sync(path, &mut file, file_size, spec, source_index) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_avi_source_async( + path: &Path, + spec: &str, + source_index: usize, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + parse_avi_source_async(path, &mut file, file_size, spec, source_index).await +} + +#[derive(Clone)] +struct AviTrackDescriptor { + stream_index: u32, + stream_type: FourCc, + timing_scale: u32, + timing_rate: u32, + audio_format: Option, + video_format: Option, +} + +#[derive(Clone, Copy)] +struct AviAudioFormat { + format_tag: u16, + channel_count: u16, + sample_rate: u32, + block_align: u16, + bits_per_sample: u16, +} + +#[derive(Clone)] +struct AviVideoFormat { + width: u16, + height: u16, + codec: AviVideoCodec, + decoder_specific_info: Vec, +} + +#[derive(Clone, Copy)] +enum AviVideoCodec { + Mp4v, + H264AnnexB, + H264Avc1, + H263, + Jpeg, + Png, + MsMpeg4V3(FourCc), + RawBgr, + GenericPassthrough(FourCc), +} + +#[derive(Clone, Copy)] +struct AviChunkSpan { + data_offset: u64, + data_size: u32, +} + +#[derive(Clone, Copy)] +enum AviAdpcmKind { + Microsoft, + ImaDvi, +} + +fn parse_avi_source_sync( + path: &Path, + file: &mut File, + file_size: u64, + spec: &str, + source_index: usize, +) -> Result { + validate_avi_header_sync(file, file_size, spec)?; + + let mut track_descriptors = Vec::new(); + let mut movi_range = None::<(u64, u64)>; + let mut offset = 12_u64; + while offset < file_size { + let (chunk_type, chunk_size, chunk_payload_offset, chunk_end) = + read_riff_chunk_header_sync(file, file_size, offset, spec)?; + if chunk_type == LIST { + if chunk_size < 4 { + return Err(invalid_avi( + spec, + "AVI `LIST` chunk was truncated before its list type", + )); + } + let list_type = read_fourcc_sync( + file, + chunk_payload_offset, + spec, + "AVI list type is truncated", + )?; + if list_type == HDRL { + parse_hdrl_list_sync( + file, + chunk_payload_offset + 4, + chunk_end, + spec, + &mut track_descriptors, + )?; + } else if list_type == MOVI { + movi_range = Some((chunk_payload_offset + 4, chunk_end)); + } + } + offset = next_riff_offset(chunk_end); + } + + let (movi_start, movi_end) = movi_range + .ok_or_else(|| invalid_avi(spec, "AVI input did not contain one `LIST` `movi` payload"))?; + if track_descriptors.is_empty() { + return Err(invalid_avi( + spec, + "AVI input did not contain any stream descriptors under `LIST` `hdrl`", + )); + } + + let mut track_chunks = vec![Vec::new(); track_descriptors.len()]; + parse_movi_chunks_sync( + file, + movi_start, + movi_end, + spec, + track_descriptors.len(), + &mut track_chunks, + )?; + finalize_avi_tracks_sync( + file, + path, + spec, + source_index, + track_descriptors, + track_chunks, + ) +} + +#[cfg(feature = "async")] +async fn parse_avi_source_async( + path: &Path, + file: &mut TokioFile, + file_size: u64, + spec: &str, + source_index: usize, +) -> Result { + validate_avi_header_async(file, file_size, spec).await?; + + let mut track_descriptors = Vec::new(); + let mut movi_range = None::<(u64, u64)>; + let mut offset = 12_u64; + while offset < file_size { + let (chunk_type, chunk_size, chunk_payload_offset, chunk_end) = + read_riff_chunk_header_async(file, file_size, offset, spec).await?; + if chunk_type == LIST { + if chunk_size < 4 { + return Err(invalid_avi( + spec, + "AVI `LIST` chunk was truncated before its list type", + )); + } + let list_type = read_fourcc_async( + file, + chunk_payload_offset, + spec, + "AVI list type is truncated", + ) + .await?; + if list_type == HDRL { + parse_hdrl_list_async( + file, + chunk_payload_offset + 4, + chunk_end, + spec, + &mut track_descriptors, + ) + .await?; + } else if list_type == MOVI { + movi_range = Some((chunk_payload_offset + 4, chunk_end)); + } + } + offset = next_riff_offset(chunk_end); + } + + let (movi_start, movi_end) = movi_range + .ok_or_else(|| invalid_avi(spec, "AVI input did not contain one `LIST` `movi` payload"))?; + if track_descriptors.is_empty() { + return Err(invalid_avi( + spec, + "AVI input did not contain any stream descriptors under `LIST` `hdrl`", + )); + } + + let mut track_chunks = vec![Vec::new(); track_descriptors.len()]; + parse_movi_chunks_async( + file, + movi_start, + movi_end, + spec, + track_descriptors.len(), + &mut track_chunks, + ) + .await?; + finalize_avi_tracks_async( + file, + path, + spec, + source_index, + track_descriptors, + track_chunks, + ) + .await +} + +fn finalize_avi_tracks_sync( + file: &mut File, + path: &Path, + spec: &str, + source_index: usize, + track_descriptors: Vec, + track_chunks: Vec>, +) -> Result { + let mut tracks = Vec::new(); + let mut composite_tracks = Vec::new(); + for (descriptor, chunks) in track_descriptors.into_iter().zip(track_chunks) { + if chunks.is_empty() { + continue; + } + match descriptor.stream_type { + AUDS => { + let audio_format = descriptor.audio_format.ok_or_else(|| { + invalid_avi( + spec, + &format!( + "AVI audio stream {} did not carry a parseable WAVEFORMAT payload", + descriptor.stream_index + ), + ) + })?; + match audio_format.format_tag { + WAVE_FORMAT_PCM => tracks.push(finalize_avi_pcm_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + false, + )?), + WAVE_FORMAT_IEEE_FLOAT => tracks.push(finalize_avi_pcm_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + true, + )?), + WAVE_FORMAT_ALAW | IBM_FORMAT_ALAW => { + tracks.push(finalize_avi_companded_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_ALAW, + "alaw", + )?) + } + WAVE_FORMAT_MULAW | IBM_FORMAT_MULAW => { + tracks.push(finalize_avi_companded_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_MLAW, + "ulaw", + )?) + } + WAVE_FORMAT_ADPCM => tracks.push(finalize_avi_adpcm_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + AviAdpcmKind::Microsoft, + )?), + WAVE_FORMAT_DVI_ADPCM => tracks.push(finalize_avi_adpcm_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + AviAdpcmKind::ImaDvi, + )?), + IBM_FORMAT_CVSD => tracks.push(finalize_avi_generic_coded_audio_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_IBM_CVSD, + "ibm-cvsd", + )?), + WAVE_FORMAT_OKI_ADPCM => tracks.push(finalize_avi_generic_coded_audio_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_OKI_ADPCM, + "oki-adpcm", + )?), + WAVE_FORMAT_DIGISTD => tracks.push(finalize_avi_generic_coded_audio_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_DIGISTD, + "digistd", + )?), + WAVE_FORMAT_YAMAHA_ADPCM => { + tracks.push(finalize_avi_generic_coded_audio_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_YAMAHA_ADPCM, + "yamaha-adpcm", + )?) + } + WAVE_FORMAT_DSP_TRUESPEECH => { + tracks.push(finalize_avi_generic_coded_audio_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_DSP_TRUESPEECH, + "truespeech", + )?) + } + WAVE_FORMAT_GSM610 => tracks.push(finalize_avi_generic_coded_audio_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_GSM610, + "gsm610", + )?), + IBM_FORMAT_ADPCM => tracks.push(finalize_avi_generic_coded_audio_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_IBM_ADPCM, + "ibm-adpcm", + )?), + WAVE_FORMAT_AAC_ADTS => composite_tracks.push(finalize_avi_adts_track_sync( + file, path, spec, descriptor, chunks, + )?), + WAVE_FORMAT_AAC => tracks.push(finalize_avi_raw_aac_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + )?), + WAVE_FORMAT_MP3 => composite_tracks.push(finalize_avi_mp3_track_sync( + file, path, spec, descriptor, chunks, + )?), + WAVE_FORMAT_AC3 => composite_tracks.push(finalize_avi_ac3_track_sync( + file, path, spec, descriptor, chunks, + )?), + _ => { + return Err(invalid_avi( + spec, + &unsupported_avi_audio_format_tag_message( + descriptor.stream_index, + audio_format, + ), + )); + } + } + } + VIDS => { + let video_format = descriptor.video_format.clone().ok_or_else(|| { + invalid_avi( + spec, + &format!( + "AVI video stream {} did not carry a parseable BITMAPINFO payload", + descriptor.stream_index + ), + ) + })?; + match video_format.codec { + AviVideoCodec::Mp4v => tracks.push(finalize_avi_mp4v_track_sync( + file, + path, + spec, + source_index, + descriptor, + video_format, + chunks, + )?), + AviVideoCodec::H264AnnexB => { + composite_tracks.push(finalize_avi_h264_track_sync( + file, + path, + spec, + descriptor, + video_format, + chunks, + )?) + } + AviVideoCodec::H264Avc1 => tracks.push(finalize_avi_h264_avc1_track_sync( + file, + spec, + source_index, + descriptor, + video_format, + chunks, + )?), + AviVideoCodec::H263 => tracks.push(finalize_avi_h263_track_sync( + file, + spec, + source_index, + descriptor, + video_format, + chunks, + )?), + AviVideoCodec::Jpeg => tracks.push(finalize_avi_jpeg_track_sync( + file, + spec, + source_index, + descriptor, + video_format, + chunks, + )?), + AviVideoCodec::Png => tracks.push(finalize_avi_png_track_sync( + file, + spec, + source_index, + descriptor, + video_format, + chunks, + )?), + AviVideoCodec::MsMpeg4V3(sample_entry_type) => { + tracks.push(finalize_avi_generic_visual_track( + source_index, + descriptor, + video_format, + chunks, + sample_entry_type, + AVI_MS_MPEG4_V3_COMPRESSOR_NAME.to_vec(), + )?) + } + AviVideoCodec::RawBgr => tracks.push(finalize_avi_uncv_bgr_track( + source_index, + descriptor, + video_format, + chunks, + )?), + AviVideoCodec::GenericPassthrough(sample_entry_type) => { + tracks.push(finalize_avi_generic_visual_track( + source_index, + descriptor, + video_format, + chunks, + sample_entry_type, + AVI_GENERIC_UNSUPPORTED_COMPRESSOR_NAME.to_vec(), + )?) + } + } + } + other => { + return Err(invalid_avi( + spec, + &format!( + "AVI stream {} uses unsupported stream type `{other}` on the native direct-ingest path", + descriptor.stream_index + ), + )); + } + }; + } + if tracks.is_empty() && composite_tracks.is_empty() { + return Err(invalid_avi( + spec, + "AVI input did not contain any supported stream chunks under `LIST` `movi`", + )); + } + Ok(ScannedAviSource { + tracks, + composite_tracks, + }) +} + +#[cfg(feature = "async")] +async fn finalize_avi_tracks_async( + file: &mut TokioFile, + path: &Path, + spec: &str, + source_index: usize, + track_descriptors: Vec, + track_chunks: Vec>, +) -> Result { + let mut tracks = Vec::new(); + let mut composite_tracks = Vec::new(); + for (descriptor, chunks) in track_descriptors.into_iter().zip(track_chunks) { + if chunks.is_empty() { + continue; + } + match descriptor.stream_type { + AUDS => { + let audio_format = descriptor.audio_format.ok_or_else(|| { + invalid_avi( + spec, + &format!( + "AVI audio stream {} did not carry a parseable WAVEFORMAT payload", + descriptor.stream_index + ), + ) + })?; + match audio_format.format_tag { + WAVE_FORMAT_PCM => tracks.push(finalize_avi_pcm_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + false, + )?), + WAVE_FORMAT_IEEE_FLOAT => tracks.push(finalize_avi_pcm_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + true, + )?), + WAVE_FORMAT_ALAW | IBM_FORMAT_ALAW => { + tracks.push(finalize_avi_companded_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_ALAW, + "alaw", + )?) + } + WAVE_FORMAT_MULAW | IBM_FORMAT_MULAW => { + tracks.push(finalize_avi_companded_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_MLAW, + "ulaw", + )?) + } + WAVE_FORMAT_ADPCM => tracks.push(finalize_avi_adpcm_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + AviAdpcmKind::Microsoft, + )?), + WAVE_FORMAT_DVI_ADPCM => tracks.push(finalize_avi_adpcm_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + AviAdpcmKind::ImaDvi, + )?), + IBM_FORMAT_CVSD => tracks.push(finalize_avi_generic_coded_audio_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_IBM_CVSD, + "ibm-cvsd", + )?), + WAVE_FORMAT_OKI_ADPCM => tracks.push(finalize_avi_generic_coded_audio_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_OKI_ADPCM, + "oki-adpcm", + )?), + WAVE_FORMAT_DIGISTD => tracks.push(finalize_avi_generic_coded_audio_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_DIGISTD, + "digistd", + )?), + WAVE_FORMAT_YAMAHA_ADPCM => { + tracks.push(finalize_avi_generic_coded_audio_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_YAMAHA_ADPCM, + "yamaha-adpcm", + )?) + } + WAVE_FORMAT_DSP_TRUESPEECH => { + tracks.push(finalize_avi_generic_coded_audio_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_DSP_TRUESPEECH, + "truespeech", + )?) + } + WAVE_FORMAT_GSM610 => tracks.push(finalize_avi_generic_coded_audio_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_GSM610, + "gsm610", + )?), + IBM_FORMAT_ADPCM => tracks.push(finalize_avi_generic_coded_audio_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + SAMPLE_ENTRY_IBM_ADPCM, + "ibm-adpcm", + )?), + WAVE_FORMAT_AAC_ADTS => composite_tracks.push( + finalize_avi_adts_track_async(file, path, spec, descriptor, chunks).await?, + ), + WAVE_FORMAT_AAC => tracks.push(finalize_avi_raw_aac_track( + spec, + source_index, + descriptor.stream_index, + audio_format, + chunks, + )?), + WAVE_FORMAT_MP3 => composite_tracks.push( + finalize_avi_mp3_track_async(file, path, spec, descriptor, chunks).await?, + ), + WAVE_FORMAT_AC3 => composite_tracks.push( + finalize_avi_ac3_track_async(file, path, spec, descriptor, chunks).await?, + ), + _ => { + return Err(invalid_avi( + spec, + &unsupported_avi_audio_format_tag_message( + descriptor.stream_index, + audio_format, + ), + )); + } + } + } + VIDS => { + let video_format = descriptor.video_format.clone().ok_or_else(|| { + invalid_avi( + spec, + &format!( + "AVI video stream {} did not carry a parseable BITMAPINFO payload", + descriptor.stream_index + ), + ) + })?; + match video_format.codec { + AviVideoCodec::Mp4v => tracks.push( + finalize_avi_mp4v_track_async( + file, + path, + spec, + source_index, + descriptor, + video_format, + chunks, + ) + .await?, + ), + AviVideoCodec::H264AnnexB => composite_tracks.push( + finalize_avi_h264_track_async( + file, + path, + spec, + descriptor, + video_format, + chunks, + ) + .await?, + ), + AviVideoCodec::H264Avc1 => tracks.push( + finalize_avi_h264_avc1_track_async( + file, + spec, + source_index, + descriptor, + video_format, + chunks, + ) + .await?, + ), + AviVideoCodec::H263 => tracks.push( + finalize_avi_h263_track_async( + file, + spec, + source_index, + descriptor, + video_format, + chunks, + ) + .await?, + ), + AviVideoCodec::Jpeg => tracks.push( + finalize_avi_jpeg_track_async( + file, + spec, + source_index, + descriptor, + video_format, + chunks, + ) + .await?, + ), + AviVideoCodec::Png => tracks.push( + finalize_avi_png_track_async( + file, + spec, + source_index, + descriptor, + video_format, + chunks, + ) + .await?, + ), + AviVideoCodec::MsMpeg4V3(sample_entry_type) => { + tracks.push(finalize_avi_generic_visual_track( + source_index, + descriptor, + video_format, + chunks, + sample_entry_type, + AVI_MS_MPEG4_V3_COMPRESSOR_NAME.to_vec(), + )?) + } + AviVideoCodec::RawBgr => tracks.push(finalize_avi_uncv_bgr_track( + source_index, + descriptor, + video_format, + chunks, + )?), + AviVideoCodec::GenericPassthrough(sample_entry_type) => { + tracks.push(finalize_avi_generic_visual_track( + source_index, + descriptor, + video_format, + chunks, + sample_entry_type, + AVI_GENERIC_UNSUPPORTED_COMPRESSOR_NAME.to_vec(), + )?) + } + } + } + other => { + return Err(invalid_avi( + spec, + &format!( + "AVI stream {} uses unsupported stream type `{other}` on the native direct-ingest path", + descriptor.stream_index + ), + )); + } + }; + } + if tracks.is_empty() && composite_tracks.is_empty() { + return Err(invalid_avi( + spec, + "AVI input did not contain any supported stream chunks under `LIST` `movi`", + )); + } + Ok(ScannedAviSource { + tracks, + composite_tracks, + }) +} + +fn finalize_avi_pcm_track( + spec: &str, + source_index: usize, + stream_index: u32, + audio_format: AviAudioFormat, + chunks: Vec, + floating_point: bool, +) -> Result { + if audio_format.block_align == 0 { + return Err(invalid_avi( + spec, + &format!("AVI PCM stream {stream_index} declared a zero block align"), + )); + } + let sample_entry_type = if floating_point { + SAMPLE_ENTRY_FPCM + } else { + SAMPLE_ENTRY_IPCM + }; + let sample_entry_box = build_pcm_sample_entry_box( + sample_entry_type, + audio_format.sample_rate, + audio_format.channel_count, + audio_format.bits_per_sample, + true, + )?; + let mut samples = Vec::with_capacity(chunks.len()); + for chunk in chunks { + if !chunk + .data_size + .is_multiple_of(u32::from(audio_format.block_align)) + { + return Err(invalid_avi( + spec, + &format!( + "AVI PCM stream {stream_index} chunk size {} is not a whole number of PCM frames", + chunk.data_size + ), + )); + } + let duration = chunk.data_size / u32::from(audio_format.block_align); + if duration == 0 { + return Err(invalid_avi( + spec, + &format!( + "AVI PCM stream {stream_index} chunk did not contain a complete audio frame" + ), + )); + } + samples.push(CandidateSample { + source_index, + data_offset: chunk.data_offset, + data_size: chunk.data_size, + duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + Ok(TrackCandidate { + track_id: stream_index + 1, + kind: MuxTrackKind::Audio, + timescale: audio_format.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("pcm"), + mux_policy: direct_ingest_mux_policy("pcm", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }) +} + +fn finalize_avi_companded_track( + spec: &str, + source_index: usize, + stream_index: u32, + audio_format: AviAudioFormat, + chunks: Vec, + sample_entry_type: FourCc, + codec_label: &str, +) -> Result { + if audio_format.block_align == 0 { + return Err(invalid_avi( + spec, + &format!("AVI audio stream {stream_index} declared a zero block align"), + )); + } + let mut samples = Vec::with_capacity(chunks.len()); + for chunk in chunks { + if !chunk + .data_size + .is_multiple_of(u32::from(audio_format.block_align)) + { + return Err(invalid_avi( + spec, + &format!( + "AVI audio stream {stream_index} chunk size {} is not a whole number of audio sample frames", + chunk.data_size + ), + )); + } + let duration = chunk.data_size / u32::from(audio_format.block_align); + if duration == 0 { + return Err(invalid_avi( + spec, + &format!( + "AVI audio stream {stream_index} chunk did not contain a complete audio frame" + ), + )); + } + samples.push(CandidateSample { + source_index, + data_offset: chunk.data_offset, + data_size: chunk.data_size, + duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + let sample_entry_sample_size = if sample_entry_type == SAMPLE_ENTRY_MLAW { + 16 + } else { + audio_format.bits_per_sample + }; + let mut child_boxes = Vec::new(); + if sample_entry_type == SAMPLE_ENTRY_MLAW { + child_boxes.push(super::super::mp4::encode_typed_box( + &build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + audio_format.sample_rate, + )?, + &[], + )?); + } + let sample_entry_box = build_generic_audio_sample_entry_box( + sample_entry_type, + audio_format.sample_rate, + audio_format.channel_count, + sample_entry_sample_size, + &child_boxes, + )?; + Ok(TrackCandidate { + track_id: stream_index + 1, + kind: MuxTrackKind::Audio, + timescale: audio_format.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name(codec_label), + mux_policy: direct_ingest_mux_policy(codec_label, MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }) +} + +fn finalize_avi_generic_coded_audio_track( + spec: &str, + source_index: usize, + stream_index: u32, + audio_format: AviAudioFormat, + chunks: Vec, + sample_entry_type: FourCc, + codec_label: &str, +) -> Result { + let channels = u32::from(audio_format.channel_count); + if channels == 0 { + return Err(invalid_avi( + spec, + &format!("AVI audio stream {stream_index} declared zero channels"), + )); + } + let bits_per_sample = u32::from(audio_format.bits_per_sample); + if bits_per_sample == 0 { + return Err(invalid_avi( + spec, + &format!("AVI audio stream {stream_index} declared zero bits per sample"), + )); + } + let coded_sample_width = bits_per_sample + .checked_mul(channels) + .ok_or_else(|| invalid_avi(spec, "AVI coded-audio sample width overflow"))?; + let sample_entry_box = build_generic_audio_sample_entry_box( + sample_entry_type, + audio_format.sample_rate, + audio_format.channel_count, + audio_format.bits_per_sample, + &[], + )?; + let mut samples = Vec::with_capacity(chunks.len()); + for chunk in chunks { + let coded_payload_bits = chunk + .data_size + .checked_mul(8) + .ok_or(MuxError::LayoutOverflow("AVI coded-audio payload bits"))?; + // Mirror the local reference AVI demux timing model for framed coded audio: + // duration comes from payload size, coded sample width, and channel count. + let duration = coded_payload_bits / coded_sample_width; + if duration == 0 { + return Err(invalid_avi( + spec, + &format!( + "AVI audio stream {stream_index} chunk did not contain one complete coded audio sample frame" + ), + )); + } + samples.push(CandidateSample { + source_index, + data_offset: chunk.data_offset, + data_size: chunk.data_size, + duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + Ok(TrackCandidate { + track_id: stream_index + 1, + kind: MuxTrackKind::Audio, + timescale: audio_format.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name(codec_label), + mux_policy: direct_ingest_mux_policy(codec_label, MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }) +} + +fn finalize_avi_adpcm_track( + spec: &str, + source_index: usize, + stream_index: u32, + audio_format: AviAudioFormat, + chunks: Vec, + adpcm_kind: AviAdpcmKind, +) -> Result { + if audio_format.block_align == 0 { + return Err(invalid_avi( + spec, + &format!("AVI audio stream {stream_index} declared a zero block align"), + )); + } + let (sample_entry_type, codec_label, samples_per_block) = + avi_adpcm_parameters(spec, stream_index, audio_format, adpcm_kind)?; + let sample_entry_box = build_generic_audio_sample_entry_box( + sample_entry_type, + audio_format.sample_rate, + audio_format.channel_count, + audio_format.bits_per_sample, + &[], + )?; + let mut samples = Vec::with_capacity(chunks.len()); + for chunk in chunks { + if !chunk + .data_size + .is_multiple_of(u32::from(audio_format.block_align)) + { + return Err(invalid_avi( + spec, + &format!( + "AVI audio stream {stream_index} chunk size {} is not a whole number of ADPCM blocks", + chunk.data_size + ), + )); + } + let block_count = chunk.data_size / u32::from(audio_format.block_align); + if block_count == 0 { + return Err(invalid_avi( + spec, + &format!( + "AVI audio stream {stream_index} chunk did not contain one complete ADPCM block" + ), + )); + } + let duration = block_count.checked_mul(samples_per_block).ok_or_else(|| { + invalid_avi( + spec, + &format!("AVI audio stream {stream_index} ADPCM duration overflowed"), + ) + })?; + samples.push(CandidateSample { + source_index, + data_offset: chunk.data_offset, + data_size: chunk.data_size, + duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + Ok(TrackCandidate { + track_id: stream_index + 1, + kind: MuxTrackKind::Audio, + timescale: audio_format.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name(codec_label), + mux_policy: direct_ingest_mux_policy(codec_label, MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }) +} + +fn avi_adpcm_parameters( + spec: &str, + stream_index: u32, + audio_format: AviAudioFormat, + adpcm_kind: AviAdpcmKind, +) -> Result<(FourCc, &'static str, u32), MuxError> { + let channels = u32::from(audio_format.channel_count); + if channels == 0 { + return Err(invalid_avi( + spec, + &format!("AVI audio stream {stream_index} declared zero channels"), + )); + } + let bits_per_sample = u32::from(audio_format.bits_per_sample); + if bits_per_sample == 0 { + return Err(invalid_avi( + spec, + &format!("AVI audio stream {stream_index} declared zero bits per sample"), + )); + } + let block_align = u32::from(audio_format.block_align); + let (sample_entry_type, codec_label, header_bytes_per_channel, leading_samples) = + match adpcm_kind { + AviAdpcmKind::Microsoft => (SAMPLE_ENTRY_MS_ADPCM, "adpcm", 7_u32, 2_u32), + AviAdpcmKind::ImaDvi => (SAMPLE_ENTRY_IMA_ADPCM, "ima-adpcm", 4_u32, 1_u32), + }; + let header_bytes = header_bytes_per_channel + .checked_mul(channels) + .ok_or_else(|| invalid_avi(spec, "AVI ADPCM header-size overflow"))?; + if block_align <= header_bytes { + return Err(invalid_avi( + spec, + &format!( + "AVI audio stream {stream_index} declared block_align {} too small for its ADPCM header", + audio_format.block_align + ), + )); + } + let coded_payload_bits = block_align + .checked_sub(header_bytes) + .and_then(|value| value.checked_mul(8)) + .ok_or_else(|| invalid_avi(spec, "AVI ADPCM coded-payload size overflow"))?; + let coded_sample_width = bits_per_sample + .checked_mul(channels) + .ok_or_else(|| invalid_avi(spec, "AVI ADPCM coded-sample width overflow"))?; + if coded_sample_width == 0 || coded_payload_bits % coded_sample_width != 0 { + return Err(invalid_avi( + spec, + &format!( + "AVI audio stream {stream_index} declared one unsupported ADPCM block geometry (block_align={}, channels={}, bits_per_sample={})", + audio_format.block_align, audio_format.channel_count, audio_format.bits_per_sample + ), + )); + } + let samples_per_block = coded_payload_bits / coded_sample_width + leading_samples; + if samples_per_block == 0 { + return Err(invalid_avi( + spec, + &format!( + "AVI audio stream {stream_index} did not expose one complete ADPCM sample block" + ), + )); + } + Ok((sample_entry_type, codec_label, samples_per_block)) +} + +fn finalize_avi_mp3_track_sync( + file: &mut File, + path: &Path, + spec: &str, + descriptor: AviTrackDescriptor, + chunks: Vec, +) -> Result { + let source_spec = build_avi_segmented_source_spec(path, &chunks)?; + let parsed = + scan_mp3_segmented_sync(file, &source_spec.segments, source_spec.total_size, spec)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("mp3"), + mux_policy: direct_ingest_mux_policy("mp3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples: candidate_samples_from_staged(parsed.samples), + }, + source_spec, + }) +} + +#[cfg(feature = "async")] +async fn finalize_avi_mp3_track_async( + file: &mut TokioFile, + path: &Path, + spec: &str, + descriptor: AviTrackDescriptor, + chunks: Vec, +) -> Result { + let source_spec = build_avi_segmented_source_spec(path, &chunks)?; + let parsed = + scan_mp3_segmented_async(file, &source_spec.segments, source_spec.total_size, spec).await?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("mp3"), + mux_policy: direct_ingest_mux_policy("mp3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples: candidate_samples_from_staged(parsed.samples), + }, + source_spec, + }) +} + +fn finalize_avi_ac3_track_sync( + file: &mut File, + path: &Path, + spec: &str, + descriptor: AviTrackDescriptor, + chunks: Vec, +) -> Result { + let source_spec = build_avi_segmented_source_spec(path, &chunks)?; + let parsed = + scan_ac3_segmented_sync(file, &source_spec.segments, source_spec.total_size, spec)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("ac3"), + mux_policy: direct_ingest_mux_policy("ac3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples: candidate_samples_from_staged(parsed.samples), + }, + source_spec, + }) +} + +#[cfg(feature = "async")] +async fn finalize_avi_ac3_track_async( + file: &mut TokioFile, + path: &Path, + spec: &str, + descriptor: AviTrackDescriptor, + chunks: Vec, +) -> Result { + let source_spec = build_avi_segmented_source_spec(path, &chunks)?; + let parsed = + scan_ac3_segmented_async(file, &source_spec.segments, source_spec.total_size, spec).await?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("ac3"), + mux_policy: direct_ingest_mux_policy("ac3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples: candidate_samples_from_staged(parsed.samples), + }, + source_spec, + }) +} + +fn finalize_avi_adts_track_sync( + file: &mut File, + path: &Path, + spec: &str, + descriptor: AviTrackDescriptor, + chunks: Vec, +) -> Result { + let source_spec = build_avi_segmented_source_spec(path, &chunks)?; + let parsed = + scan_adts_segmented_sync(file, &source_spec.segments, source_spec.total_size, spec)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("aac"), + mux_policy: direct_ingest_mux_policy("aac", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples: candidate_samples_from_staged(parsed.samples), + }, + source_spec, + }) +} + +#[cfg(feature = "async")] +async fn finalize_avi_adts_track_async( + file: &mut TokioFile, + path: &Path, + spec: &str, + descriptor: AviTrackDescriptor, + chunks: Vec, +) -> Result { + let source_spec = build_avi_segmented_source_spec(path, &chunks)?; + let parsed = + scan_adts_segmented_async(file, &source_spec.segments, source_spec.total_size, spec) + .await?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("aac"), + mux_policy: direct_ingest_mux_policy("aac", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples: candidate_samples_from_staged(parsed.samples), + }, + source_spec, + }) +} + +fn finalize_avi_raw_aac_track( + spec: &str, + source_index: usize, + stream_index: u32, + audio_format: AviAudioFormat, + chunks: Vec, +) -> Result { + let sample_entry_box = + build_aac_lc_sample_entry_box(audio_format.sample_rate, audio_format.channel_count)?; + let samples = chunks + .into_iter() + .map(|chunk| CandidateSample { + source_index, + data_offset: chunk.data_offset, + data_size: chunk.data_size, + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }) + .collect::>(); + if samples.is_empty() { + return Err(invalid_avi( + spec, + &format!("AVI AAC stream {stream_index} did not contain any audio chunks"), + )); + } + Ok(TrackCandidate { + track_id: stream_index + 1, + kind: MuxTrackKind::Audio, + timescale: audio_format.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("aac"), + mux_policy: direct_ingest_mux_policy("aac", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }) +} + +fn finalize_avi_mp4v_track_sync( + file: &mut File, + path: &Path, + spec: &str, + source_index: usize, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, +) -> Result { + let timing = avi_video_timing(descriptor.timing_scale, descriptor.timing_rate); + let input_spec = build_avi_segmented_source_spec(path, &chunks)?; + let (decoder_specific_info, parsed_samples) = match scan_mp4v_segmented_sync( + file, + &input_spec.segments, + input_spec.total_size, + spec, + ) { + Ok(parsed) => { + if parsed.samples.len() != chunks.len() { + return Err(invalid_avi( + spec, + &format!( + "AVI MPEG-4 Part 2 stream {} did not map one chunk to one access unit on the native direct-ingest path", + descriptor.stream_index + ), + )); + } + if parsed.width != video_format.width || parsed.height != video_format.height { + return Err(invalid_avi( + spec, + &format!( + "AVI MPEG-4 Part 2 stream {} carried container dimensions that disagreed with the decoder configuration", + descriptor.stream_index + ), + )); + } + (parsed.decoder_specific_info, Some(parsed.samples)) + } + Err(MuxError::UnsupportedTrackImport { message, .. }) + if message == "MPEG-4 Part 2 decoder config did not precede the first VOP sample" + && !video_format.decoder_specific_info.is_empty() => + { + (video_format.decoder_specific_info.clone(), None) + } + Err(error) => return Err(error), + }; + let native_samples = if let Some(parsed_samples) = parsed_samples { + build_native_avi_mp4v_candidate_samples( + spec, + descriptor.stream_index, + source_index, + &chunks, + parsed_samples, + )? + } else { + None + }; + let (samples, sample_sizes) = match native_samples { + Some(native) => native, + None => build_fallback_avi_mp4v_candidate_samples_sync( + file, + spec, + descriptor.stream_index, + source_index, + timing.sample_duration, + &decoder_specific_info, + &chunks, + )?, + }; + Ok(TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Video, + timescale: timing.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mp4v"), + mux_policy: direct_ingest_mux_policy("mp4v", MuxTrackKind::Video), + width: video_format.width, + height: video_format.height, + sample_entry_box: build_direct_mp4v_sample_entry_box( + video_format.width, + video_format.height, + &decoder_specific_info, + timing.timescale, + sample_sizes, + )?, + source_edit_media_time: None, + samples, + }) +} + +#[cfg(feature = "async")] +async fn finalize_avi_mp4v_track_async( + file: &mut TokioFile, + path: &Path, + spec: &str, + source_index: usize, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, +) -> Result { + let timing = avi_video_timing(descriptor.timing_scale, descriptor.timing_rate); + let input_spec = build_avi_segmented_source_spec(path, &chunks)?; + let (decoder_specific_info, parsed_samples) = match scan_mp4v_segmented_async( + file, + &input_spec.segments, + input_spec.total_size, + spec, + ) + .await + { + Ok(parsed) => { + if parsed.samples.len() != chunks.len() { + return Err(invalid_avi( + spec, + &format!( + "AVI MPEG-4 Part 2 stream {} did not map one chunk to one access unit on the native direct-ingest path", + descriptor.stream_index + ), + )); + } + if parsed.width != video_format.width || parsed.height != video_format.height { + return Err(invalid_avi( + spec, + &format!( + "AVI MPEG-4 Part 2 stream {} carried container dimensions that disagreed with the decoder configuration", + descriptor.stream_index + ), + )); + } + (parsed.decoder_specific_info, Some(parsed.samples)) + } + Err(MuxError::UnsupportedTrackImport { message, .. }) + if message == "MPEG-4 Part 2 decoder config did not precede the first VOP sample" + && !video_format.decoder_specific_info.is_empty() => + { + (video_format.decoder_specific_info.clone(), None) + } + Err(error) => return Err(error), + }; + let native_samples = if let Some(parsed_samples) = parsed_samples { + build_native_avi_mp4v_candidate_samples( + spec, + descriptor.stream_index, + source_index, + &chunks, + parsed_samples, + )? + } else { + None + }; + let (samples, sample_sizes) = match native_samples { + Some(native) => native, + None => { + build_fallback_avi_mp4v_candidate_samples_async( + file, + spec, + descriptor.stream_index, + source_index, + timing.sample_duration, + &decoder_specific_info, + &chunks, + ) + .await? + } + }; + Ok(TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Video, + timescale: timing.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mp4v"), + mux_policy: direct_ingest_mux_policy("mp4v", MuxTrackKind::Video), + width: video_format.width, + height: video_format.height, + sample_entry_box: build_direct_mp4v_sample_entry_box( + video_format.width, + video_format.height, + &decoder_specific_info, + timing.timescale, + sample_sizes, + )?, + source_edit_media_time: None, + samples, + }) +} + +fn build_native_avi_mp4v_candidate_samples( + spec: &str, + stream_index: u32, + source_index: usize, + chunks: &[AviChunkSpan], + parsed_samples: Vec, +) -> Result, MuxError> { + let mut logical_chunk_offset = 0_u64; + let mut samples = Vec::with_capacity(parsed_samples.len()); + let mut sample_sizes = Vec::with_capacity(parsed_samples.len()); + for (chunk, sample) in chunks.iter().zip(parsed_samples) { + let sample_start = sample + .data_offset + .checked_sub(logical_chunk_offset) + .ok_or_else(|| { + invalid_avi( + spec, + &format!( + "AVI MPEG-4 Part 2 stream {} produced one sample before its chunk start", + stream_index + ), + ) + })?; + let sample_end = sample_start + .checked_add(u64::from(sample.data_size)) + .ok_or(MuxError::LayoutOverflow("AVI MPEG-4 Part 2 sample end"))?; + if sample_end > u64::from(chunk.data_size) { + return Ok(None); + } + let data_offset = chunk + .data_offset + .checked_add(sample_start) + .ok_or(MuxError::LayoutOverflow("AVI MPEG-4 Part 2 sample offset"))?; + sample_sizes.push((sample.data_size, sample.duration)); + samples.push(CandidateSample { + source_index, + data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }); + logical_chunk_offset = logical_chunk_offset + .checked_add(u64::from(chunk.data_size)) + .ok_or(MuxError::LayoutOverflow("AVI MPEG-4 Part 2 logical offset"))?; + } + Ok(Some((samples, sample_sizes))) +} + +type AviCandidateSamples = (Vec, Vec<(u32, u32)>); + +fn trimmed_avi_mp4v_chunk_payload( + spec: &str, + stream_index: u32, + chunk: &AviChunkSpan, + frame: &[u8], + decoder_specific_info: &[u8], +) -> Result<(u64, u32), MuxError> { + if decoder_specific_info.is_empty() || !frame.starts_with(decoder_specific_info) { + return Ok((chunk.data_offset, chunk.data_size)); + } + + let trimmed_prefix = u64::try_from(decoder_specific_info.len()) + .map_err(|_| MuxError::LayoutOverflow("AVI MPEG-4 Part 2 decoder config size"))?; + let trimmed_prefix_u32 = u32::try_from(trimmed_prefix) + .map_err(|_| MuxError::LayoutOverflow("AVI MPEG-4 Part 2 decoder config size"))?; + let trimmed_size = chunk + .data_size + .checked_sub(trimmed_prefix_u32) + .ok_or_else(|| { + invalid_avi( + spec, + &format!( + "AVI MPEG-4 Part 2 stream {} carried one chunk with only decoder configuration and no VOP payload", + stream_index + ), + ) + })?; + if trimmed_size == 0 { + return Err(invalid_avi( + spec, + &format!( + "AVI MPEG-4 Part 2 stream {} carried one zero-length VOP payload after stripping duplicated decoder configuration", + stream_index + ), + )); + } + + let trimmed_offset = chunk + .data_offset + .checked_add(trimmed_prefix) + .ok_or(MuxError::LayoutOverflow("AVI MPEG-4 Part 2 sample offset"))?; + Ok((trimmed_offset, trimmed_size)) +} + +fn build_fallback_avi_mp4v_candidate_samples_sync( + file: &mut File, + spec: &str, + stream_index: u32, + source_index: usize, + sample_duration: u32, + decoder_specific_info: &[u8], + chunks: &[AviChunkSpan], +) -> Result { + let mut samples = Vec::with_capacity(chunks.len()); + let mut sample_sizes = Vec::with_capacity(chunks.len()); + for chunk in chunks { + if chunk.data_size == 0 { + return Err(invalid_avi( + spec, + &format!( + "AVI video stream {} carried one zero-length chunk", + stream_index + ), + )); + } + let mut frame = vec![ + 0_u8; + usize::try_from(chunk.data_size) + .map_err(|_| MuxError::LayoutOverflow("AVI video chunk size"))? + ]; + read_exact_at_sync( + file, + chunk.data_offset, + &mut frame, + spec, + "AVI video chunk is truncated", + )?; + let is_sync_sample = avi_mp4v_chunk_is_sync_sample(spec, stream_index, &frame)?; + let (data_offset, data_size) = trimmed_avi_mp4v_chunk_payload( + spec, + stream_index, + chunk, + &frame, + decoder_specific_info, + )?; + sample_sizes.push((data_size, sample_duration)); + samples.push(CandidateSample { + source_index, + data_offset, + data_size, + duration: sample_duration, + composition_time_offset: 0, + is_sync_sample, + }); + } + Ok((samples, sample_sizes)) +} + +#[cfg(feature = "async")] +async fn build_fallback_avi_mp4v_candidate_samples_async( + file: &mut TokioFile, + spec: &str, + stream_index: u32, + source_index: usize, + sample_duration: u32, + decoder_specific_info: &[u8], + chunks: &[AviChunkSpan], +) -> Result { + let mut samples = Vec::with_capacity(chunks.len()); + let mut sample_sizes = Vec::with_capacity(chunks.len()); + for chunk in chunks { + if chunk.data_size == 0 { + return Err(invalid_avi( + spec, + &format!( + "AVI video stream {} carried one zero-length chunk", + stream_index + ), + )); + } + let mut frame = vec![ + 0_u8; + usize::try_from(chunk.data_size) + .map_err(|_| MuxError::LayoutOverflow("AVI video chunk size"))? + ]; + read_exact_at_async( + file, + chunk.data_offset, + &mut frame, + spec, + "AVI video chunk is truncated", + ) + .await?; + let is_sync_sample = avi_mp4v_chunk_is_sync_sample(spec, stream_index, &frame)?; + let (data_offset, data_size) = trimmed_avi_mp4v_chunk_payload( + spec, + stream_index, + chunk, + &frame, + decoder_specific_info, + )?; + sample_sizes.push((data_size, sample_duration)); + samples.push(CandidateSample { + source_index, + data_offset, + data_size, + duration: sample_duration, + composition_time_offset: 0, + is_sync_sample, + }); + } + Ok((samples, sample_sizes)) +} + +fn finalize_avi_h264_track_sync( + file: &mut File, + path: &Path, + spec: &str, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, +) -> Result { + let timing = avi_video_timing(descriptor.timing_scale, descriptor.timing_rate); + let input_spec = build_avi_segmented_source_spec(path, &chunks)?; + let mut staged = stage_annex_b_h264_segmented_sync( + path, + file, + &input_spec.segments, + input_spec.total_size, + spec, + )?; + if staged.samples.len() != chunks.len() { + return Err(invalid_avi( + spec, + &format!( + "AVI H.264 stream {} did not map one chunk to one access unit on the native direct-ingest path", + descriptor.stream_index + ), + )); + } + if staged.track_width != video_format.width || staged.track_height != video_format.height { + return Err(invalid_avi( + spec, + &format!( + "AVI H.264 stream {} carried container dimensions that disagreed with the SPS dimensions", + descriptor.stream_index + ), + )); + } + for sample in &mut staged.samples { + sample.duration = timing.sample_duration; + sample.composition_time_offset = 0; + } + let sample_entry_box = retune_carried_h264_sample_entry_box( + &staged.sample_entry_box, + timing.timescale, + Some(super::h264::authored_h264_media_duration( + staged + .samples + .iter() + .map(|sample| (sample.duration, sample.composition_time_offset)), + )?), + staged + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + true, + false, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Video, + timescale: timing.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h264"), + mux_policy: direct_ingest_mux_policy("h264", MuxTrackKind::Video), + width: staged.track_width, + height: staged.track_height, + sample_entry_box, + source_edit_media_time: None, + samples: candidate_samples_from_staged(staged.samples), + }, + source_spec: staged.segmented_source, + }) +} + +#[cfg(feature = "async")] +async fn finalize_avi_h264_track_async( + file: &mut TokioFile, + path: &Path, + spec: &str, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, +) -> Result { + let timing = avi_video_timing(descriptor.timing_scale, descriptor.timing_rate); + let input_spec = build_avi_segmented_source_spec(path, &chunks)?; + let mut staged = stage_annex_b_h264_segmented_async( + path, + file, + &input_spec.segments, + input_spec.total_size, + spec, + ) + .await?; + if staged.samples.len() != chunks.len() { + return Err(invalid_avi( + spec, + &format!( + "AVI H.264 stream {} did not map one chunk to one access unit on the native direct-ingest path", + descriptor.stream_index + ), + )); + } + if staged.track_width != video_format.width || staged.track_height != video_format.height { + return Err(invalid_avi( + spec, + &format!( + "AVI H.264 stream {} carried container dimensions that disagreed with the SPS dimensions", + descriptor.stream_index + ), + )); + } + for sample in &mut staged.samples { + sample.duration = timing.sample_duration; + sample.composition_time_offset = 0; + } + let sample_entry_box = retune_carried_h264_sample_entry_box( + &staged.sample_entry_box, + timing.timescale, + Some(super::h264::authored_h264_media_duration( + staged + .samples + .iter() + .map(|sample| (sample.duration, sample.composition_time_offset)), + )?), + staged + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + true, + false, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Video, + timescale: timing.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h264"), + mux_policy: direct_ingest_mux_policy("h264", MuxTrackKind::Video), + width: staged.track_width, + height: staged.track_height, + sample_entry_box, + source_edit_media_time: None, + samples: candidate_samples_from_staged(staged.samples), + }, + source_spec: staged.segmented_source, + }) +} + +fn finalize_avi_h264_avc1_track_sync( + file: &mut File, + spec: &str, + source_index: usize, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, +) -> Result { + let timing = avi_video_timing(descriptor.timing_scale, descriptor.timing_rate); + let avcc = parse_avi_avc1_decoder_configuration( + spec, + descriptor.stream_index, + &video_format.decoder_specific_info, + )?; + let length_size = usize::from(avcc.length_size_minus_one) + 1; + let (sample_entry_box, coded_width, coded_height) = + build_h264_sample_entry_from_avc_config_with_options(&avcc, spec, false)?; + if coded_width != video_format.width || coded_height != video_format.height { + return Err(invalid_avi( + spec, + &format!( + "AVI H.264 `avc1` stream {} carried container dimensions that disagreed with the decoder configuration", + descriptor.stream_index + ), + )); + } + + let mut samples = Vec::with_capacity(chunks.len()); + for chunk in &chunks { + if chunk.data_size == 0 { + return Err(invalid_avi( + spec, + &format!( + "AVI video stream {} carried one zero-length chunk", + descriptor.stream_index + ), + )); + } + let mut frame = vec![ + 0_u8; + usize::try_from(chunk.data_size) + .map_err(|_| MuxError::LayoutOverflow("AVI video chunk size"))? + ]; + read_exact_at_sync( + file, + chunk.data_offset, + &mut frame, + spec, + "AVI video chunk is truncated", + )?; + let is_sync_sample = + avi_avc1_chunk_is_sync_sample(spec, descriptor.stream_index, &frame, length_size)?; + samples.push(CandidateSample { + source_index, + data_offset: chunk.data_offset, + data_size: chunk.data_size, + duration: timing.sample_duration, + composition_time_offset: 0, + is_sync_sample, + }); + } + let sample_entry_box = append_btrt_to_visual_sample_entry( + sample_entry_box, + timing.timescale, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?; + Ok(TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Video, + timescale: timing.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h264"), + mux_policy: avi_avc1_mux_policy(), + width: video_format.width, + height: video_format.height, + sample_entry_box, + source_edit_media_time: None, + samples, + }) +} + +#[cfg(feature = "async")] +async fn finalize_avi_h264_avc1_track_async( + file: &mut TokioFile, + spec: &str, + source_index: usize, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, +) -> Result { + let timing = avi_video_timing(descriptor.timing_scale, descriptor.timing_rate); + let avcc = parse_avi_avc1_decoder_configuration( + spec, + descriptor.stream_index, + &video_format.decoder_specific_info, + )?; + let length_size = usize::from(avcc.length_size_minus_one) + 1; + let (sample_entry_box, coded_width, coded_height) = + build_h264_sample_entry_from_avc_config_with_options(&avcc, spec, false)?; + if coded_width != video_format.width || coded_height != video_format.height { + return Err(invalid_avi( + spec, + &format!( + "AVI H.264 `avc1` stream {} carried container dimensions that disagreed with the decoder configuration", + descriptor.stream_index + ), + )); + } + + let mut samples = Vec::with_capacity(chunks.len()); + for chunk in &chunks { + if chunk.data_size == 0 { + return Err(invalid_avi( + spec, + &format!( + "AVI video stream {} carried one zero-length chunk", + descriptor.stream_index + ), + )); + } + let mut frame = vec![ + 0_u8; + usize::try_from(chunk.data_size) + .map_err(|_| MuxError::LayoutOverflow("AVI video chunk size"))? + ]; + read_exact_at_async( + file, + chunk.data_offset, + &mut frame, + spec, + "AVI video chunk is truncated", + ) + .await?; + let is_sync_sample = + avi_avc1_chunk_is_sync_sample(spec, descriptor.stream_index, &frame, length_size)?; + samples.push(CandidateSample { + source_index, + data_offset: chunk.data_offset, + data_size: chunk.data_size, + duration: timing.sample_duration, + composition_time_offset: 0, + is_sync_sample, + }); + } + let sample_entry_box = append_btrt_to_visual_sample_entry( + sample_entry_box, + timing.timescale, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?; + Ok(TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Video, + timescale: timing.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h264"), + mux_policy: avi_avc1_mux_policy(), + width: video_format.width, + height: video_format.height, + sample_entry_box, + source_edit_media_time: None, + samples, + }) +} + +fn finalize_avi_h263_track_sync( + file: &mut File, + spec: &str, + source_index: usize, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, +) -> Result { + let timing = avi_video_timing(descriptor.timing_scale, descriptor.timing_rate); + let mut samples = Vec::with_capacity(chunks.len()); + for chunk in chunks { + let frame = read_avi_chunk_bytes_sync(file, chunk, spec, "AVI H.263 chunk is truncated")?; + let (width, height, is_sync_sample) = parse_h263_picture_bytes(spec, &frame)?; + if width != video_format.width || height != video_format.height { + return Err(invalid_avi( + spec, + &format!( + "AVI H.263 stream {} carried container dimensions that disagreed with the picture header", + descriptor.stream_index + ), + )); + } + samples.push(CandidateSample { + source_index, + data_offset: chunk.data_offset, + data_size: chunk.data_size, + duration: timing.sample_duration, + composition_time_offset: 0, + is_sync_sample, + }); + } + let sample_entry_box = build_avi_h263_sample_entry_box( + video_format.width, + video_format.height, + build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + timing.timescale, + )?, + )?; + Ok(TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Video, + timescale: timing.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h263"), + mux_policy: with_force_empty_sync_sample_table(direct_ingest_mux_policy( + "h263", + MuxTrackKind::Video, + )), + width: video_format.width, + height: video_format.height, + sample_entry_box, + source_edit_media_time: None, + samples, + }) +} + +#[cfg(feature = "async")] +async fn finalize_avi_h263_track_async( + file: &mut TokioFile, + spec: &str, + source_index: usize, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, +) -> Result { + let timing = avi_video_timing(descriptor.timing_scale, descriptor.timing_rate); + let mut samples = Vec::with_capacity(chunks.len()); + for chunk in chunks { + let frame = + read_avi_chunk_bytes_async(file, chunk, spec, "AVI H.263 chunk is truncated").await?; + let (width, height, is_sync_sample) = parse_h263_picture_bytes(spec, &frame)?; + if width != video_format.width || height != video_format.height { + return Err(invalid_avi( + spec, + &format!( + "AVI H.263 stream {} carried container dimensions that disagreed with the picture header", + descriptor.stream_index + ), + )); + } + samples.push(CandidateSample { + source_index, + data_offset: chunk.data_offset, + data_size: chunk.data_size, + duration: timing.sample_duration, + composition_time_offset: 0, + is_sync_sample, + }); + } + let sample_entry_box = build_avi_h263_sample_entry_box( + video_format.width, + video_format.height, + build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + timing.timescale, + )?, + )?; + Ok(TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Video, + timescale: timing.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h263"), + mux_policy: with_force_empty_sync_sample_table(direct_ingest_mux_policy( + "h263", + MuxTrackKind::Video, + )), + width: video_format.width, + height: video_format.height, + sample_entry_box, + source_edit_media_time: None, + samples, + }) +} + +fn finalize_avi_jpeg_track_sync( + file: &mut File, + spec: &str, + source_index: usize, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, +) -> Result { + finalize_avi_still_image_track_sync( + file, + spec, + source_index, + descriptor, + video_format, + chunks, + "jpeg", + ) +} + +#[cfg(feature = "async")] +async fn finalize_avi_jpeg_track_async( + file: &mut TokioFile, + spec: &str, + source_index: usize, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, +) -> Result { + finalize_avi_still_image_track_async( + file, + spec, + source_index, + descriptor, + video_format, + chunks, + "jpeg", + ) + .await +} + +fn finalize_avi_png_track_sync( + file: &mut File, + spec: &str, + source_index: usize, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, +) -> Result { + finalize_avi_still_image_track_sync( + file, + spec, + source_index, + descriptor, + video_format, + chunks, + "png", + ) +} + +#[cfg(feature = "async")] +async fn finalize_avi_png_track_async( + file: &mut TokioFile, + spec: &str, + source_index: usize, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, +) -> Result { + finalize_avi_still_image_track_async( + file, + spec, + source_index, + descriptor, + video_format, + chunks, + "png", + ) + .await +} + +fn finalize_avi_still_image_track_sync( + file: &mut File, + spec: &str, + source_index: usize, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, + codec_label: &'static str, +) -> Result { + let timing = avi_video_timing(descriptor.timing_scale, descriptor.timing_rate); + let mut sample_entry_box = None::>; + let mut samples = Vec::with_capacity(chunks.len()); + for chunk in chunks { + let frame = + read_avi_chunk_bytes_sync(file, chunk, spec, "AVI still-image chunk is truncated")?; + let (parsed_width, parsed_height, parsed_sample_entry_box) = match codec_label { + "jpeg" => { + let parsed = parse_jpeg_bytes(spec, &frame)?; + ( + parsed.width, + parsed.height, + build_avi_jpeg_sample_entry_box(parsed.width, parsed.height)?, + ) + } + "png" => { + let parsed = parse_png_bytes(spec, &frame)?; + ( + parsed.width, + parsed.height, + build_avi_png_sample_entry_box(parsed.width, parsed.height)?, + ) + } + _ => unreachable!("AVI still-image helper only supports JPEG and PNG"), + }; + if parsed_width != video_format.width || parsed_height != video_format.height { + return Err(invalid_avi( + spec, + &format!( + "AVI video stream {} carried container dimensions that disagreed with the still-image payload", + descriptor.stream_index + ), + )); + } + if sample_entry_box.is_none() { + sample_entry_box = Some(parsed_sample_entry_box); + } + samples.push(CandidateSample { + source_index, + data_offset: chunk.data_offset, + data_size: chunk.data_size, + duration: timing.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + Ok(TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Video, + timescale: timing.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name(codec_label), + mux_policy: with_force_empty_sync_sample_table(direct_ingest_mux_policy( + codec_label, + MuxTrackKind::Video, + )), + width: video_format.width, + height: video_format.height, + sample_entry_box: sample_entry_box.ok_or_else(|| { + invalid_avi( + spec, + &format!( + "AVI video stream {} did not contain any still-image chunks", + descriptor.stream_index + ), + ) + })?, + source_edit_media_time: None, + samples, + }) +} + +fn finalize_avi_generic_visual_track( + source_index: usize, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, + sample_entry_type: FourCc, + compressor_name: Vec, +) -> Result { + let timing = avi_video_timing(descriptor.timing_scale, descriptor.timing_rate); + let samples = chunks + .into_iter() + .map(|chunk| CandidateSample { + source_index, + data_offset: chunk.data_offset, + data_size: chunk.data_size, + duration: timing.sample_duration, + composition_time_offset: 0, + is_sync_sample: false, + }) + .collect::>(); + let sample_entry_box = build_avi_generic_visual_sample_entry_box( + sample_entry_type, + video_format.width, + video_format.height, + &compressor_name, + )?; + let sample_entry_box = append_btrt_to_visual_sample_entry( + sample_entry_box, + timing.timescale, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?; + Ok(TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Video, + timescale: timing.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mp4v"), + mux_policy: with_force_empty_sync_sample_table(direct_ingest_mux_policy( + "mp4v", + MuxTrackKind::Video, + )), + width: video_format.width, + height: video_format.height, + sample_entry_box, + source_edit_media_time: None, + samples, + }) +} + +fn finalize_avi_uncv_bgr_track( + source_index: usize, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, +) -> Result { + let timing = avi_video_timing(descriptor.timing_scale, descriptor.timing_rate); + let samples = chunks + .into_iter() + .map(|chunk| CandidateSample { + source_index, + data_offset: chunk.data_offset, + data_size: chunk.data_size, + duration: timing.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }) + .collect::>(); + let sample_entry_box = build_avi_uncv_bgr_sample_entry_box( + video_format.width, + video_format.height, + AVI_RAW_VIDEO_COMPRESSOR_NAME, + )?; + Ok(TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Video, + timescale: timing.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mp4v"), + mux_policy: direct_ingest_mux_policy("mp4v", MuxTrackKind::Video), + width: video_format.width, + height: video_format.height, + sample_entry_box, + source_edit_media_time: None, + samples, + }) +} + +#[cfg(feature = "async")] +async fn finalize_avi_still_image_track_async( + file: &mut TokioFile, + spec: &str, + source_index: usize, + descriptor: AviTrackDescriptor, + video_format: AviVideoFormat, + chunks: Vec, + codec_label: &'static str, +) -> Result { + let timing = avi_video_timing(descriptor.timing_scale, descriptor.timing_rate); + let mut sample_entry_box = None::>; + let mut samples = Vec::with_capacity(chunks.len()); + for chunk in chunks { + let frame = + read_avi_chunk_bytes_async(file, chunk, spec, "AVI still-image chunk is truncated") + .await?; + let (parsed_width, parsed_height, parsed_sample_entry_box) = match codec_label { + "jpeg" => { + let parsed = parse_jpeg_bytes(spec, &frame)?; + ( + parsed.width, + parsed.height, + build_avi_jpeg_sample_entry_box(parsed.width, parsed.height)?, + ) + } + "png" => { + let parsed = parse_png_bytes(spec, &frame)?; + ( + parsed.width, + parsed.height, + build_avi_png_sample_entry_box(parsed.width, parsed.height)?, + ) + } + _ => unreachable!("AVI still-image helper only supports JPEG and PNG"), + }; + if parsed_width != video_format.width || parsed_height != video_format.height { + return Err(invalid_avi( + spec, + &format!( + "AVI video stream {} carried container dimensions that disagreed with the still-image payload", + descriptor.stream_index + ), + )); + } + if sample_entry_box.is_none() { + sample_entry_box = Some(parsed_sample_entry_box); + } + samples.push(CandidateSample { + source_index, + data_offset: chunk.data_offset, + data_size: chunk.data_size, + duration: timing.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + Ok(TrackCandidate { + track_id: descriptor.stream_index + 1, + kind: MuxTrackKind::Video, + timescale: timing.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name(codec_label), + mux_policy: with_force_empty_sync_sample_table(direct_ingest_mux_policy( + codec_label, + MuxTrackKind::Video, + )), + width: video_format.width, + height: video_format.height, + sample_entry_box: sample_entry_box.ok_or_else(|| { + invalid_avi( + spec, + &format!( + "AVI video stream {} did not contain any still-image chunks", + descriptor.stream_index + ), + ) + })?, + source_edit_media_time: None, + samples, + }) +} + +fn parse_hdrl_list_sync( + file: &mut File, + start: u64, + end: u64, + spec: &str, + tracks: &mut Vec, +) -> Result<(), MuxError> { + let mut offset = start; + while offset < end { + let (chunk_type, chunk_size, chunk_payload_offset, chunk_end) = + read_riff_chunk_header_sync(file, end, offset, spec)?; + if chunk_type == LIST { + if chunk_size < 4 { + return Err(invalid_avi( + spec, + "AVI stream list was truncated before its list type", + )); + } + let list_type = read_fourcc_sync( + file, + chunk_payload_offset, + spec, + "AVI stream list type is truncated", + )?; + if list_type == STRL { + tracks.push(parse_stream_list_sync( + file, + chunk_payload_offset + 4, + chunk_end, + spec, + u32::try_from(tracks.len()) + .map_err(|_| MuxError::LayoutOverflow("AVI stream index"))?, + )?); + } + } + offset = next_riff_offset(chunk_end); + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn parse_hdrl_list_async( + file: &mut TokioFile, + start: u64, + end: u64, + spec: &str, + tracks: &mut Vec, +) -> Result<(), MuxError> { + let mut offset = start; + while offset < end { + let (chunk_type, chunk_size, chunk_payload_offset, chunk_end) = + read_riff_chunk_header_async(file, end, offset, spec).await?; + if chunk_type == LIST { + if chunk_size < 4 { + return Err(invalid_avi( + spec, + "AVI stream list was truncated before its list type", + )); + } + let list_type = read_fourcc_async( + file, + chunk_payload_offset, + spec, + "AVI stream list type is truncated", + ) + .await?; + if list_type == STRL { + tracks.push( + parse_stream_list_async( + file, + chunk_payload_offset + 4, + chunk_end, + spec, + u32::try_from(tracks.len()) + .map_err(|_| MuxError::LayoutOverflow("AVI stream index"))?, + ) + .await?, + ); + } + } + offset = next_riff_offset(chunk_end); + } + Ok(()) +} + +fn parse_stream_list_sync( + file: &mut File, + start: u64, + end: u64, + spec: &str, + stream_index: u32, +) -> Result { + let mut strh = None::>; + let mut strf = None::>; + let mut offset = start; + while offset < end { + let (chunk_type, chunk_size, chunk_payload_offset, chunk_end) = + read_riff_chunk_header_sync(file, end, offset, spec)?; + if matches!(chunk_type, STRH | STRF) { + let mut bytes = vec![ + 0_u8; + usize::try_from(chunk_size).map_err(|_| { + MuxError::LayoutOverflow("AVI stream chunk size") + })? + ]; + read_exact_at_sync( + file, + chunk_payload_offset, + &mut bytes, + spec, + "AVI stream chunk is truncated", + )?; + match chunk_type { + STRH => strh = Some(bytes), + STRF => strf = Some(bytes), + _ => {} + } + } + offset = next_riff_offset(chunk_end); + } + parse_stream_descriptor(spec, stream_index, strh, strf) +} + +#[cfg(feature = "async")] +async fn parse_stream_list_async( + file: &mut TokioFile, + start: u64, + end: u64, + spec: &str, + stream_index: u32, +) -> Result { + let mut strh = None::>; + let mut strf = None::>; + let mut offset = start; + while offset < end { + let (chunk_type, chunk_size, chunk_payload_offset, chunk_end) = + read_riff_chunk_header_async(file, end, offset, spec).await?; + if matches!(chunk_type, STRH | STRF) { + let mut bytes = vec![ + 0_u8; + usize::try_from(chunk_size).map_err(|_| { + MuxError::LayoutOverflow("AVI stream chunk size") + })? + ]; + read_exact_at_async( + file, + chunk_payload_offset, + &mut bytes, + spec, + "AVI stream chunk is truncated", + ) + .await?; + match chunk_type { + STRH => strh = Some(bytes), + STRF => strf = Some(bytes), + _ => {} + } + } + offset = next_riff_offset(chunk_end); + } + parse_stream_descriptor(spec, stream_index, strh, strf) +} + +fn parse_stream_descriptor( + spec: &str, + stream_index: u32, + strh: Option>, + strf: Option>, +) -> Result { + let strh = strh.ok_or_else(|| invalid_avi(spec, "AVI stream list did not contain `strh`"))?; + let strf = strf.ok_or_else(|| invalid_avi(spec, "AVI stream list did not contain `strf`"))?; + if strh.len() < 36 { + return Err(invalid_avi( + spec, + "AVI `strh` payload is shorter than 36 bytes", + )); + } + let stream_type = FourCc::from_bytes(strh[0..4].try_into().unwrap()); + let stream_handler = FourCc::from_bytes(strh[4..8].try_into().unwrap()); + let scale = u32::from_le_bytes(strh[20..24].try_into().unwrap()); + let rate = u32::from_le_bytes(strh[24..28].try_into().unwrap()); + if scale == 0 || rate == 0 { + return Err(invalid_avi( + spec, + &format!("AVI stream {stream_index} declared zero timing scale or rate"), + )); + } + let audio_format = if stream_type == AUDS { + Some(parse_avi_audio_format(spec, stream_index, &strf)?) + } else { + None + }; + let video_format = if stream_type == VIDS { + Some(parse_avi_video_format( + spec, + stream_index, + stream_handler, + &strf, + )?) + } else { + None + }; + Ok(AviTrackDescriptor { + stream_index, + stream_type, + timing_scale: scale, + timing_rate: rate, + audio_format, + video_format, + }) +} + +fn parse_avi_audio_format( + spec: &str, + stream_index: u32, + bytes: &[u8], +) -> Result { + if bytes.len() < 16 { + return Err(invalid_avi( + spec, + &format!("AVI audio stream {stream_index} carried a truncated WAVEFORMAT payload"), + )); + } + let mut format_tag = u16::from_le_bytes(bytes[0..2].try_into().unwrap()); + let channel_count = u16::from_le_bytes(bytes[2..4].try_into().unwrap()); + let sample_rate = u32::from_le_bytes(bytes[4..8].try_into().unwrap()); + let block_align = u16::from_le_bytes(bytes[12..14].try_into().unwrap()); + let bits_per_sample = u16::from_le_bytes(bytes[14..16].try_into().unwrap()); + if channel_count == 0 || sample_rate == 0 { + return Err(invalid_avi( + spec, + &format!("AVI audio stream {stream_index} declared zero channels or zero sample rate"), + )); + } + if format_tag == WAVE_FORMAT_EXTENSIBLE { + if bytes.len() < 40 { + return Err(invalid_avi( + spec, + &format!( + "AVI audio stream {stream_index} carried a truncated WAVE extensible payload" + ), + )); + } + let cb_size = u16::from_le_bytes(bytes[16..18].try_into().unwrap()); + if cb_size < 22 { + return Err(invalid_avi( + spec, + &format!( + "AVI audio stream {stream_index} carried one unsupported WAVE extensible extra size {cb_size}" + ), + )); + } + let subtype_guid = &bytes[24..40]; + format_tag = if subtype_guid == KSDATAFORMAT_SUBTYPE_PCM { + WAVE_FORMAT_PCM + } else if subtype_guid == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT { + WAVE_FORMAT_IEEE_FLOAT + } else if subtype_guid == KSDATAFORMAT_SUBTYPE_ALAW { + WAVE_FORMAT_ALAW + } else if subtype_guid == KSDATAFORMAT_SUBTYPE_MULAW { + WAVE_FORMAT_MULAW + } else { + return Err(invalid_avi( + spec, + &format!( + "AVI audio stream {stream_index} carried one unsupported WAVE extensible subtype GUID {}", + format_extensible_guid(subtype_guid) + ), + )); + }; + } + Ok(AviAudioFormat { + format_tag, + channel_count, + sample_rate, + block_align, + bits_per_sample, + }) +} + +fn format_extensible_guid(bytes: &[u8]) -> String { + bytes + .iter() + .map(|byte| format!("{byte:02X}")) + .collect::>() + .join("") +} + +fn parse_avi_video_format( + spec: &str, + stream_index: u32, + stream_handler: FourCc, + bytes: &[u8], +) -> Result { + if bytes.len() < 40 { + return Err(invalid_avi( + spec, + &format!("AVI video stream {stream_index} carried a truncated BITMAPINFO payload"), + )); + } + let header_size = usize::try_from(u32::from_le_bytes(bytes[0..4].try_into().unwrap())) + .map_err(|_| MuxError::LayoutOverflow("AVI BITMAPINFO header size"))?; + if header_size < 40 || bytes.len() < header_size { + return Err(invalid_avi( + spec, + &format!( + "AVI video stream {stream_index} carried one unsupported BITMAPINFO header size {header_size}" + ), + )); + } + let width = u16::try_from(i32::from_le_bytes(bytes[4..8].try_into().unwrap()).unsigned_abs()) + .map_err(|_| { + invalid_avi( + spec, + "AVI video width does not fit in an MP4 visual sample entry", + ) + })?; + let height = u16::try_from(i32::from_le_bytes(bytes[8..12].try_into().unwrap()).unsigned_abs()) + .map_err(|_| { + invalid_avi( + spec, + "AVI video height does not fit in an MP4 visual sample entry", + ) + })?; + let planes = u16::from_le_bytes(bytes[12..14].try_into().unwrap()); + if width == 0 || height == 0 || planes != 1 { + return Err(invalid_avi( + spec, + &format!( + "AVI video stream {stream_index} declared invalid width, height, or plane count" + ), + )); + } + let original_compression = FourCc::from_bytes(bytes[16..20].try_into().unwrap()); + let compression = normalize_avi_video_tag(original_compression); + let handler = normalize_avi_video_tag(stream_handler); + let codec = if avi_tag_maps_to_mp4v(compression) || avi_tag_maps_to_mp4v(handler) { + AviVideoCodec::Mp4v + } else if avi_tag_maps_to_h264_annex_b(compression) || avi_tag_maps_to_h264_annex_b(handler) { + AviVideoCodec::H264AnnexB + } else if compression == AVC1 || handler == AVC1 { + AviVideoCodec::H264Avc1 + } else if avi_tag_maps_to_h263(compression) || avi_tag_maps_to_h263(handler) { + AviVideoCodec::H263 + } else if avi_tag_maps_to_jpeg(compression) || avi_tag_maps_to_jpeg(handler) { + AviVideoCodec::Jpeg + } else if avi_tag_maps_to_png(compression) || avi_tag_maps_to_png(handler) { + AviVideoCodec::Png + } else if compression == SAMPLE_ENTRY_DIV3 || compression == SAMPLE_ENTRY_DIV4 { + AviVideoCodec::MsMpeg4V3(SAMPLE_ENTRY_DIV3) + } else if original_compression.into_bytes() == [0, 0, 0, 0] { + AviVideoCodec::RawBgr + } else { + AviVideoCodec::GenericPassthrough(original_compression) + }; + Ok(AviVideoFormat { + width, + height, + codec, + decoder_specific_info: bytes[header_size..].to_vec(), + }) +} + +fn parse_avi_avc1_decoder_configuration( + spec: &str, + stream_index: u32, + bytes: &[u8], +) -> Result { + if bytes.is_empty() { + return Err(invalid_avi( + spec, + &format!( + "AVI H.264 `avc1` stream {stream_index} did not carry an AVC decoder configuration payload" + ), + )); + } + let mut avcc = AVCDecoderConfiguration::default(); + unmarshal( + &mut Cursor::new(bytes), + u64::try_from(bytes.len()).map_err(|_| MuxError::LayoutOverflow("AVI avcC payload"))?, + &mut avcc, + None, + ) + .map_err(|_| { + invalid_avi( + spec, + &format!( + "AVI H.264 `avc1` stream {stream_index} carried one invalid AVC decoder configuration payload" + ), + ) + })?; + if avcc.configuration_version != 1 + || avcc.sequence_parameter_sets.is_empty() + || avcc.picture_parameter_sets.is_empty() + { + return Err(invalid_avi( + spec, + &format!( + "AVI H.264 `avc1` stream {stream_index} carried one incomplete AVC decoder configuration payload" + ), + )); + } + Ok(avcc) +} + +fn avi_avc1_chunk_is_sync_sample( + spec: &str, + stream_index: u32, + frame: &[u8], + length_size: usize, +) -> Result { + if length_size == 0 || length_size > 4 { + return Err(invalid_avi( + spec, + &format!( + "AVI H.264 `avc1` stream {stream_index} declared unsupported NAL length width {length_size}" + ), + )); + } + let mut offset = 0usize; + let mut saw_nal = false; + let mut is_sync_sample = false; + while offset < frame.len() { + if frame.len() - offset < length_size { + return Err(invalid_avi( + spec, + &format!( + "AVI H.264 `avc1` stream {stream_index} carried one truncated length-prefixed access unit" + ), + )); + } + let mut nal_size = 0usize; + for byte in &frame[offset..offset + length_size] { + nal_size = (nal_size << 8) | usize::from(*byte); + } + offset += length_size; + if nal_size == 0 || frame.len() - offset < nal_size { + return Err(invalid_avi( + spec, + &format!( + "AVI H.264 `avc1` stream {stream_index} carried one invalid length-prefixed NAL unit" + ), + )); + } + saw_nal = true; + if frame[offset] & 0x1F == 5 { + is_sync_sample = true; + } + offset += nal_size; + } + if !saw_nal { + return Err(invalid_avi( + spec, + &format!( + "AVI H.264 `avc1` stream {stream_index} carried one empty length-prefixed access unit" + ), + )); + } + Ok(is_sync_sample) +} + +fn append_btrt_to_visual_sample_entry( + mut sample_entry_box: Vec, + timescale: u32, + samples: I, +) -> Result, MuxError> +where + I: IntoIterator, +{ + let btrt_box = super::super::mp4::encode_typed_box( + &build_btrt_from_sample_sizes(samples, timescale)?, + &[], + )?; + if sample_entry_box.len() < 8 { + return Err(MuxError::LayoutOverflow("AVI visual sample-entry header")); + } + let existing_size = u32::from_be_bytes(sample_entry_box[..4].try_into().unwrap()); + let appended_size = u32::try_from(btrt_box.len()) + .map_err(|_| MuxError::LayoutOverflow("AVI visual btrt child size"))?; + let updated_size = existing_size + .checked_add(appended_size) + .ok_or(MuxError::LayoutOverflow("AVI visual sample-entry size"))?; + sample_entry_box[..4].copy_from_slice(&updated_size.to_be_bytes()); + sample_entry_box.extend_from_slice(&btrt_box); + Ok(sample_entry_box) +} + +fn build_avi_generic_visual_sample_entry_box( + sample_entry_type: FourCc, + width: u16, + height: u16, + compressor_name: &[u8], +) -> Result, MuxError> { + build_avi_generic_visual_sample_entry_box_with_children( + sample_entry_type, + width, + height, + compressor_name, + &[], + ) +} + +fn build_avi_generic_visual_sample_entry_box_with_children( + sample_entry_type: FourCc, + width: u16, + height: u16, + compressor_name: &[u8], + child_boxes: &[Vec], +) -> Result, MuxError> { + let mut compressorname = [0_u8; 32]; + let visible_len = compressor_name.len().min(31); + compressorname[0] = + u8::try_from(visible_len).map_err(|_| MuxError::LayoutOverflow("compressor name"))?; + compressorname[1..1 + visible_len].copy_from_slice(&compressor_name[..visible_len]); + super::super::mp4::encode_typed_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: sample_entry_type, + data_reference_index: 1, + }, + pre_defined2: [0, 0, 0], + width, + height, + // The retained reference authoring writes literal 72 here, not 16.16 fixed-point. + horizresolution: 72, + vertresolution: 72, + frame_count: 1, + compressorname, + depth: 0x0018, + pre_defined3: -1, + ..VisualSampleEntry::default() + }, + &child_boxes.concat(), + ) +} + +fn build_avi_uncv_bgr_sample_entry_box( + width: u16, + height: u16, + compressor_name: &[u8], +) -> Result, MuxError> { + let child_boxes = vec![ + build_avi_uncv_bgr_cmpd_box()?, + build_avi_uncv_bgr_uncc_box()?, + ]; + build_avi_generic_visual_sample_entry_box_with_children( + SAMPLE_ENTRY_UNCV, + width, + height, + compressor_name, + &child_boxes, + ) +} + +fn build_avi_uncv_bgr_cmpd_box() -> Result, MuxError> { + let mut payload = Vec::with_capacity(10); + payload.extend_from_slice(&3_u32.to_be_bytes()); + payload.extend_from_slice(&6_u16.to_be_bytes()); + payload.extend_from_slice(&5_u16.to_be_bytes()); + payload.extend_from_slice(&4_u16.to_be_bytes()); + super::super::mp4::encode_raw_box(CMPD, &payload) +} + +fn build_avi_uncv_bgr_uncc_box() -> Result, MuxError> { + let mut payload = Vec::with_capacity(51); + payload.extend_from_slice(&0_u32.to_be_bytes()); + payload.extend_from_slice(&0_u32.to_be_bytes()); + payload.extend_from_slice(&3_u32.to_be_bytes()); + for component_index in 0_u16..3 { + payload.extend_from_slice(&component_index.to_be_bytes()); + payload.push(7); + payload.push(0); + payload.push(0); + } + payload.push(0); + payload.push(1); + payload.push(0); + payload.push(0x08); + payload.extend_from_slice(&0_u32.to_be_bytes()); + payload.extend_from_slice(&0_u32.to_be_bytes()); + payload.extend_from_slice(&0_u32.to_be_bytes()); + payload.extend_from_slice(&0_u32.to_be_bytes()); + payload.extend_from_slice(&0_u32.to_be_bytes()); + super::super::mp4::encode_raw_box(UNCC, &payload) +} + +fn avi_avc1_mux_policy() -> super::super::import::ImportedTrackMuxPolicy { + with_force_empty_sync_sample_table(direct_ingest_mux_policy("h264", MuxTrackKind::Video)) +} + +#[derive(Clone, Copy)] +struct AviVideoTiming { + timescale: u32, + sample_duration: u32, +} + +fn avi_video_timing(frame_scale: u32, frame_rate: u32) -> AviVideoTiming { + let fps_times_1000 = ((f64::from(frame_rate) / f64::from(frame_scale)) * 1000.0 + 0.5) as u32; + match fps_times_1000 { + 29_970 => AviVideoTiming { + timescale: 30_000, + sample_duration: 1_001, + }, + 23_976 => AviVideoTiming { + timescale: 24_000, + sample_duration: 1_001, + }, + 59_940 => AviVideoTiming { + timescale: 60_000, + sample_duration: 1_001, + }, + _ => AviVideoTiming { + timescale: fps_times_1000, + sample_duration: 1_000, + }, + } +} + +fn normalize_avi_video_tag(tag: FourCc) -> FourCc { + let mut bytes = tag.into_bytes(); + for byte in &mut bytes { + byte.make_ascii_uppercase(); + } + FourCc::from_bytes(bytes) +} + +fn avi_tag_maps_to_mp4v(tag: FourCc) -> bool { + const TAGS: &[[u8; 4]] = &[ + *b"DIVX", *b"DX50", *b"XVID", *b"3IV2", *b"FVFW", *b"NDIG", *b"MP4V", *b"M4CC", *b"PVMM", + *b"SEDG", *b"RMP4", *b"MP43", *b"FMP4", *b"VP6F", + ]; + TAGS.contains(&tag.into_bytes()) +} + +fn avi_tag_maps_to_h264_annex_b(tag: FourCc) -> bool { + matches!( + tag.into_bytes(), + [b'H', b'2', b'6', b'4'] | [b'X', b'2', b'6', b'4'] + ) +} + +fn avi_tag_maps_to_h263(tag: FourCc) -> bool { + matches!( + tag.into_bytes(), + [b'H', b'2', b'6', b'3'] | [b'S', b'2', b'6', b'3'] + ) +} + +fn avi_tag_maps_to_jpeg(tag: FourCc) -> bool { + matches!( + tag.into_bytes(), + [b'M', b'J', b'P', b'G'] | [b'J', b'P', b'E', b'G'] + ) +} + +fn avi_tag_maps_to_png(tag: FourCc) -> bool { + matches!(tag.into_bytes(), [b'P', b'N', b'G', b' ']) +} + +fn read_avi_chunk_bytes_sync( + file: &mut File, + chunk: AviChunkSpan, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + if chunk.data_size == 0 { + return Err(invalid_avi(spec, "AVI chunk payload was empty")); + } + let mut bytes = vec![ + 0_u8; + usize::try_from(chunk.data_size) + .map_err(|_| MuxError::LayoutOverflow("AVI chunk size"))? + ]; + read_exact_at_sync(file, chunk.data_offset, &mut bytes, spec, truncated_message)?; + Ok(bytes) +} + +#[cfg(feature = "async")] +async fn read_avi_chunk_bytes_async( + file: &mut TokioFile, + chunk: AviChunkSpan, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + if chunk.data_size == 0 { + return Err(invalid_avi(spec, "AVI chunk payload was empty")); + } + let mut bytes = vec![ + 0_u8; + usize::try_from(chunk.data_size) + .map_err(|_| MuxError::LayoutOverflow("AVI chunk size"))? + ]; + read_exact_at_async(file, chunk.data_offset, &mut bytes, spec, truncated_message).await?; + Ok(bytes) +} + +fn parse_movi_chunks_sync( + file: &mut File, + start: u64, + end: u64, + spec: &str, + track_count: usize, + track_chunks: &mut [Vec], +) -> Result<(), MuxError> { + let mut ranges = vec![(start, end)]; + while let Some((range_start, range_end)) = ranges.pop() { + let mut offset = range_start; + while offset < range_end { + let (chunk_type, chunk_size, chunk_payload_offset, chunk_end) = + read_riff_chunk_header_sync(file, range_end, offset, spec)?; + if chunk_type == LIST { + if chunk_size < 4 { + return Err(invalid_avi( + spec, + "AVI `movi` sub-list was truncated before its list type", + )); + } + let list_type = read_fourcc_sync( + file, + chunk_payload_offset, + spec, + "AVI `movi` sub-list type is truncated", + )?; + if list_type == RECL { + ranges.push((chunk_payload_offset + 4, chunk_end)); + } + } else if let Some(stream_index) = parse_stream_chunk_index(chunk_type) + && stream_index < track_count + { + track_chunks[stream_index].push(AviChunkSpan { + data_offset: chunk_payload_offset, + data_size: u32::try_from(chunk_size) + .map_err(|_| MuxError::LayoutOverflow("AVI chunk size"))?, + }); + } + offset = next_riff_offset(chunk_end); + } + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn parse_movi_chunks_async( + file: &mut TokioFile, + start: u64, + end: u64, + spec: &str, + track_count: usize, + track_chunks: &mut [Vec], +) -> Result<(), MuxError> { + let mut ranges = vec![(start, end)]; + while let Some((range_start, range_end)) = ranges.pop() { + let mut offset = range_start; + while offset < range_end { + let (chunk_type, chunk_size, chunk_payload_offset, chunk_end) = + read_riff_chunk_header_async(file, range_end, offset, spec).await?; + if chunk_type == LIST { + if chunk_size < 4 { + return Err(invalid_avi( + spec, + "AVI `movi` sub-list was truncated before its list type", + )); + } + let list_type = read_fourcc_async( + file, + chunk_payload_offset, + spec, + "AVI `movi` sub-list type is truncated", + ) + .await?; + if list_type == RECL { + ranges.push((chunk_payload_offset + 4, chunk_end)); + } + } else if let Some(stream_index) = parse_stream_chunk_index(chunk_type) + && stream_index < track_count + { + track_chunks[stream_index].push(AviChunkSpan { + data_offset: chunk_payload_offset, + data_size: u32::try_from(chunk_size) + .map_err(|_| MuxError::LayoutOverflow("AVI chunk size"))?, + }); + } + offset = next_riff_offset(chunk_end); + } + } + Ok(()) +} + +fn validate_avi_header_sync(file: &mut File, file_size: u64, spec: &str) -> Result<(), MuxError> { + if file_size < 12 { + return Err(invalid_avi( + spec, + "AVI input is truncated before the 12-byte RIFF header", + )); + } + let mut header = [0_u8; 12]; + read_exact_at_sync( + file, + 0, + &mut header, + spec, + "AVI input is truncated before the 12-byte RIFF header", + )?; + validate_avi_header_bytes(&header, file_size, spec) +} + +#[cfg(feature = "async")] +async fn validate_avi_header_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result<(), MuxError> { + if file_size < 12 { + return Err(invalid_avi( + spec, + "AVI input is truncated before the 12-byte RIFF header", + )); + } + let mut header = [0_u8; 12]; + read_exact_at_async( + file, + 0, + &mut header, + spec, + "AVI input is truncated before the 12-byte RIFF header", + ) + .await?; + validate_avi_header_bytes(&header, file_size, spec) +} + +fn validate_avi_header_bytes( + header: &[u8; 12], + file_size: u64, + spec: &str, +) -> Result<(), MuxError> { + if &header[..4] != RIFF { + return Err(invalid_avi( + spec, + "AVI input did not start with the `RIFF` signature", + )); + } + if FourCc::from_bytes(header[8..12].try_into().unwrap()) != AVI_FORM { + return Err(invalid_avi( + spec, + "AVI input did not carry the `AVI ` RIFF form type", + )); + } + let declared_size = u64::from(u32::from_le_bytes(header[4..8].try_into().unwrap())) + 8; + if declared_size > file_size { + return Err(invalid_avi( + spec, + &format!( + "AVI RIFF size field declared {declared_size} bytes but the file only contains {file_size}" + ), + )); + } + Ok(()) +} + +fn read_riff_chunk_header_sync( + file: &mut File, + file_end: u64, + offset: u64, + spec: &str, +) -> Result<(FourCc, u64, u64, u64), MuxError> { + if file_end - offset < 8 { + return Err(invalid_avi(spec, "AVI chunk header is truncated")); + } + let mut header = [0_u8; 8]; + read_exact_at_sync( + file, + offset, + &mut header, + spec, + "AVI chunk header is truncated", + )?; + decode_riff_chunk_header(offset, file_end, header, spec) +} + +#[cfg(feature = "async")] +async fn read_riff_chunk_header_async( + file: &mut TokioFile, + file_end: u64, + offset: u64, + spec: &str, +) -> Result<(FourCc, u64, u64, u64), MuxError> { + if file_end - offset < 8 { + return Err(invalid_avi(spec, "AVI chunk header is truncated")); + } + let mut header = [0_u8; 8]; + read_exact_at_async( + file, + offset, + &mut header, + spec, + "AVI chunk header is truncated", + ) + .await?; + decode_riff_chunk_header(offset, file_end, header, spec) +} + +fn decode_riff_chunk_header( + offset: u64, + file_end: u64, + header: [u8; 8], + spec: &str, +) -> Result<(FourCc, u64, u64, u64), MuxError> { + let chunk_type = FourCc::from_bytes(header[0..4].try_into().unwrap()); + let chunk_size = u64::from(u32::from_le_bytes(header[4..8].try_into().unwrap())); + let chunk_payload_offset = offset + 8; + let chunk_end = chunk_payload_offset + .checked_add(chunk_size) + .ok_or(MuxError::LayoutOverflow("AVI chunk range"))?; + if chunk_end > file_end { + return Err(invalid_avi( + spec, + &format!("AVI chunk `{chunk_type}` overruns the input length"), + )); + } + Ok((chunk_type, chunk_size, chunk_payload_offset, chunk_end)) +} + +fn read_fourcc_sync( + file: &mut File, + offset: u64, + spec: &str, + truncated_message: &'static str, +) -> Result { + let mut bytes = [0_u8; 4]; + read_exact_at_sync(file, offset, &mut bytes, spec, truncated_message)?; + Ok(FourCc::from_bytes(bytes)) +} + +#[cfg(feature = "async")] +async fn read_fourcc_async( + file: &mut TokioFile, + offset: u64, + spec: &str, + truncated_message: &'static str, +) -> Result { + let mut bytes = [0_u8; 4]; + read_exact_at_async(file, offset, &mut bytes, spec, truncated_message).await?; + Ok(FourCc::from_bytes(bytes)) +} + +fn next_riff_offset(chunk_end: u64) -> u64 { + chunk_end + (chunk_end & 1) +} + +fn parse_stream_chunk_index(chunk_type: FourCc) -> Option { + let bytes = chunk_type.into_bytes(); + if !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() { + return None; + } + Some(usize::from(bytes[0] - b'0') * 10 + usize::from(bytes[1] - b'0')) +} + +fn build_avi_segmented_source_spec( + path: &Path, + chunks: &[AviChunkSpan], +) -> Result { + let mut segments = Vec::with_capacity(chunks.len()); + let mut logical_offset = 0_u64; + for chunk in chunks { + segments.push(SegmentedMuxSourceSegment { + logical_offset, + data: super::super::import::SegmentedMuxSourceSegmentData::FileRange { + source_offset: chunk.data_offset, + size: chunk.data_size, + }, + }); + logical_offset = logical_offset + .checked_add(u64::from(chunk.data_size)) + .ok_or(MuxError::LayoutOverflow("AVI segmented logical size"))?; + } + Ok(SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments, + total_size: logical_offset, + }) +} + +fn candidate_samples_from_staged(samples: Vec) -> Vec { + samples + .into_iter() + .map(|sample| CandidateSample { + source_index: 0, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect() +} + +fn avi_mp4v_chunk_is_sync_sample( + spec: &str, + stream_index: u32, + bytes: &[u8], +) -> Result { + for offset in 0..bytes.len().saturating_sub(4) { + if bytes[offset..].starts_with(&[0x00, 0x00, 0x01, 0xB6]) { + let Some(header) = bytes.get(offset + 4) else { + return Err(invalid_avi( + spec, + &format!( + "AVI video stream {stream_index} carried a truncated MPEG-4 Part 2 VOP header" + ), + )); + }; + return Ok((header >> 6) == 0); + } + } + Err(invalid_avi( + spec, + &format!( + "AVI video stream {stream_index} did not expose one MPEG-4 Part 2 VOP start code in its chunk payload" + ), + )) +} + +fn invalid_avi(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} + +fn unsupported_avi_audio_format_tag_message( + stream_index: u32, + audio_format: AviAudioFormat, +) -> String { + format!( + "AVI audio stream {stream_index} uses unsupported WAVE format tag 0x{:04X} (channels={}, sample_rate={}, bits_per_sample={}, block_align={}); native direct-ingest currently accepts {SUPPORTED_AVI_AUDIO_TAGS}", + audio_format.format_tag, + audio_format.channel_count, + audio_format.sample_rate, + audio_format.bits_per_sample, + audio_format.block_align, + ) +} + +#[cfg(test)] +mod tests { + use super::avi_video_timing; + + #[test] + fn avi_video_timing_matches_expected_import_style_rates() { + let exact = avi_video_timing(1, 25); + assert_eq!(exact.timescale, 25_000); + assert_eq!(exact.sample_duration, 1_000); + + let ntsc = avi_video_timing(1_001, 30_000); + assert_eq!(ntsc.timescale, 30_000); + assert_eq!(ntsc.sample_duration, 1_001); + + let film = avi_video_timing(1_001, 24_000); + assert_eq!(film.timescale, 24_000); + assert_eq!(film.sample_duration, 1_001); + } +} diff --git a/src/mux/demux/avs3.rs b/src/mux/demux/avs3.rs new file mode 100644 index 0000000..75618ad --- /dev/null +++ b/src/mux/demux/avs3.rs @@ -0,0 +1,346 @@ +use std::fs::File; +use std::io::Cursor; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::bitio::BitReader; +use crate::boxes::avs3::Av3c; + +use super::super::MuxError; +use super::super::import::{ + SegmentedMuxSourceSegment, StagedSample, build_btrt_from_sample_sizes, + build_visual_sample_entry_box, +}; +use super::annexb_common::{ + read_bit_labeled, read_bits_u8_labeled, read_bits_u16_labeled, skip_bits_labeled, +}; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::read_segmented_bytes_sync; + +const SAMPLE_ENTRY_AVS3: FourCc = FourCc::from_bytes(*b"avs3"); +const AVS3_DESCRIPTOR_CONFIG_SIZE: usize = 10; +const AVS3_SEQUENCE_HEADER_PREFIX: [u8; 4] = [0x00, 0x00, 0x01, 0xB0]; +const AVS3_INTRA_PICTURE_PREFIX: [u8; 4] = [0x00, 0x00, 0x01, 0xB3]; +const AVS3_INTER_PICTURE_PREFIX: [u8; 4] = [0x00, 0x00, 0x01, 0xB6]; +const AVS3_PREFIX_SCAN_BYTES: usize = 128; + +pub(in crate::mux) struct ParsedTransportAvs3Track { + pub(in crate::mux) timescale: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +struct ParsedAvs3SequenceHeader { + timescale: u32, + sample_duration: u32, + low_delay: bool, +} + +pub(in crate::mux) fn scan_transport_avs3_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + sample_offsets: &[u64], + avs3_config: &[u8], + spec: &str, +) -> Result { + let config = parse_transport_avs3_config_bytes(avs3_config, spec)?; + let sample_bounds = build_transport_avs3_sample_bounds(sample_offsets, total_size, spec)?; + let prefix_cap = + usize::try_from(total_size.min(u64::try_from(AVS3_PREFIX_SCAN_BYTES).unwrap())) + .map_err(|_| MuxError::LayoutOverflow("transport-stream AVS3 prefix read length"))?; + let mut sequence_header = None::; + let mut samples = Vec::with_capacity(sample_bounds.len()); + + for (sample_offset, data_size) in sample_bounds { + let prefix_len = prefix_cap.min(usize::try_from(data_size).unwrap_or(prefix_cap)); + let mut prefix = vec![0_u8; prefix_len]; + read_segmented_bytes_sync( + file, + segments, + total_size, + sample_offset, + &mut prefix, + spec, + "transport-stream AVS3 sample prefix is truncated", + )?; + if sequence_header.is_none() + && let Some(sequence_start) = + find_prefixed_start_code(&prefix, AVS3_SEQUENCE_HEADER_PREFIX) + { + sequence_header = Some(parse_avs3_sequence_header(spec, &prefix[sequence_start..])?); + } + samples.push(StagedSample { + data_offset: sample_offset, + data_size, + duration: 0, + composition_time_offset: 0, + is_sync_sample: classify_transport_avs3_sample(spec, &prefix)?, + }); + } + + finalize_transport_avs3_track(spec, config, sequence_header, samples) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_transport_avs3_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + sample_offsets: &[u64], + avs3_config: &[u8], + spec: &str, +) -> Result { + let config = parse_transport_avs3_config_bytes(avs3_config, spec)?; + let sample_bounds = build_transport_avs3_sample_bounds(sample_offsets, total_size, spec)?; + let prefix_cap = + usize::try_from(total_size.min(u64::try_from(AVS3_PREFIX_SCAN_BYTES).unwrap())) + .map_err(|_| MuxError::LayoutOverflow("transport-stream AVS3 prefix read length"))?; + let mut sequence_header = None::; + let mut samples = Vec::with_capacity(sample_bounds.len()); + + for (sample_offset, data_size) in sample_bounds { + let prefix_len = prefix_cap.min(usize::try_from(data_size).unwrap_or(prefix_cap)); + let mut prefix = vec![0_u8; prefix_len]; + read_segmented_bytes_async( + file, + segments, + total_size, + sample_offset, + &mut prefix, + spec, + "transport-stream AVS3 sample prefix is truncated", + ) + .await?; + if sequence_header.is_none() + && let Some(sequence_start) = + find_prefixed_start_code(&prefix, AVS3_SEQUENCE_HEADER_PREFIX) + { + sequence_header = Some(parse_avs3_sequence_header(spec, &prefix[sequence_start..])?); + } + samples.push(StagedSample { + data_offset: sample_offset, + data_size, + duration: 0, + composition_time_offset: 0, + is_sync_sample: classify_transport_avs3_sample(spec, &prefix)?, + }); + } + + finalize_transport_avs3_track(spec, config, sequence_header, samples) +} + +fn finalize_transport_avs3_track( + spec: &str, + config: Av3c, + sequence_header: Option, + mut samples: Vec, +) -> Result { + let sequence_header = sequence_header.ok_or(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AVS3 carriage did not expose a sequence header on the native direct-ingest path" + .to_string(), + })?; + if !sequence_header.low_delay { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AVS3 carriage with reordered presentation is not supported on the native direct-ingest path yet" + .to_string(), + }); + } + for sample in &mut samples { + sample.duration = sequence_header.sample_duration; + } + let config_box = super::super::mp4::encode_typed_box(&config, &[])?; + let btrt_box = super::super::mp4::encode_typed_box( + &build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + sequence_header.timescale, + )?, + &[], + )?; + Ok(ParsedTransportAvs3Track { + timescale: sequence_header.timescale, + sample_entry_box: build_visual_sample_entry_box( + SAMPLE_ENTRY_AVS3, + 0, + 0, + &[config_box, btrt_box], + )?, + samples, + }) +} + +fn build_transport_avs3_sample_bounds( + sample_offsets: &[u64], + total_size: u64, + spec: &str, +) -> Result, MuxError> { + if sample_offsets.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream AVS3 carriage did not contain any PES payload units" + .to_string(), + }); + } + let mut bounds = Vec::with_capacity(sample_offsets.len()); + for (index, &sample_offset) in sample_offsets.iter().enumerate() { + let next_offset = sample_offsets.get(index + 1).copied().unwrap_or(total_size); + if next_offset <= sample_offset { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream AVS3 PES payload units must advance monotonically" + .to_string(), + }); + } + bounds.push(( + sample_offset, + u32::try_from(next_offset - sample_offset) + .map_err(|_| MuxError::LayoutOverflow("transport-stream AVS3 sample size"))?, + )); + } + Ok(bounds) +} + +fn parse_transport_avs3_config_bytes(config_bytes: &[u8], spec: &str) -> Result { + if config_bytes.len() != AVS3_DESCRIPTOR_CONFIG_SIZE { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AVS3 registration descriptor did not carry the expected 10-byte decoder configuration payload" + .to_string(), + }); + } + let sequence_header_length = u16::from_be_bytes([config_bytes[1], config_bytes[2]]); + let expected_size = + usize::from(sequence_header_length) + .checked_add(4) + .ok_or(MuxError::LayoutOverflow( + "transport-stream AVS3 decoder config size", + ))?; + if expected_size != config_bytes.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AVS3 decoder configuration length does not match its carried payload" + .to_string(), + }); + } + let sequence_header_end = 3 + usize::from(sequence_header_length); + let sequence_header = config_bytes[3..sequence_header_end].to_vec(); + if !sequence_header.starts_with(&AVS3_SEQUENCE_HEADER_PREFIX) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AVS3 decoder configuration did not begin with a sequence-header prefix" + .to_string(), + }); + } + Ok(Av3c { + configuration_version: config_bytes[0], + sequence_header_length, + sequence_header, + library_dependency_idc: config_bytes[config_bytes.len() - 1] & 0x03, + }) +} + +fn parse_avs3_sequence_header( + spec: &str, + bytes: &[u8], +) -> Result { + if bytes.len() < 17 || !bytes.starts_with(&AVS3_SEQUENCE_HEADER_PREFIX) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream AVS3 sequence header is truncated".to_string(), + }); + } + let mut reader = BitReader::new(Cursor::new(&bytes[4..])); + let _profile = read_bits_u8_labeled(&mut reader, 8, spec, "transport AVS3 profile")?; + let _level = read_bits_u8_labeled(&mut reader, 8, spec, "transport AVS3 level")?; + let _progressive = read_bit_labeled(&mut reader, spec, "transport AVS3 progressive flag")?; + let _field = read_bit_labeled(&mut reader, spec, "transport AVS3 field flag")?; + let _library_stream = + read_bits_u8_labeled(&mut reader, 2, spec, "transport AVS3 library-stream flag")?; + skip_bits_labeled(&mut reader, 1, spec, "transport AVS3 reserved bit")?; + let width = read_bits_u16_labeled(&mut reader, 14, spec, "transport AVS3 width")?; + skip_bits_labeled(&mut reader, 1, spec, "transport AVS3 reserved width bit")?; + let height = read_bits_u16_labeled(&mut reader, 14, spec, "transport AVS3 height")?; + skip_bits_labeled(&mut reader, 2, spec, "transport AVS3 chroma format")?; + skip_bits_labeled(&mut reader, 3, spec, "transport AVS3 sample precision")?; + skip_bits_labeled(&mut reader, 1, spec, "transport AVS3 reserved aspect bit")?; + skip_bits_labeled(&mut reader, 4, spec, "transport AVS3 aspect ratio")?; + let frame_rate_code = + read_bits_u8_labeled(&mut reader, 4, spec, "transport AVS3 frame-rate code")?; + skip_bits_labeled(&mut reader, 1, spec, "transport AVS3 reserved bitrate bit")?; + skip_bits_labeled(&mut reader, 18, spec, "transport AVS3 bitrate low bits")?; + skip_bits_labeled( + &mut reader, + 1, + spec, + "transport AVS3 reserved high-bitrate bit", + )?; + skip_bits_labeled(&mut reader, 12, spec, "transport AVS3 bitrate high bits")?; + let low_delay = read_bit_labeled(&mut reader, spec, "transport AVS3 low-delay flag")?; + if width == 0 || height == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream AVS3 sequence header declared a zero coded picture size" + .to_string(), + }); + } + let (timescale, sample_duration) = avs3_frame_rate_from_code(frame_rate_code, spec)?; + Ok(ParsedAvs3SequenceHeader { + timescale, + sample_duration, + low_delay, + }) +} + +fn avs3_frame_rate_from_code(frame_rate_code: u8, spec: &str) -> Result<(u32, u32), MuxError> { + match frame_rate_code { + 0x01 => Ok((24_000, 1_001)), + 0x02 => Ok((24, 1)), + 0x03 => Ok((25, 1)), + 0x04 => Ok((30_000, 1_001)), + 0x05 => Ok((30, 1)), + 0x06 => Ok((50, 1)), + 0x07 => Ok((60_000, 1_001)), + 0x08 => Ok((60, 1)), + 0x09 => Ok((100, 1)), + 0x0A => Ok((120, 1)), + 0x0B => Ok((200, 1)), + 0x0C => Ok((240, 1)), + 0x0D => Ok((300, 1)), + _ => Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "transport-stream AVS3 sequence header used unsupported frame-rate code 0x{frame_rate_code:02X}" + ), + }), + } +} + +fn classify_transport_avs3_sample(spec: &str, bytes: &[u8]) -> Result { + if find_prefixed_start_code(bytes, AVS3_INTRA_PICTURE_PREFIX).is_some() { + return Ok(true); + } + if find_prefixed_start_code(bytes, AVS3_INTER_PICTURE_PREFIX).is_some() { + return Ok(false); + } + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream AVS3 sample did not contain a supported picture-start prefix" + .to_string(), + }) +} + +fn find_prefixed_start_code(bytes: &[u8], needle: [u8; 4]) -> Option { + bytes.windows(4).position(|window| window == needle) +} diff --git a/src/mux/demux/bmp.rs b/src/mux/demux/bmp.rs new file mode 100644 index 0000000..37ee37a --- /dev/null +++ b/src/mux/demux/bmp.rs @@ -0,0 +1,381 @@ +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +use super::super::MuxError; +use super::super::import::{ + SegmentedMuxSourceSegment, SegmentedMuxSourceSegmentData, SegmentedMuxSourceSpec, +}; +use super::raw_visual::{UncvPixelLayout, build_uncv_sample_entry_box}; + +pub(in crate::mux) struct ParsedBmpTrack { + pub(in crate::mux) width: u16, + pub(in crate::mux) height: u16, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) segmented_source: SegmentedMuxSourceSpec, +} + +pub(in crate::mux) fn scan_bmp_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + parse_bmp_file_sync(path, spec, &mut file, file_size) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_bmp_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + parse_bmp_file_async(path, spec, &mut file, file_size).await +} + +fn parse_bmp_file_sync( + path: &Path, + spec: &str, + file: &mut File, + file_size: u64, +) -> Result { + if file_size < 54 { + return Err(invalid_bmp( + spec, + "BMP input is truncated before the 54-byte file and info headers", + )); + } + let mut header = [0_u8; 54]; + file.seek(SeekFrom::Start(0))?; + file.read_exact(&mut header)?; + if &header[..2] != b"BM" { + return Err(invalid_bmp( + spec, + "input does not start with the BMP file signature", + )); + } + + let data_offset = usize::try_from(read_le_u32(&header, 10, spec, "pixel data offset")?) + .map_err(|_| MuxError::LayoutOverflow("BMP data offset"))?; + let dib_header_size = read_le_u32(&header, 14, spec, "DIB header size")?; + if dib_header_size < 40 { + return Err(invalid_bmp( + spec, + "BMP DIB header is smaller than the required 40-byte BITMAPINFOHEADER layout", + )); + } + + let width_signed = read_le_i32(&header, 18, spec, "width")?; + let height_signed = read_le_i32(&header, 22, spec, "height")?; + if width_signed <= 0 || height_signed == 0 { + return Err(invalid_bmp( + spec, + "BMP header declared a non-positive width or a zero height", + )); + } + let top_down = height_signed < 0; + let width_abs = u32::try_from(width_signed) + .map_err(|_| invalid_bmp(spec, "BMP width does not fit in a positive 32-bit size"))?; + let height_abs = height_signed.unsigned_abs(); + let width = u16::try_from(width_abs) + .map_err(|_| invalid_bmp(spec, "BMP width does not fit in an MP4 visual sample entry"))?; + let height = u16::try_from(height_abs).map_err(|_| { + invalid_bmp( + spec, + "BMP height does not fit in an MP4 visual sample entry", + ) + })?; + + let planes = read_le_u16(&header, 26, spec, "plane count")?; + if planes != 1 { + return Err(invalid_bmp( + spec, + "BMP input declared a plane count other than one", + )); + } + let bits_per_pixel = read_le_u16(&header, 28, spec, "bits per pixel")?; + let compression = read_le_u32(&header, 30, spec, "compression")?; + if compression != 0 { + return Err(invalid_bmp( + spec, + "BMP input used a compressed pixel layout; only BI_RGB carriage is supported", + )); + } + + let (layout, bytes_per_pixel) = match bits_per_pixel { + 24 => (UncvPixelLayout::Rgb24, 3_u64), + 32 => (UncvPixelLayout::Rgbx32, 4_u64), + _ => { + return Err(invalid_bmp( + spec, + "BMP input declared an unsupported bit depth; only 24-bit and 32-bit BI_RGB carriage is supported", + )); + } + }; + + let row_stride = (u64::from(width_abs) * u64::from(bits_per_pixel)).div_ceil(32) * 4; + let data_end = u64::try_from(data_offset) + .unwrap() + .checked_add( + row_stride + .checked_mul(u64::from(height_abs)) + .ok_or(MuxError::LayoutOverflow("BMP pixel payload size"))?, + ) + .ok_or(MuxError::LayoutOverflow("BMP pixel payload range"))?; + if data_end > file_size { + return Err(invalid_bmp( + spec, + "BMP pixel payload overruns the input length", + )); + } + + let transformed_len = u64::from(width_abs) + .checked_mul(u64::from(height_abs)) + .and_then(|value| value.checked_mul(bytes_per_pixel)) + .ok_or(MuxError::LayoutOverflow("BMP transformed payload size"))?; + let transformed_capacity = usize::try_from(transformed_len) + .map_err(|_| MuxError::LayoutOverflow("BMP transformed payload size"))?; + let mut transformed = Vec::with_capacity(transformed_capacity); + let row_stride_usize = + usize::try_from(row_stride).map_err(|_| MuxError::LayoutOverflow("BMP row stride"))?; + let mut row = vec![0_u8; row_stride_usize]; + for output_row in 0..height_abs { + let source_row = if top_down { + output_row + } else { + height_abs - 1 - output_row + }; + let row_start = u64::try_from(data_offset) + .unwrap() + .checked_add(u64::from(source_row) * row_stride) + .ok_or(MuxError::LayoutOverflow("BMP row offset"))?; + file.seek(SeekFrom::Start(row_start))?; + file.read_exact(&mut row)?; + for column in 0..width_abs { + let pixel_offset = usize::try_from(u64::from(column) * bytes_per_pixel) + .map_err(|_| MuxError::LayoutOverflow("BMP pixel offset"))?; + match bits_per_pixel { + 24 => { + transformed.push(row[pixel_offset + 2]); + transformed.push(row[pixel_offset + 1]); + transformed.push(row[pixel_offset]); + } + 32 => { + transformed.push(row[pixel_offset + 2]); + transformed.push(row[pixel_offset + 1]); + transformed.push(row[pixel_offset]); + transformed.push(row[pixel_offset + 3]); + } + _ => unreachable!(), + } + } + } + + let sample_entry_box = build_uncv_sample_entry_box(width, height, layout, false, false)?; + let total_size = u64::try_from(transformed.len()) + .map_err(|_| MuxError::LayoutOverflow("BMP transformed payload size"))?; + Ok(ParsedBmpTrack { + width, + height, + sample_entry_box, + segmented_source: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: vec![SegmentedMuxSourceSegment { + logical_offset: 0, + data: SegmentedMuxSourceSegmentData::Bytes(transformed), + }], + total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn parse_bmp_file_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + file_size: u64, +) -> Result { + if file_size < 54 { + return Err(invalid_bmp( + spec, + "BMP input is truncated before the 54-byte file and info headers", + )); + } + let mut header = [0_u8; 54]; + file.seek(SeekFrom::Start(0)).await?; + file.read_exact(&mut header).await?; + if &header[..2] != b"BM" { + return Err(invalid_bmp( + spec, + "input does not start with the BMP file signature", + )); + } + + let data_offset = usize::try_from(read_le_u32(&header, 10, spec, "pixel data offset")?) + .map_err(|_| MuxError::LayoutOverflow("BMP data offset"))?; + let dib_header_size = read_le_u32(&header, 14, spec, "DIB header size")?; + if dib_header_size < 40 { + return Err(invalid_bmp( + spec, + "BMP DIB header is smaller than the required 40-byte BITMAPINFOHEADER layout", + )); + } + + let width_signed = read_le_i32(&header, 18, spec, "width")?; + let height_signed = read_le_i32(&header, 22, spec, "height")?; + if width_signed <= 0 || height_signed == 0 { + return Err(invalid_bmp( + spec, + "BMP header declared a non-positive width or a zero height", + )); + } + let top_down = height_signed < 0; + let width_abs = u32::try_from(width_signed) + .map_err(|_| invalid_bmp(spec, "BMP width does not fit in a positive 32-bit size"))?; + let height_abs = height_signed.unsigned_abs(); + let width = u16::try_from(width_abs) + .map_err(|_| invalid_bmp(spec, "BMP width does not fit in an MP4 visual sample entry"))?; + let height = u16::try_from(height_abs).map_err(|_| { + invalid_bmp( + spec, + "BMP height does not fit in an MP4 visual sample entry", + ) + })?; + + let planes = read_le_u16(&header, 26, spec, "plane count")?; + if planes != 1 { + return Err(invalid_bmp( + spec, + "BMP input declared a plane count other than one", + )); + } + let bits_per_pixel = read_le_u16(&header, 28, spec, "bits per pixel")?; + let compression = read_le_u32(&header, 30, spec, "compression")?; + if compression != 0 { + return Err(invalid_bmp( + spec, + "BMP input used a compressed pixel layout; only BI_RGB carriage is supported", + )); + } + + let (layout, bytes_per_pixel) = match bits_per_pixel { + 24 => (UncvPixelLayout::Rgb24, 3_u64), + 32 => (UncvPixelLayout::Rgbx32, 4_u64), + _ => { + return Err(invalid_bmp( + spec, + "BMP input declared an unsupported bit depth; only 24-bit and 32-bit BI_RGB carriage is supported", + )); + } + }; + + let row_stride = (u64::from(width_abs) * u64::from(bits_per_pixel)).div_ceil(32) * 4; + let data_end = u64::try_from(data_offset) + .unwrap() + .checked_add( + row_stride + .checked_mul(u64::from(height_abs)) + .ok_or(MuxError::LayoutOverflow("BMP pixel payload size"))?, + ) + .ok_or(MuxError::LayoutOverflow("BMP pixel payload range"))?; + if data_end > file_size { + return Err(invalid_bmp( + spec, + "BMP pixel payload overruns the input length", + )); + } + + let transformed_len = u64::from(width_abs) + .checked_mul(u64::from(height_abs)) + .and_then(|value| value.checked_mul(bytes_per_pixel)) + .ok_or(MuxError::LayoutOverflow("BMP transformed payload size"))?; + let transformed_capacity = usize::try_from(transformed_len) + .map_err(|_| MuxError::LayoutOverflow("BMP transformed payload size"))?; + let mut transformed = Vec::with_capacity(transformed_capacity); + let row_stride_usize = + usize::try_from(row_stride).map_err(|_| MuxError::LayoutOverflow("BMP row stride"))?; + let mut row = vec![0_u8; row_stride_usize]; + for output_row in 0..height_abs { + let source_row = if top_down { + output_row + } else { + height_abs - 1 - output_row + }; + let row_start = u64::try_from(data_offset) + .unwrap() + .checked_add(u64::from(source_row) * row_stride) + .ok_or(MuxError::LayoutOverflow("BMP row offset"))?; + file.seek(SeekFrom::Start(row_start)).await?; + file.read_exact(&mut row).await?; + for column in 0..width_abs { + let pixel_offset = usize::try_from(u64::from(column) * bytes_per_pixel) + .map_err(|_| MuxError::LayoutOverflow("BMP pixel offset"))?; + match bits_per_pixel { + 24 => { + transformed.push(row[pixel_offset + 2]); + transformed.push(row[pixel_offset + 1]); + transformed.push(row[pixel_offset]); + } + 32 => { + transformed.push(row[pixel_offset + 2]); + transformed.push(row[pixel_offset + 1]); + transformed.push(row[pixel_offset]); + transformed.push(row[pixel_offset + 3]); + } + _ => unreachable!(), + } + } + } + + let sample_entry_box = build_uncv_sample_entry_box(width, height, layout, false, false)?; + let total_size = u64::try_from(transformed.len()) + .map_err(|_| MuxError::LayoutOverflow("BMP transformed payload size"))?; + Ok(ParsedBmpTrack { + width, + height, + sample_entry_box, + segmented_source: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: vec![SegmentedMuxSourceSegment { + logical_offset: 0, + data: SegmentedMuxSourceSegmentData::Bytes(transformed), + }], + total_size, + }, + }) +} + +fn read_le_u16(bytes: &[u8], offset: usize, spec: &str, field: &str) -> Result { + let slice = bytes + .get(offset..offset + 2) + .ok_or_else(|| invalid_bmp(spec, &format!("BMP header is truncated before the {field}")))?; + Ok(u16::from_le_bytes(slice.try_into().unwrap())) +} + +fn read_le_u32(bytes: &[u8], offset: usize, spec: &str, field: &str) -> Result { + let slice = bytes + .get(offset..offset + 4) + .ok_or_else(|| invalid_bmp(spec, &format!("BMP header is truncated before the {field}")))?; + Ok(u32::from_le_bytes(slice.try_into().unwrap())) +} + +fn read_le_i32(bytes: &[u8], offset: usize, spec: &str, field: &str) -> Result { + let slice = bytes + .get(offset..offset + 4) + .ok_or_else(|| invalid_bmp(spec, &format!("BMP header is truncated before the {field}")))?; + Ok(i32::from_le_bytes(slice.try_into().unwrap())) +} + +fn invalid_bmp(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} diff --git a/src/mux/demux/caf_common.rs b/src/mux/demux/caf_common.rs new file mode 100644 index 0000000..582dc70 --- /dev/null +++ b/src/mux/demux/caf_common.rs @@ -0,0 +1,215 @@ +use std::fs::File; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; + +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::read_exact_at_sync; +use super::super::{MuxError, MuxRawCodec}; +use super::detect::DetectedPathTrackKind; + +pub(super) struct CafDetectionDescription { + pub(super) format_id: FourCc, +} + +pub(in crate::mux) fn detect_caf_track_kind_sync( + file: &mut File, +) -> Result { + detect_caf_track_kind_with_reader_sync(file, "CAF path detection") +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn detect_caf_track_kind_async( + file: &mut TokioFile, +) -> Result { + detect_caf_track_kind_with_reader_async(file, "CAF path detection").await +} + +fn detect_caf_track_kind_with_reader_sync( + file: &mut File, + spec: &str, +) -> Result { + let description = read_caf_description_sync(file, spec)?; + if description.format_id == FourCc::from_bytes(*b"alac") { + Ok(DetectedPathTrackKind::Raw(MuxRawCodec::Alac)) + } else { + Ok(DetectedPathTrackKind::Unknown) + } +} + +#[cfg(feature = "async")] +async fn detect_caf_track_kind_with_reader_async( + file: &mut TokioFile, + spec: &str, +) -> Result { + let description = read_caf_description_async(file, spec).await?; + if description.format_id == FourCc::from_bytes(*b"alac") { + Ok(DetectedPathTrackKind::Raw(MuxRawCodec::Alac)) + } else { + Ok(DetectedPathTrackKind::Unknown) + } +} + +pub(super) fn read_caf_description_sync( + file: &mut File, + spec: &str, +) -> Result { + let file_size = file.metadata()?.len(); + if file_size < 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF input is truncated before the 8-byte file header".to_string(), + }); + } + let mut header = [0_u8; 8]; + read_exact_at_sync( + file, + 0, + &mut header, + spec, + "CAF input is truncated before the 8-byte file header", + )?; + if &header[..4] != b"caff" { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF input did not start with the `caff` signature".to_string(), + }); + } + let mut offset = 8_u64; + while offset < file_size { + let (chunk_type, chunk_size) = read_caf_chunk_header_sync(file, offset, spec)?; + let chunk_data_offset = offset + 12; + if chunk_type == FourCc::from_bytes(*b"desc") { + if chunk_size < 12 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF `desc` chunk is too short to include a format identifier" + .to_string(), + }); + } + let mut desc = [0_u8; 12]; + read_exact_at_sync( + file, + chunk_data_offset, + &mut desc, + spec, + "CAF `desc` chunk is truncated", + )?; + return Ok(CafDetectionDescription { + format_id: FourCc::from_bytes(desc[8..12].try_into().unwrap()), + }); + } + offset = chunk_data_offset + .checked_add(chunk_size) + .ok_or(MuxError::LayoutOverflow("CAF chunk range"))?; + } + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF input did not contain a required `desc` chunk".to_string(), + }) +} + +#[cfg(feature = "async")] +pub(super) async fn read_caf_description_async( + file: &mut TokioFile, + spec: &str, +) -> Result { + let file_size = file.metadata().await?.len(); + if file_size < 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF input is truncated before the 8-byte file header".to_string(), + }); + } + let mut header = [0_u8; 8]; + read_exact_at_async( + file, + 0, + &mut header, + spec, + "CAF input is truncated before the 8-byte file header", + ) + .await?; + if &header[..4] != b"caff" { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF input did not start with the `caff` signature".to_string(), + }); + } + let mut offset = 8_u64; + while offset < file_size { + let (chunk_type, chunk_size) = read_caf_chunk_header_async(file, offset, spec).await?; + let chunk_data_offset = offset + 12; + if chunk_type == FourCc::from_bytes(*b"desc") { + if chunk_size < 12 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF `desc` chunk is too short to include a format identifier" + .to_string(), + }); + } + let mut desc = [0_u8; 12]; + read_exact_at_async( + file, + chunk_data_offset, + &mut desc, + spec, + "CAF `desc` chunk is truncated", + ) + .await?; + return Ok(CafDetectionDescription { + format_id: FourCc::from_bytes(desc[8..12].try_into().unwrap()), + }); + } + offset = chunk_data_offset + .checked_add(chunk_size) + .ok_or(MuxError::LayoutOverflow("CAF chunk range"))?; + } + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "CAF input did not contain a required `desc` chunk".to_string(), + }) +} + +pub(super) fn read_caf_chunk_header_sync( + file: &mut File, + offset: u64, + spec: &str, +) -> Result<(FourCc, u64), MuxError> { + let mut header = [0_u8; 12]; + read_exact_at_sync( + file, + offset, + &mut header, + spec, + "CAF chunk header is truncated before 12 bytes", + )?; + Ok(( + FourCc::from_bytes(header[..4].try_into().unwrap()), + u64::from_be_bytes(header[4..12].try_into().unwrap()), + )) +} + +#[cfg(feature = "async")] +pub(super) async fn read_caf_chunk_header_async( + file: &mut TokioFile, + offset: u64, + spec: &str, +) -> Result<(FourCc, u64), MuxError> { + let mut header = [0_u8; 12]; + read_exact_at_async( + file, + offset, + &mut header, + spec, + "CAF chunk header is truncated before 12 bytes", + ) + .await?; + Ok(( + FourCc::from_bytes(header[..4].try_into().unwrap()), + u64::from_be_bytes(header[4..12].try_into().unwrap()), + )) +} diff --git a/src/mux/demux/container_common.rs b/src/mux/demux/container_common.rs new file mode 100644 index 0000000..847ef8c --- /dev/null +++ b/src/mux/demux/container_common.rs @@ -0,0 +1,275 @@ +use std::fs::File; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{ + SegmentedMuxSourceSegment, SegmentedMuxSourceSegmentData, read_exact_at_sync, +}; + +fn segment_logical_end(segment: &SegmentedMuxSourceSegment) -> u64 { + segment.logical_offset + + match &segment.data { + SegmentedMuxSourceSegmentData::Prefix(_) => 4, + SegmentedMuxSourceSegmentData::Bytes(bytes) => u64::try_from(bytes.len()).unwrap(), + SegmentedMuxSourceSegmentData::FileRange { size, .. } => u64::from(*size), + SegmentedMuxSourceSegmentData::ExternalFileRange { size, .. } => u64::from(*size), + } +} + +pub(in crate::mux) fn append_file_range_segment( + segments: &mut Vec, + logical_size: &mut u64, + source_offset: u64, + size: u32, +) { + if size == 0 { + return; + } + if let Some(previous) = segments.last_mut() { + let previous_end = segment_logical_end(previous); + if previous_end == *logical_size + && let SegmentedMuxSourceSegmentData::FileRange { + source_offset: previous_source_offset, + size: previous_size, + } = &mut previous.data + && *previous_source_offset + u64::from(*previous_size) == source_offset + { + *previous_size = previous_size.checked_add(size).unwrap(); + *logical_size += u64::from(size); + return; + } + } + segments.push(SegmentedMuxSourceSegment { + logical_offset: *logical_size, + data: SegmentedMuxSourceSegmentData::FileRange { + source_offset, + size, + }, + }); + *logical_size += u64::from(size); +} + +pub(in crate::mux) fn read_segmented_bytes_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + offset: u64, + buf: &mut [u8], + spec: &str, + truncated_message: &'static str, +) -> Result<(), MuxError> { + if offset + .checked_add(u64::try_from(buf.len()).unwrap_or(u64::MAX)) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: truncated_message.to_string(), + }); + } + + let mut written = 0usize; + let mut logical_offset = offset; + for segment in segments { + if written == buf.len() { + break; + } + if segment_logical_end(segment) <= logical_offset || segment.logical_offset > logical_offset + { + if segment.logical_offset > logical_offset { + break; + } + continue; + } + let segment_offset = usize::try_from(logical_offset - segment.logical_offset) + .map_err(|_| MuxError::LayoutOverflow("segmented logical offset"))?; + match &segment.data { + SegmentedMuxSourceSegmentData::Prefix(prefix) => { + let available = prefix.len().saturating_sub(segment_offset); + let to_copy = available.min(buf.len() - written); + buf[written..written + to_copy] + .copy_from_slice(&prefix[segment_offset..segment_offset + to_copy]); + written += to_copy; + logical_offset += u64::try_from(to_copy).unwrap(); + } + SegmentedMuxSourceSegmentData::Bytes(bytes) => { + let available = bytes.len().saturating_sub(segment_offset); + let to_copy = available.min(buf.len() - written); + buf[written..written + to_copy] + .copy_from_slice(&bytes[segment_offset..segment_offset + to_copy]); + written += to_copy; + logical_offset += u64::try_from(to_copy).unwrap(); + } + SegmentedMuxSourceSegmentData::FileRange { + source_offset, + size, + } => { + let available = + usize::try_from(u64::from(*size) - u64::try_from(segment_offset).unwrap()) + .map_err(|_| MuxError::LayoutOverflow("segmented file range"))?; + let to_copy = available.min(buf.len() - written); + read_exact_at_sync( + file, + source_offset + u64::try_from(segment_offset).unwrap(), + &mut buf[written..written + to_copy], + spec, + truncated_message, + )?; + written += to_copy; + logical_offset += u64::try_from(to_copy).unwrap(); + } + SegmentedMuxSourceSegmentData::ExternalFileRange { + path, + source_offset, + size, + } => { + let available = + usize::try_from(u64::from(*size) - u64::try_from(segment_offset).unwrap()) + .map_err(|_| MuxError::LayoutOverflow("segmented file range"))?; + let to_copy = available.min(buf.len() - written); + let mut external = File::open(path).map_err(|error| { + MuxError::Io(std::io::Error::new( + error.kind(), + format!( + "failed to open segmented mux source `{}`: {error}", + path.display() + ), + )) + })?; + read_exact_at_sync( + &mut external, + source_offset + u64::try_from(segment_offset).unwrap(), + &mut buf[written..written + to_copy], + spec, + truncated_message, + )?; + written += to_copy; + logical_offset += u64::try_from(to_copy).unwrap(); + } + } + } + + if written != buf.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: truncated_message.to_string(), + }); + } + Ok(()) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn read_segmented_bytes_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + offset: u64, + buf: &mut [u8], + spec: &str, + truncated_message: &'static str, +) -> Result<(), MuxError> { + if offset + .checked_add(u64::try_from(buf.len()).unwrap_or(u64::MAX)) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: truncated_message.to_string(), + }); + } + + let mut written = 0usize; + let mut logical_offset = offset; + for segment in segments { + if written == buf.len() { + break; + } + if segment_logical_end(segment) <= logical_offset || segment.logical_offset > logical_offset + { + if segment.logical_offset > logical_offset { + break; + } + continue; + } + let segment_offset = usize::try_from(logical_offset - segment.logical_offset) + .map_err(|_| MuxError::LayoutOverflow("segmented logical offset"))?; + match &segment.data { + SegmentedMuxSourceSegmentData::Prefix(prefix) => { + let available = prefix.len().saturating_sub(segment_offset); + let to_copy = available.min(buf.len() - written); + buf[written..written + to_copy] + .copy_from_slice(&prefix[segment_offset..segment_offset + to_copy]); + written += to_copy; + logical_offset += u64::try_from(to_copy).unwrap(); + } + SegmentedMuxSourceSegmentData::Bytes(bytes) => { + let available = bytes.len().saturating_sub(segment_offset); + let to_copy = available.min(buf.len() - written); + buf[written..written + to_copy] + .copy_from_slice(&bytes[segment_offset..segment_offset + to_copy]); + written += to_copy; + logical_offset += u64::try_from(to_copy).unwrap(); + } + SegmentedMuxSourceSegmentData::FileRange { + source_offset, + size, + } => { + let available = + usize::try_from(u64::from(*size) - u64::try_from(segment_offset).unwrap()) + .map_err(|_| MuxError::LayoutOverflow("segmented file range"))?; + let to_copy = available.min(buf.len() - written); + read_exact_at_async( + file, + source_offset + u64::try_from(segment_offset).unwrap(), + &mut buf[written..written + to_copy], + spec, + truncated_message, + ) + .await?; + written += to_copy; + logical_offset += u64::try_from(to_copy).unwrap(); + } + SegmentedMuxSourceSegmentData::ExternalFileRange { + path, + source_offset, + size, + } => { + let available = + usize::try_from(u64::from(*size) - u64::try_from(segment_offset).unwrap()) + .map_err(|_| MuxError::LayoutOverflow("segmented file range"))?; + let to_copy = available.min(buf.len() - written); + let mut external = TokioFile::open(path).await.map_err(|error| { + MuxError::Io(std::io::Error::new( + error.kind(), + format!( + "failed to open segmented mux source `{}`: {error}", + path.display() + ), + )) + })?; + read_exact_at_async( + &mut external, + source_offset + u64::try_from(segment_offset).unwrap(), + &mut buf[written..written + to_copy], + spec, + truncated_message, + ) + .await?; + written += to_copy; + logical_offset += u64::try_from(to_copy).unwrap(); + } + } + } + + if written != buf.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: truncated_message.to_string(), + }); + } + Ok(()) +} diff --git a/src/mux/demux/dash.rs b/src/mux/demux/dash.rs new file mode 100644 index 0000000..aee8fc7 --- /dev/null +++ b/src/mux/demux/dash.rs @@ -0,0 +1,1621 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +#[cfg(feature = "async")] +use tokio::fs as tokio_fs; +#[cfg(feature = "async")] +use tokio::io::{AsyncBufReadExt, BufReader as AsyncBufReader}; + +use super::super::MuxError; +use super::super::import::{ + SegmentedMuxSourceSegment, SegmentedMuxSourceSegmentData, SegmentedMuxSourceSpec, +}; + +/// Parsed one local MPD source into one or more representation-backed staged sources. +#[derive(Clone)] +pub(in crate::mux) struct ParsedDashSource { + pub(in crate::mux) periods: Vec, +} + +#[derive(Clone)] +pub(in crate::mux) struct ParsedDashPeriodSource { + pub(in crate::mux) start_millis: u64, + pub(in crate::mux) sources: Vec, +} + +struct ParsedDashManifest { + periods: Vec, +} + +struct ParsedDashPeriodPlan { + start_millis: u64, + representations: Vec, +} + +struct DashRepresentationPlan { + manifest_path: PathBuf, + base_url_parts: Vec, + representation_id: Option, + bandwidth: Option, + initialization: Option, + media_plan: DashMediaPlan, +} + +enum DashMediaPlan { + Explicit(Vec), + NumberTemplate { + media_template: String, + start_number: usize, + }, +} + +struct DashTemplateExpansion { + template: String, + number: Option, + time: Option, +} + +struct ResolvedDashSegmentPath { + path: PathBuf, + size: u32, +} + +#[derive(Clone, Default)] +struct PendingRepresentationDefaults { + template_initialization: Option, + template_media: Option, + template_start_number: usize, + template_segment_times: Vec>, + template_next_time: Option, + list_initialization: Option, + list_media: Vec, +} + +#[derive(Default)] +struct PendingRepresentation { + representation_id: Option, + bandwidth: Option, + base_url: Option, + template_initialization: Option, + template_media: Option, + template_start_number: usize, + template_segment_times: Vec>, + template_next_time: Option, + list_initialization: Option, + list_media: Vec, +} + +impl PendingRepresentation { + fn from_defaults(defaults: Option<&PendingRepresentationDefaults>) -> Self { + let mut pending = Self::default(); + if let Some(defaults) = defaults { + pending.template_initialization = defaults.template_initialization.clone(); + pending.template_media = defaults.template_media.clone(); + pending.template_start_number = defaults.template_start_number; + pending.template_segment_times = defaults.template_segment_times.clone(); + pending.template_next_time = defaults.template_next_time; + pending.list_initialization = defaults.list_initialization.clone(); + pending.list_media = defaults.list_media.clone(); + } + pending + } +} + +#[derive(Clone)] +struct XmlTag { + name: String, + attrs: BTreeMap, + self_closing: bool, + closing: bool, +} + +enum DashXmlEvent { + Tag(XmlTag), + Text(String), +} + +enum DashXmlEventPoll { + Event(DashXmlEvent), + NeedMore, + End, +} + +#[derive(Clone, Copy)] +enum PendingBaseUrlTarget { + Global, + Period, + Adaptation, + Representation, +} + +#[derive(Default)] +struct DashManifestBuilder { + saw_root: bool, + period_open: bool, + current_period_plans: Vec, + periods: Vec, + current_period_start_millis: u64, + global_base_url: Option, + period_base_url: Option, + adaptation_base_url: Option, + adaptation_defaults: Option, + representation: Option, + pending_base_url: Option<(PendingBaseUrlTarget, String)>, +} + +pub(in crate::mux) fn looks_like_dash_manifest_path(path: &Path, prefix: &[u8]) -> bool { + let Some(root_name) = extract_xml_root_name(prefix) else { + return path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("mpd")); + }; + root_name.eq_ignore_ascii_case("MPD") + || root_name.eq_ignore_ascii_case("Period") + || path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("mpd")) +} + +pub(in crate::mux) fn parse_dash_source_sync(path: &Path) -> Result { + let file = fs::File::open(path)?; + let mut reader = BufReader::new(file); + let manifest = parse_dash_source_reader_sync(path, &mut reader)?; + let mut periods = Vec::with_capacity(manifest.periods.len()); + for period in manifest.periods { + let mut representation_sources = Vec::with_capacity(period.representations.len()); + for plan in period.representations { + representation_sources.push(build_representation_source_spec_sync(plan)?); + } + periods.push(ParsedDashPeriodSource { + start_millis: period.start_millis, + sources: representation_sources, + }); + } + Ok(ParsedDashSource { periods }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn parse_dash_source_async( + path: &Path, +) -> Result { + let file = tokio_fs::File::open(path).await?; + let mut reader = AsyncBufReader::new(file); + let manifest = parse_dash_source_reader_async(path, &mut reader).await?; + let mut periods = Vec::with_capacity(manifest.periods.len()); + for period in manifest.periods { + let mut representation_sources = Vec::with_capacity(period.representations.len()); + for plan in period.representations { + representation_sources.push(build_representation_source_spec_async(plan).await?); + } + periods.push(ParsedDashPeriodSource { + start_millis: period.start_millis, + sources: representation_sources, + }); + } + Ok(ParsedDashSource { periods }) +} + +fn parse_dash_source_reader_sync( + path: &Path, + reader: &mut R, +) -> Result +where + R: BufRead, +{ + let mut builder = DashManifestBuilder::default(); + let mut buffer = String::new(); + let mut line = String::new(); + let mut cursor = 0usize; + let mut first_line = true; + loop { + line.clear(); + let bytes_read = reader + .read_line(&mut line) + .map_err(|_| invalid_dash_manifest(path, "manifest bytes are not valid UTF-8"))?; + let eof = bytes_read == 0; + if !line.is_empty() { + let rendered = if first_line { + first_line = false; + line.strip_prefix('\u{FEFF}').unwrap_or(&line) + } else { + &line + }; + buffer.push_str(rendered); + } + consume_dash_buffer(path, &mut builder, &mut buffer, &mut cursor, eof)?; + if eof { + break; + } + } + builder.finish(path) +} + +#[cfg(feature = "async")] +async fn parse_dash_source_reader_async( + path: &Path, + reader: &mut R, +) -> Result +where + R: tokio::io::AsyncBufRead + Unpin, +{ + let mut builder = DashManifestBuilder::default(); + let mut buffer = String::new(); + let mut line = String::new(); + let mut cursor = 0usize; + let mut first_line = true; + loop { + line.clear(); + let bytes_read = reader + .read_line(&mut line) + .await + .map_err(|_| invalid_dash_manifest(path, "manifest bytes are not valid UTF-8"))?; + let eof = bytes_read == 0; + if !line.is_empty() { + let rendered = if first_line { + first_line = false; + line.strip_prefix('\u{FEFF}').unwrap_or(&line) + } else { + &line + }; + buffer.push_str(rendered); + } + consume_dash_buffer(path, &mut builder, &mut buffer, &mut cursor, eof)?; + if eof { + break; + } + } + builder.finish(path) +} + +fn consume_dash_buffer( + path: &Path, + builder: &mut DashManifestBuilder, + buffer: &mut String, + cursor: &mut usize, + eof: bool, +) -> Result<(), MuxError> { + while let DashXmlEventPoll::Event(event) = poll_next_xml_event(buffer, cursor, eof) + .map_err(|message| invalid_dash_manifest(path, &message))? + { + builder.push_event(path, event)?; + } + if *cursor == buffer.len() { + buffer.clear(); + *cursor = 0; + } else if *cursor > 8_192 || eof { + buffer.drain(..*cursor); + *cursor = 0; + } + Ok(()) +} + +impl DashManifestBuilder { + fn push_event(&mut self, path: &Path, event: DashXmlEvent) -> Result<(), MuxError> { + let DashXmlEvent::Tag(tag) = event else { + if let DashXmlEvent::Text(value) = event + && let Some((_, pending_text)) = self.pending_base_url.as_mut() + { + pending_text.push_str(&value); + } + return Ok(()); + }; + let name = tag.name.as_str(); + if tag.closing { + if name.eq_ignore_ascii_case("BaseURL") { + if let Some((target, value)) = self.pending_base_url.take() { + let value = value.trim().to_string(); + match target { + PendingBaseUrlTarget::Global => self.global_base_url = Some(value), + PendingBaseUrlTarget::Period => self.period_base_url = Some(value), + PendingBaseUrlTarget::Adaptation => self.adaptation_base_url = Some(value), + PendingBaseUrlTarget::Representation => { + if let Some(pending) = self.representation.as_mut() { + pending.base_url = Some(value); + } + } + } + } + return Ok(()); + } + if name.eq_ignore_ascii_case("Representation") { + let Some(pending) = self.representation.take() else { + return Err(invalid_dash_manifest( + path, + "encountered `` without ``", + )); + }; + self.current_period_plans.push(build_representation_plan( + path, + &self.global_base_url, + &self.period_base_url, + &self.adaptation_base_url, + pending, + )?); + return Ok(()); + } + if name.eq_ignore_ascii_case("AdaptationSet") { + self.adaptation_base_url = None; + self.adaptation_defaults = None; + return Ok(()); + } + if name.eq_ignore_ascii_case("Period") { + if !self.current_period_plans.is_empty() { + self.periods.push(ParsedDashPeriodPlan { + start_millis: self.current_period_start_millis, + representations: std::mem::take(&mut self.current_period_plans), + }); + } + self.period_base_url = None; + self.adaptation_base_url = None; + self.adaptation_defaults = None; + self.current_period_start_millis = 0; + self.period_open = false; + return Ok(()); + } + return Ok(()); + } + + if name.eq_ignore_ascii_case("BaseURL") { + let target = if self.representation.is_some() { + PendingBaseUrlTarget::Representation + } else if self.adaptation_defaults.is_some() { + PendingBaseUrlTarget::Adaptation + } else if self.period_open { + PendingBaseUrlTarget::Period + } else { + PendingBaseUrlTarget::Global + }; + if tag.self_closing { + match target { + PendingBaseUrlTarget::Global => self.global_base_url = Some(String::new()), + PendingBaseUrlTarget::Period => self.period_base_url = Some(String::new()), + PendingBaseUrlTarget::Adaptation => { + self.adaptation_base_url = Some(String::new()) + } + PendingBaseUrlTarget::Representation => { + if let Some(pending) = self.representation.as_mut() { + pending.base_url = Some(String::new()); + } + } + } + } else { + self.pending_base_url = Some((target, String::new())); + } + return Ok(()); + } + + if name.eq_ignore_ascii_case("MPD") { + self.saw_root = true; + return Ok(()); + } + if !self.saw_root { + if name.eq_ignore_ascii_case("Period") { + self.saw_root = true; + } else { + return Err(invalid_dash_manifest(path, "missing MPD root element")); + } + } + if name.eq_ignore_ascii_case("Period") { + if self.period_open && !self.current_period_plans.is_empty() { + self.periods.push(ParsedDashPeriodPlan { + start_millis: self.current_period_start_millis, + representations: std::mem::take(&mut self.current_period_plans), + }); + } + self.period_base_url = None; + self.period_open = true; + self.adaptation_base_url = None; + self.adaptation_defaults = None; + self.current_period_start_millis = attrs_optional_string(path, &tag.attrs, "start")? + .map(|value| parse_dash_duration_millis(path, &value)) + .transpose()? + .unwrap_or(0); + return Ok(()); + } + if name.eq_ignore_ascii_case("AdaptationSet") { + if !self.period_open { + return Err(invalid_dash_manifest( + path, + "encountered `` outside ``", + )); + } + self.adaptation_defaults = Some(PendingRepresentationDefaults::default()); + return Ok(()); + } + if name.eq_ignore_ascii_case("Representation") { + if !self.period_open { + return Err(invalid_dash_manifest( + path, + "encountered `` outside ``", + )); + } + if self.representation.is_some() { + return Err(invalid_dash_manifest( + path, + "nested `` elements are not supported", + )); + } + let mut pending = + PendingRepresentation::from_defaults(self.adaptation_defaults.as_ref()); + pending.representation_id = attrs_optional_string(path, &tag.attrs, "id")?; + pending.bandwidth = attrs_optional_usize(path, &tag.attrs, "bandwidth")?; + self.representation = Some(pending); + if tag.self_closing { + let pending = self.representation.take().unwrap(); + self.current_period_plans.push(build_representation_plan( + path, + &self.global_base_url, + &self.period_base_url, + &self.adaptation_base_url, + pending, + )?); + } + return Ok(()); + } + let Some(mut pending) = self + .representation + .as_mut() + .map(PendingDashTarget::Representation) + .or_else(|| { + self.adaptation_defaults + .as_mut() + .map(PendingDashTarget::AdaptationDefaults) + }) + else { + return Ok(()); + }; + if name.eq_ignore_ascii_case("SegmentTemplate") { + pending.set_template_initialization(attrs_optional_string( + path, + &tag.attrs, + "initialization", + )?); + pending.set_template_media(attrs_optional_string(path, &tag.attrs, "media")?); + pending.set_template_start_number( + attrs_optional_usize(path, &tag.attrs, "startNumber")?.unwrap_or(1), + ); + return Ok(()); + } + if name.eq_ignore_ascii_case("S") { + let duration = attrs_optional_u64(path, &tag.attrs, "d")?; + let start_time = attrs_optional_u64(path, &tag.attrs, "t")?; + let repeat = attrs_optional_usize(path, &tag.attrs, "r")?.unwrap_or(0); + if repeat != 0 && duration.is_none() { + return Err(invalid_dash_manifest( + path, + "SegmentTimeline entries with `r` must also carry one `d` duration attribute", + )); + } + if let Some(start_time) = start_time { + pending.set_template_next_time(Some(start_time)); + } else if pending.template_next_time().is_none() && duration.is_some() { + pending.set_template_next_time(Some(0)); + } + for _ in 0..=repeat { + pending.push_template_segment_time(pending.template_next_time()); + if let (Some(current_time), Some(duration)) = + (pending.template_next_time(), duration) + { + pending.set_template_next_time(Some( + current_time + .checked_add(duration) + .ok_or(MuxError::LayoutOverflow("MPD segment timeline time"))?, + )); + } else { + pending.set_template_next_time(None); + } + } + return Ok(()); + } + if name.eq_ignore_ascii_case("Initialization") { + pending.set_list_initialization(attrs_optional_string(path, &tag.attrs, "sourceURL")?); + return Ok(()); + } + if name.eq_ignore_ascii_case("SegmentURL") { + let Some(media) = attrs_optional_string(path, &tag.attrs, "media")? else { + return Err(invalid_dash_manifest( + path, + "SegmentURL entries must carry one `media` attribute", + )); + }; + pending.push_list_media(media); + } + Ok(()) + } + + fn finish(mut self, path: &Path) -> Result { + if !self.saw_root { + return Err(invalid_dash_manifest(path, "missing MPD root element")); + } + if let Some(pending) = self.representation.take() { + self.current_period_plans.push(build_representation_plan( + path, + &self.global_base_url, + &self.period_base_url, + &self.adaptation_base_url, + pending, + )?); + } + if !self.current_period_plans.is_empty() { + self.periods.push(ParsedDashPeriodPlan { + start_millis: self.current_period_start_millis, + representations: self.current_period_plans, + }); + } + if self.periods.is_empty() { + return Err(invalid_dash_manifest( + path, + "MPD did not describe any local representation-backed sources", + )); + } + if self + .periods + .iter() + .any(|period| period.representations.is_empty()) + { + return Err(invalid_dash_manifest( + path, + "one MPD Period did not describe any local representation-backed sources", + )); + } + Ok(ParsedDashManifest { + periods: self.periods, + }) + } +} + +fn parse_dash_duration_millis(path: &Path, value: &str) -> Result { + let Some(mut remainder) = value.strip_prefix("PT") else { + return Err(invalid_dash_manifest( + path, + "only `PT...` DASH duration strings are supported on the local path-only ingest surface", + )); + }; + if remainder.is_empty() { + return Err(invalid_dash_manifest(path, "empty DASH duration string")); + } + + let mut total_seconds = 0_f64; + let mut saw_component = false; + while !remainder.is_empty() { + let component_len = remainder + .find(|ch: char| !matches!(ch, '0'..='9' | '.')) + .ok_or_else(|| { + invalid_dash_manifest(path, "DASH duration component is missing a unit suffix") + })?; + let (number, rest) = remainder.split_at(component_len); + let Some(unit) = rest.chars().next() else { + return Err(invalid_dash_manifest( + path, + "DASH duration component is missing a unit suffix", + )); + }; + let magnitude = number.parse::().map_err(|_| { + invalid_dash_manifest(path, "DASH duration component is not a valid number") + })?; + if !magnitude.is_finite() || magnitude < 0.0 { + return Err(invalid_dash_manifest( + path, + "DASH duration component must be a finite non-negative value", + )); + } + match unit { + 'H' => total_seconds += magnitude * 3600.0, + 'M' => total_seconds += magnitude * 60.0, + 'S' => total_seconds += magnitude, + _ => { + return Err(invalid_dash_manifest( + path, + "unsupported DASH duration unit on the local path-only ingest surface", + )); + } + } + saw_component = true; + remainder = &rest[unit.len_utf8()..]; + } + + if !saw_component { + return Err(invalid_dash_manifest(path, "empty DASH duration string")); + } + + let millis = (total_seconds * 1000.0).round(); + if !millis.is_finite() || millis < 0.0 || millis > u64::MAX as f64 { + return Err(invalid_dash_manifest( + path, + "DASH duration is too large for the current local ingest surface", + )); + } + Ok(millis as u64) +} + +enum PendingDashTarget<'a> { + Representation(&'a mut PendingRepresentation), + AdaptationDefaults(&'a mut PendingRepresentationDefaults), +} + +impl PendingDashTarget<'_> { + fn set_template_initialization(&mut self, value: Option) { + match self { + Self::Representation(pending) => pending.template_initialization = value, + Self::AdaptationDefaults(pending) => pending.template_initialization = value, + } + } + + fn set_template_media(&mut self, value: Option) { + match self { + Self::Representation(pending) => pending.template_media = value, + Self::AdaptationDefaults(pending) => pending.template_media = value, + } + } + + fn set_template_start_number(&mut self, value: usize) { + match self { + Self::Representation(pending) => pending.template_start_number = value, + Self::AdaptationDefaults(pending) => pending.template_start_number = value, + } + } + + fn template_next_time(&self) -> Option { + match self { + Self::Representation(pending) => pending.template_next_time, + Self::AdaptationDefaults(pending) => pending.template_next_time, + } + } + + fn set_template_next_time(&mut self, value: Option) { + match self { + Self::Representation(pending) => pending.template_next_time = value, + Self::AdaptationDefaults(pending) => pending.template_next_time = value, + } + } + + fn push_template_segment_time(&mut self, value: Option) { + match self { + Self::Representation(pending) => pending.template_segment_times.push(value), + Self::AdaptationDefaults(pending) => pending.template_segment_times.push(value), + } + } + + fn set_list_initialization(&mut self, value: Option) { + match self { + Self::Representation(pending) => pending.list_initialization = value, + Self::AdaptationDefaults(pending) => pending.list_initialization = value, + } + } + + fn push_list_media(&mut self, value: String) { + match self { + Self::Representation(pending) => pending.list_media.push(value), + Self::AdaptationDefaults(pending) => pending.list_media.push(value), + } + } +} + +fn build_representation_plan( + manifest_path: &Path, + global_base_url: &Option, + period_base_url: &Option, + adaptation_base_url: &Option, + representation: PendingRepresentation, +) -> Result { + let mut base_url_parts = Vec::new(); + if let Some(base_url) = global_base_url.as_ref().filter(|value| !value.is_empty()) { + base_url_parts.push(base_url.clone()); + } + if let Some(base_url) = period_base_url.as_ref().filter(|value| !value.is_empty()) { + base_url_parts.push(base_url.clone()); + } + if let Some(base_url) = adaptation_base_url + .as_ref() + .filter(|value| !value.is_empty()) + { + base_url_parts.push(base_url.clone()); + } + if let Some(base_url) = representation + .base_url + .as_ref() + .filter(|value| !value.is_empty()) + { + base_url_parts.push(base_url.clone()); + } + let initialization = representation + .list_initialization + .or(representation.template_initialization) + .map(|template| DashTemplateExpansion { + template, + number: None, + time: None, + }); + let media_plan = if !representation.list_media.is_empty() { + DashMediaPlan::Explicit( + representation + .list_media + .into_iter() + .map(|template| DashTemplateExpansion { + template, + number: None, + time: None, + }) + .collect(), + ) + } else if let Some(media_template) = representation.template_media { + if representation.template_segment_times.is_empty() { + if dash_template_uses_token(&media_template, "Time") { + return Err(invalid_dash_manifest( + manifest_path, + "SegmentTemplate used `$Time$` without one SegmentTimeline", + )); + } + if !dash_template_uses_token(&media_template, "Number") { + return Err(invalid_dash_manifest( + manifest_path, + "SegmentTemplate without one SegmentTimeline must use `$Number$` so the local path-only importer can enumerate segment files", + )); + } + DashMediaPlan::NumberTemplate { + media_template, + start_number: representation.template_start_number, + } + } else { + DashMediaPlan::Explicit( + representation + .template_segment_times + .into_iter() + .enumerate() + .map(|(index, time)| { + let number = representation + .template_start_number + .checked_add(index) + .ok_or(MuxError::LayoutOverflow("MPD segment number"))?; + Ok(DashTemplateExpansion { + template: media_template.clone(), + number: Some(number), + time, + }) + }) + .collect::, MuxError>>()?, + ) + } + } else { + DashMediaPlan::Explicit(Vec::new()) + }; + Ok(DashRepresentationPlan { + manifest_path: manifest_path.to_path_buf(), + base_url_parts, + representation_id: representation.representation_id, + bandwidth: representation.bandwidth, + initialization, + media_plan, + }) +} + +fn build_representation_source_spec_sync( + plan: DashRepresentationPlan, +) -> Result { + let source_paths = resolve_representation_paths_sync(&plan)?; + build_segmented_source_spec(&plan.manifest_path, source_paths) +} + +#[cfg(feature = "async")] +async fn build_representation_source_spec_async( + plan: DashRepresentationPlan, +) -> Result { + let source_paths = resolve_representation_paths_async(&plan).await?; + build_segmented_source_spec(&plan.manifest_path, source_paths) +} + +fn resolve_representation_paths_sync( + plan: &DashRepresentationPlan, +) -> Result, MuxError> { + let mut source_paths = Vec::new(); + if let Some(initialization) = &plan.initialization { + source_paths.push(resolve_dash_segment_path_sync(plan, initialization)?); + } + match &plan.media_plan { + DashMediaPlan::Explicit(media_entries) => { + for media in media_entries { + source_paths.push(resolve_dash_segment_path_sync(plan, media)?); + } + } + DashMediaPlan::NumberTemplate { + media_template, + start_number, + } => { + source_paths.extend(resolve_dash_number_template_paths_sync( + plan, + media_template, + *start_number, + )?); + } + } + if source_paths.is_empty() { + return Err(invalid_dash_manifest( + &plan.manifest_path, + "Representation did not resolve to any initialization or segment file paths", + )); + } + Ok(source_paths) +} + +#[cfg(feature = "async")] +async fn resolve_representation_paths_async( + plan: &DashRepresentationPlan, +) -> Result, MuxError> { + let mut source_paths = Vec::new(); + if let Some(initialization) = &plan.initialization { + source_paths.push(resolve_dash_segment_path_async(plan, initialization).await?); + } + match &plan.media_plan { + DashMediaPlan::Explicit(media_entries) => { + for media in media_entries { + source_paths.push(resolve_dash_segment_path_async(plan, media).await?); + } + } + DashMediaPlan::NumberTemplate { + media_template, + start_number, + } => { + source_paths.extend( + resolve_dash_number_template_paths_async(plan, media_template, *start_number) + .await?, + ); + } + } + if source_paths.is_empty() { + return Err(invalid_dash_manifest( + &plan.manifest_path, + "Representation did not resolve to any initialization or segment file paths", + )); + } + Ok(source_paths) +} + +fn resolve_dash_segment_path_sync( + plan: &DashRepresentationPlan, + expansion: &DashTemplateExpansion, +) -> Result { + let media = expand_dash_template( + &plan.manifest_path, + &expansion.template, + plan.representation_id.as_deref(), + plan.bandwidth, + expansion.number, + expansion.time, + )?; + let path = resolve_dash_path(&plan.manifest_path, &plan.base_url_parts, &media)?; + let size = fs::metadata(&path) + .map_err(|error| { + MuxError::Io(std::io::Error::new( + error.kind(), + format!("failed to stat DASH segment `{}`: {error}", path.display()), + )) + })? + .len(); + let size = u32::try_from(size) + .map_err(|_| invalid_dash_manifest(&plan.manifest_path, "segment size exceeds u32"))?; + Ok(ResolvedDashSegmentPath { path, size }) +} + +#[cfg(feature = "async")] +async fn resolve_dash_segment_path_async( + plan: &DashRepresentationPlan, + expansion: &DashTemplateExpansion, +) -> Result { + let media = expand_dash_template( + &plan.manifest_path, + &expansion.template, + plan.representation_id.as_deref(), + plan.bandwidth, + expansion.number, + expansion.time, + )?; + let path = resolve_dash_path(&plan.manifest_path, &plan.base_url_parts, &media)?; + let size = tokio_fs::metadata(&path) + .await + .map_err(|error| { + MuxError::Io(std::io::Error::new( + error.kind(), + format!("failed to stat DASH segment `{}`: {error}", path.display()), + )) + })? + .len(); + let size = u32::try_from(size) + .map_err(|_| invalid_dash_manifest(&plan.manifest_path, "segment size exceeds u32"))?; + Ok(ResolvedDashSegmentPath { path, size }) +} + +fn resolve_dash_number_template_paths_sync( + plan: &DashRepresentationPlan, + media_template: &str, + start_number: usize, +) -> Result, MuxError> { + let mut source_paths = Vec::new(); + let mut next_number = start_number; + loop { + let expansion = DashTemplateExpansion { + template: media_template.to_string(), + number: Some(next_number), + time: None, + }; + match resolve_dash_segment_path_sync(plan, &expansion) { + Ok(resolved) => { + source_paths.push(resolved); + next_number = next_number + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("MPD segment number"))?; + } + Err(MuxError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => { + if source_paths.is_empty() { + return Err(invalid_dash_manifest( + &plan.manifest_path, + "SegmentTemplate did not resolve any local numbered segment file paths", + )); + } + break; + } + Err(error) => return Err(error), + } + } + Ok(source_paths) +} + +#[cfg(feature = "async")] +async fn resolve_dash_number_template_paths_async( + plan: &DashRepresentationPlan, + media_template: &str, + start_number: usize, +) -> Result, MuxError> { + let mut source_paths = Vec::new(); + let mut next_number = start_number; + loop { + let expansion = DashTemplateExpansion { + template: media_template.to_string(), + number: Some(next_number), + time: None, + }; + match resolve_dash_segment_path_async(plan, &expansion).await { + Ok(resolved) => { + source_paths.push(resolved); + next_number = next_number + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("MPD segment number"))?; + } + Err(MuxError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => { + if source_paths.is_empty() { + return Err(invalid_dash_manifest( + &plan.manifest_path, + "SegmentTemplate did not resolve any local numbered segment file paths", + )); + } + break; + } + Err(error) => return Err(error), + } + } + Ok(source_paths) +} + +fn build_segmented_source_spec( + manifest_path: &Path, + source_paths: Vec, +) -> Result { + let mut logical_offset = 0_u64; + let mut segments = Vec::with_capacity(source_paths.len()); + for resolved in source_paths { + segments.push(SegmentedMuxSourceSegment { + logical_offset, + data: SegmentedMuxSourceSegmentData::ExternalFileRange { + path: resolved.path, + source_offset: 0, + size: resolved.size, + }, + }); + logical_offset = logical_offset + .checked_add(u64::from(resolved.size)) + .ok_or(MuxError::LayoutOverflow("MPD segmented source size"))?; + } + Ok(SegmentedMuxSourceSpec { + path: manifest_path.to_path_buf(), + segments, + total_size: logical_offset, + }) +} + +fn expand_dash_template( + manifest_path: &Path, + template: &str, + representation_id: Option<&str>, + bandwidth: Option, + number: Option, + time: Option, +) -> Result { + let mut expanded = String::with_capacity(template.len()); + let mut cursor = 0usize; + while let Some(token_start_rel) = template[cursor..].find('$') { + let token_start = cursor + token_start_rel; + expanded.push_str(&template[cursor..token_start]); + if template[token_start..].starts_with("$$") { + expanded.push('$'); + cursor = token_start + 2; + continue; + } + let token_end_rel = template[token_start + 1..].find('$').ok_or_else(|| { + invalid_dash_manifest( + manifest_path, + &format!("unterminated SegmentTemplate token in `{template}`"), + ) + })?; + let token_end = token_start + 1 + token_end_rel; + expanded.push_str(&expand_dash_template_token( + manifest_path, + &template[token_start + 1..token_end], + representation_id, + bandwidth, + number, + time, + )?); + cursor = token_end + 1; + } + expanded.push_str(&template[cursor..]); + Ok(expanded) +} + +fn dash_template_uses_token(template: &str, token_name: &str) -> bool { + let mut cursor = 0usize; + while let Some(token_start_rel) = template[cursor..].find('$') { + let token_start = cursor + token_start_rel; + if template[token_start..].starts_with("$$") { + cursor = token_start + 2; + continue; + } + let Some(token_end_rel) = template[token_start + 1..].find('$') else { + return false; + }; + let token_end = token_start + 1 + token_end_rel; + let token = &template[token_start + 1..token_end]; + let name = token.split('%').next().unwrap_or(token); + if name == token_name { + return true; + } + cursor = token_end + 1; + } + false +} + +fn expand_dash_template_token( + manifest_path: &Path, + token: &str, + representation_id: Option<&str>, + bandwidth: Option, + number: Option, + time: Option, +) -> Result { + let (name, width, zero_pad) = parse_dash_token_format(manifest_path, token)?; + match name { + "RepresentationID" => { + if width.is_some() || zero_pad { + return Err(invalid_dash_manifest( + manifest_path, + "`$RepresentationID$` does not support integer-width formatting", + )); + } + Ok(representation_id + .ok_or_else(|| { + invalid_dash_manifest( + manifest_path, + "SegmentTemplate used `$RepresentationID$` without one Representation `id` attribute", + ) + })? + .to_string()) + } + "Bandwidth" => format_dash_template_number( + manifest_path, + bandwidth.map(|value| value as u64), + width, + zero_pad, + "SegmentTemplate used `$Bandwidth$` without one Representation `bandwidth` attribute", + ), + "Number" => format_dash_template_number( + manifest_path, + number.map(|value| value as u64), + width, + zero_pad, + "SegmentTemplate used `$Number$` outside one numbered media-template expansion", + ), + "Time" => format_dash_template_number( + manifest_path, + time, + width, + zero_pad, + "SegmentTemplate used `$Time$` outside one timeline-backed media-template expansion", + ), + _ => Err(invalid_dash_manifest( + manifest_path, + &format!("unsupported SegmentTemplate token `${token}$`"), + )), + } +} + +fn parse_dash_token_format<'a>( + manifest_path: &Path, + token: &'a str, +) -> Result<(&'a str, Option, bool), MuxError> { + let Some(format_start) = token.find('%') else { + return Ok((token, None, false)); + }; + let name = &token[..format_start]; + let format = &token[format_start + 1..]; + let Some(format_body) = format.strip_suffix('d') else { + return Err(invalid_dash_manifest( + manifest_path, + &format!("unsupported SegmentTemplate integer formatter `%{format}` in `${token}$`"), + )); + }; + let (zero_pad, digits) = if let Some(rest) = format_body.strip_prefix('0') { + (true, rest) + } else { + (false, format_body) + }; + if digits.is_empty() || !digits.chars().all(|ch| ch.is_ascii_digit()) { + return Err(invalid_dash_manifest( + manifest_path, + &format!("unsupported SegmentTemplate integer formatter `%{format}` in `${token}$`"), + )); + } + let width = digits.parse::().map_err(|_| { + invalid_dash_manifest( + manifest_path, + &format!("unsupported SegmentTemplate integer formatter `%{format}` in `${token}$`"), + ) + })?; + Ok((name, Some(width), zero_pad)) +} + +fn format_dash_template_number( + manifest_path: &Path, + value: Option, + width: Option, + zero_pad: bool, + missing_message: &'static str, +) -> Result { + let value = value.ok_or_else(|| invalid_dash_manifest(manifest_path, missing_message))?; + match width { + Some(width) if zero_pad => Ok(format!("{value:0width$}")), + Some(width) => Ok(format!("{value:width$}")), + None => Ok(value.to_string()), + } +} + +fn resolve_dash_path( + manifest_path: &Path, + base_urls: &[String], + url: &str, +) -> Result { + let mut joined = manifest_path + .parent() + .map(PathBuf::from) + .unwrap_or_default(); + for base_url in base_urls { + if let Some(local_path) = resolve_dash_local_file_uri(base_url) { + joined = if local_path.is_absolute() { + local_path + } else { + joined.join(local_path) + }; + continue; + } + if is_unsupported_dash_url(base_url) { + return Err(invalid_dash_manifest( + manifest_path, + "remote MPD URLs are not supported on the current path-only ingest surface; only local paths and file:// URIs are supported", + )); + } + joined = joined.join(PathBuf::from(base_url)); + } + if let Some(local_path) = resolve_dash_local_file_uri(url) { + return Ok(if local_path.is_absolute() { + local_path + } else { + joined.join(local_path) + }); + } + if is_unsupported_dash_url(url) { + return Err(invalid_dash_manifest( + manifest_path, + "remote MPD URLs are not supported on the current path-only ingest surface; only local paths and file:// URIs are supported", + )); + } + let candidate = PathBuf::from(url); + if candidate.is_absolute() { + Ok(candidate) + } else { + Ok(joined.join(candidate)) + } +} + +fn resolve_dash_local_file_uri(uri: &str) -> Option { + let rest = uri.strip_prefix("file:")?; + if let Some(path) = rest.strip_prefix("///") { + return resolve_dash_local_absolute_file_uri_path(path); + } + if let Some(authority_path) = rest.strip_prefix("//") { + let (authority, path) = authority_path.split_once('/')?; + if authority.eq_ignore_ascii_case("localhost") { + return resolve_dash_local_absolute_file_uri_path(path); + } + return resolve_dash_local_authority_file_uri_path(authority, path); + } + if let Some(path) = rest.strip_prefix('/') { + return resolve_dash_local_single_slash_file_uri_path(path); + } + None +} + +fn is_unsupported_dash_url(value: &str) -> bool { + value.starts_with("file:") || value.contains("://") +} + +#[cfg(windows)] +fn resolve_dash_local_single_slash_file_uri_path(path: &str) -> Option { + if path.len() >= 2 && path.as_bytes()[1] == b':' && path.as_bytes()[0].is_ascii_alphabetic() { + Some(PathBuf::from(path)) + } else { + None + } +} + +#[cfg(not(windows))] +fn resolve_dash_local_single_slash_file_uri_path(path: &str) -> Option { + resolve_dash_local_absolute_file_uri_path(path) +} + +#[cfg(windows)] +fn resolve_dash_local_absolute_file_uri_path(path: &str) -> Option { + if path.len() >= 2 && path.as_bytes()[1] == b':' && path.as_bytes()[0].is_ascii_alphabetic() { + Some(PathBuf::from(path)) + } else if path.starts_with('/') { + Some(PathBuf::from(format!( + r"\\{}", + path.trim_start_matches('/').replace('/', "\\") + ))) + } else if path.is_empty() { + None + } else { + Some(PathBuf::from(path)) + } +} + +#[cfg(windows)] +fn resolve_dash_local_authority_file_uri_path(authority: &str, path: &str) -> Option { + if authority.is_empty() || path.is_empty() { + None + } else if authority.len() == 2 + && authority.as_bytes()[1] == b':' + && authority.as_bytes()[0].is_ascii_alphabetic() + { + Some(PathBuf::from(format!("{authority}/{path}"))) + } else { + Some(PathBuf::from(format!( + r"\\{}\{}", + authority, + path.replace('/', "\\") + ))) + } +} + +#[cfg(not(windows))] +fn resolve_dash_local_absolute_file_uri_path(path: &str) -> Option { + if path.is_empty() { + None + } else { + Some(PathBuf::from(format!("/{}", path.trim_start_matches('/')))) + } +} + +#[cfg(not(windows))] +fn resolve_dash_local_authority_file_uri_path(_authority: &str, _path: &str) -> Option { + None +} + +fn poll_next_xml_event( + input: &str, + cursor: &mut usize, + eof: bool, +) -> Result { + while *cursor < input.len() { + let rest = &input[*cursor..]; + if rest.starts_with("") else { + return if eof { + Err("unterminated XML declaration in DASH manifest".to_string()) + } else { + Ok(DashXmlEventPoll::NeedMore) + }; + }; + *cursor += end + 2; + continue; + } + if rest.starts_with("") else { + return if eof { + Err("unterminated XML comment in DASH manifest".to_string()) + } else { + Ok(DashXmlEventPoll::NeedMore) + }; + }; + *cursor += end + 3; + continue; + } + if rest.starts_with('<') { + let tag_end = match find_xml_tag_end(rest) { + Ok(tag_end) => tag_end, + Err(message) => { + return if eof { + Err(message) + } else { + Ok(DashXmlEventPoll::NeedMore) + }; + } + }; + let tag = parse_xml_tag(&rest[..=tag_end])?; + *cursor += tag_end + 1; + return Ok(DashXmlEventPoll::Event(DashXmlEvent::Tag(tag))); + } + let Some(next_tag) = rest.find('<') else { + if eof { + let text = xml_unescape_attr(rest)?; + *cursor = input.len(); + if text.trim().is_empty() { + continue; + } + return Ok(DashXmlEventPoll::Event(DashXmlEvent::Text(text))); + } + return Ok(DashXmlEventPoll::NeedMore); + }; + let text = xml_unescape_attr(&rest[..next_tag])?; + *cursor += next_tag; + if text.trim().is_empty() { + continue; + } + return Ok(DashXmlEventPoll::Event(DashXmlEvent::Text(text))); + } + Ok(DashXmlEventPoll::End) +} + +fn find_xml_tag_end(text: &str) -> Result { + let bytes = text.as_bytes(); + let mut in_quotes = false; + let mut index = 1usize; + while index < bytes.len() { + match bytes[index] { + b'"' => in_quotes = !in_quotes, + b'>' if !in_quotes => return Ok(index), + _ => {} + } + index += 1; + } + Err("unterminated XML tag in DASH manifest".to_string()) +} + +fn parse_xml_tag(tag_text: &str) -> Result { + let trimmed = tag_text.trim(); + if let Some(content) = trimmed + .strip_prefix("')) + { + return Ok(XmlTag { + name: content.trim().to_string(), + attrs: BTreeMap::new(), + self_closing: false, + closing: true, + }); + } + let mut inner = trimmed + .strip_prefix('<') + .and_then(|value| value.strip_suffix('>')) + .ok_or_else(|| format!("unsupported MPD tag `{trimmed}`"))? + .trim(); + let self_closing = inner.ends_with('/'); + if self_closing { + inner = inner[..inner.len() - 1].trim_end(); + } + let name_end = inner.find(char::is_whitespace).unwrap_or(inner.len()); + let name = inner[..name_end].to_string(); + let mut attrs = BTreeMap::new(); + let mut cursor = inner[name_end..].trim_start(); + while !cursor.is_empty() { + let Some(eq_pos) = cursor.find('=') else { + return Err(format!("malformed MPD attribute list in `{trimmed}`")); + }; + let key = cursor[..eq_pos].trim(); + if key.is_empty() { + return Err(format!("malformed MPD attribute list in `{trimmed}`")); + } + let rest = cursor[eq_pos + 1..].trim_start(); + let Some(rest) = rest.strip_prefix('"') else { + return Err(format!( + "MPD attribute `{key}` in `{trimmed}` must use double quotes" + )); + }; + let Some(value_end) = rest.find('"') else { + return Err(format!("unterminated MPD attribute `{key}` in `{trimmed}`")); + }; + attrs.insert(key.to_string(), xml_unescape_attr(&rest[..value_end])?); + cursor = rest[value_end + 1..].trim_start(); + } + Ok(XmlTag { + name, + attrs, + self_closing, + closing: false, + }) +} + +fn extract_xml_root_name(prefix: &[u8]) -> Option { + let text = std::str::from_utf8(prefix).ok()?; + let text = text.trim_start_matches('\u{FEFF}').trim_start(); + let text = if text.starts_with("")?; + text[end + 2..].trim_start() + } else { + text + }; + let body = text.strip_prefix('<')?; + let name_end = body + .find(|ch: char| ch.is_whitespace() || ch == '>' || ch == '/') + .unwrap_or(body.len()); + if name_end == 0 { + None + } else { + Some(body[..name_end].to_string()) + } +} + +fn xml_unescape_attr(value: &str) -> Result { + let mut rendered = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + while let Some(ch) = chars.next() { + if ch != '&' { + rendered.push(ch); + continue; + } + let mut entity = String::new(); + for next in chars.by_ref() { + if next == ';' { + break; + } + entity.push(next); + } + match entity.as_str() { + "amp" => rendered.push('&'), + "lt" => rendered.push('<'), + "gt" => rendered.push('>'), + "quot" => rendered.push('"'), + "#39" => rendered.push('\''), + _ => return Err(format!("unsupported XML entity `&{entity};`")), + } + } + Ok(rendered) +} + +fn attrs_optional_string( + path: &Path, + attrs: &BTreeMap, + key: &str, +) -> Result, MuxError> { + let Some(value) = attrs.get(key) else { + return Ok(None); + }; + if value.is_empty() { + return Err(invalid_dash_manifest( + path, + &format!("attribute `{key}` must not be empty"), + )); + } + Ok(Some(value.clone())) +} + +fn attrs_optional_usize( + path: &Path, + attrs: &BTreeMap, + key: &str, +) -> Result, MuxError> { + let Some(value) = attrs.get(key) else { + return Ok(None); + }; + value.parse::().map(Some).map_err(|_| { + invalid_dash_manifest( + path, + &format!("attribute `{key}` must be one platform-sized unsigned integer"), + ) + }) +} + +fn attrs_optional_u64( + path: &Path, + attrs: &BTreeMap, + key: &str, +) -> Result, MuxError> { + let Some(value) = attrs.get(key) else { + return Ok(None); + }; + value.parse::().map(Some).map_err(|_| { + invalid_dash_manifest( + path, + &format!("attribute `{key}` must be one unsigned 64-bit integer"), + ) + }) +} + +fn invalid_dash_manifest(path: &Path, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("invalid DASH manifest: {message}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn resolve_dash_file_uri_paths_keep_unix_absolute_paths() { + assert_eq!( + resolve_dash_local_file_uri("file:///tmp/media/segment.mp4").unwrap(), + PathBuf::from("/tmp/media/segment.mp4") + ); + assert_eq!( + resolve_dash_local_file_uri("file:////tmp/media/segment.mp4").unwrap(), + PathBuf::from("/tmp/media/segment.mp4") + ); + assert_eq!( + resolve_dash_local_file_uri("file://localhost/tmp/media/segment.mp4").unwrap(), + PathBuf::from("/tmp/media/segment.mp4") + ); + assert_eq!( + resolve_dash_local_file_uri("file://LOCALHOST/private/var/tmp/segment.mp4").unwrap(), + PathBuf::from("/private/var/tmp/segment.mp4") + ); + assert_eq!( + resolve_dash_local_file_uri("file:/tmp/media/segment.mp4").unwrap(), + PathBuf::from("/tmp/media/segment.mp4") + ); + assert!(resolve_dash_local_file_uri("file://media/assets/segment.mp4").is_none()); + assert!(is_unsupported_dash_url("file:relative/segment.mp4")); + } + + #[cfg(windows)] + #[test] + fn resolve_dash_file_uri_paths_keep_windows_absolute_paths() { + assert_eq!( + resolve_dash_local_file_uri("file:///C:/media/segment.mp4").unwrap(), + PathBuf::from("C:/media/segment.mp4") + ); + assert_eq!( + resolve_dash_local_file_uri("file://C:/media/segment.mp4").unwrap(), + PathBuf::from("C:/media/segment.mp4") + ); + assert_eq!( + resolve_dash_local_file_uri("file:////media/assets/segment.mp4").unwrap(), + PathBuf::from(r"\\media\assets\segment.mp4") + ); + assert_eq!( + resolve_dash_local_file_uri("file://media/assets/segment.mp4").unwrap(), + PathBuf::from(r"\\media\assets\segment.mp4") + ); + assert_eq!( + resolve_dash_local_file_uri("file://LOCALHOST/C:/media/segment.mp4").unwrap(), + PathBuf::from("C:/media/segment.mp4") + ); + assert_eq!( + resolve_dash_local_file_uri("file:/C:/media/segment.mp4").unwrap(), + PathBuf::from("C:/media/segment.mp4") + ); + assert!(is_unsupported_dash_url("file:relative/segment.mp4")); + } +} diff --git a/src/mux/demux/detect.rs b/src/mux/demux/detect.rs new file mode 100644 index 0000000..08514a4 --- /dev/null +++ b/src/mux/demux/detect.rs @@ -0,0 +1,615 @@ +use crate::FourCc; + +use super::super::MuxRawCodec; +use super::dash::looks_like_dash_manifest_path; +use super::iamf::looks_like_iamf_prefix; +use super::vobsub::looks_like_vobsub_prefix; + +const FTYP: FourCc = FourCc::from_bytes(*b"ftyp"); +const STYP: FourCc = FourCc::from_bytes(*b"styp"); +const FREE: FourCc = FourCc::from_bytes(*b"free"); +const SKIP: FourCc = FourCc::from_bytes(*b"skip"); +const WIDE: FourCc = FourCc::from_bytes(*b"wide"); +const MDAT: FourCc = FourCc::from_bytes(*b"mdat"); +const MOOV: FourCc = FourCc::from_bytes(*b"moov"); +const MOOF: FourCc = FourCc::from_bytes(*b"moof"); +const NON_CORE_DTS_IMPORT_ONLY_FAMILY: &str = "non-core DTS-family audio; native raw direct-ingest currently supports big-endian core DTS sync frames, little-endian core DTS sync frames, transformed 14-bit core DTS sync frames, and DTS-family wrappers that expose one contiguous core substream"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::mux) enum DetectedPathTrackKind { + Mp4, + Container(DetectedContainerPathKind), + Raw(MuxRawCodec), + Mp4ImportOnly(&'static str), + Unknown, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::mux) enum DetectedContainerPathKind { + Avi, + Dash, + Ghi, + Gsf, + Nhml, + Nhnt, + ProgramStream, + Saf, + TransportStream, + VobSub, +} + +pub(in crate::mux) fn detect_id3_wrapped_audio_from_prefix( + prefix: &[u8], + id3_offset: usize, +) -> Option { + if looks_like_mp3_prefix(prefix, id3_offset) { + return Some(DetectedPathTrackKind::Raw(MuxRawCodec::Mp3)); + } + if looks_like_adts_prefix(prefix, id3_offset) { + return Some(DetectedPathTrackKind::Raw(MuxRawCodec::Aac)); + } + None +} + +pub(in crate::mux) fn detect_path_track_kind_from_prefix(prefix: &[u8]) -> DetectedPathTrackKind { + if looks_like_mp4_prefix(prefix) { + return DetectedPathTrackKind::Mp4; + } + if looks_like_avi_prefix(prefix) { + return DetectedPathTrackKind::Container(DetectedContainerPathKind::Avi); + } + if looks_like_transport_stream_prefix(prefix) { + return DetectedPathTrackKind::Container(DetectedContainerPathKind::TransportStream); + } + if looks_like_program_stream_prefix(prefix) { + return DetectedPathTrackKind::Container(DetectedContainerPathKind::ProgramStream); + } + if looks_like_vobsub_prefix(prefix) { + return DetectedPathTrackKind::Container(DetectedContainerPathKind::VobSub); + } + let id3_offset = id3v2_size_from_prefix(prefix).unwrap_or(0); + if let Some(kind) = detect_id3_wrapped_audio_from_prefix(prefix, id3_offset) { + return kind; + } + if looks_like_truehd_prefix(prefix) { + return DetectedPathTrackKind::Raw(MuxRawCodec::Truehd); + } + if let Some(kind) = detect_dolby_audio_prefix(prefix) { + return kind; + } + if let Some(kind) = detect_amr_prefix(prefix) { + return kind; + } + if looks_like_qcp_prefix(prefix) { + return DetectedPathTrackKind::Raw(MuxRawCodec::Qcp); + } + if looks_like_jpeg_prefix(prefix) { + return DetectedPathTrackKind::Raw(MuxRawCodec::Jpeg); + } + if looks_like_png_prefix(prefix) { + return DetectedPathTrackKind::Raw(MuxRawCodec::Png); + } + if looks_like_bmp_prefix(prefix) { + return DetectedPathTrackKind::Raw(MuxRawCodec::Bmp); + } + if looks_like_prores_prefix(prefix) { + return DetectedPathTrackKind::Raw(MuxRawCodec::Prores); + } + if looks_like_y4m_prefix(prefix) { + return DetectedPathTrackKind::Raw(MuxRawCodec::Y4m); + } + if looks_like_j2k_prefix(prefix) { + return DetectedPathTrackKind::Raw(MuxRawCodec::J2k); + } + if looks_like_latm_prefix(prefix) { + return DetectedPathTrackKind::Raw(MuxRawCodec::Latm); + } + if looks_like_pcm_prefix(prefix) { + return DetectedPathTrackKind::Raw(MuxRawCodec::Pcm); + } + if let Some(kind) = detect_dts_prefix(prefix) { + return kind; + } + if looks_like_mhas_prefix(prefix) { + return DetectedPathTrackKind::Raw(MuxRawCodec::MpegH); + } + if looks_like_iamf_prefix(prefix) { + return DetectedPathTrackKind::Raw(MuxRawCodec::Iamf); + } + if looks_like_h263_prefix(prefix) { + return DetectedPathTrackKind::Raw(MuxRawCodec::H263); + } + if looks_like_mp4v_prefix(prefix) { + return DetectedPathTrackKind::Raw(MuxRawCodec::Mp4v); + } + if looks_like_mpeg2v_prefix(prefix) { + return DetectedPathTrackKind::Raw(MuxRawCodec::Mpeg2v); + } + if let Some(kind) = detect_annex_b_video_prefix(prefix) { + return kind; + } + if prefix.starts_with(b"fLaC") { + return DetectedPathTrackKind::Raw(MuxRawCodec::Flac); + } + if let Some(kind) = detect_ivf_prefix(prefix) { + return kind; + } + DetectedPathTrackKind::Unknown +} + +pub(in crate::mux) fn detect_container_path_kind_from_path_and_prefix( + path: &std::path::Path, + prefix: &[u8], +) -> Option { + if looks_like_ghi_path(path, prefix) { + return Some(DetectedContainerPathKind::Ghi); + } + if looks_like_gsf_path(path, prefix) { + return Some(DetectedContainerPathKind::Gsf); + } + if looks_like_dash_manifest_path(path, prefix) { + return Some(DetectedContainerPathKind::Dash); + } + if looks_like_saf_path(path) { + return Some(DetectedContainerPathKind::Saf); + } + None +} + +fn looks_like_ghi_path(path: &std::path::Path, prefix: &[u8]) -> bool { + if prefix.starts_with(b"GHID") { + return true; + } + let Some(extension) = path.extension().and_then(|value| value.to_str()) else { + return false; + }; + extension.eq_ignore_ascii_case("ghi") || extension.eq_ignore_ascii_case("ghix") +} + +fn looks_like_gsf_path(path: &std::path::Path, prefix: &[u8]) -> bool { + if prefix.starts_with(b"GS5F") { + return true; + } + let Some(extension) = path.extension().and_then(|value| value.to_str()) else { + return false; + }; + extension.eq_ignore_ascii_case("gsf") +} + +fn looks_like_saf_path(path: &std::path::Path) -> bool { + let Some(extension) = path.extension().and_then(|value| value.to_str()) else { + return false; + }; + extension.eq_ignore_ascii_case("saf") || extension.eq_ignore_ascii_case("lsr") +} + +fn looks_like_avi_prefix(prefix: &[u8]) -> bool { + prefix.len() >= 12 && &prefix[..4] == b"RIFF" && &prefix[8..12] == b"AVI " +} + +fn looks_like_program_stream_prefix(prefix: &[u8]) -> bool { + prefix.len() >= 4 && prefix[..4] == [0x00, 0x00, 0x01, 0xBA] +} + +fn looks_like_transport_stream_prefix(prefix: &[u8]) -> bool { + prefix.len() >= 376 && prefix[0] == 0x47 && prefix[188] == 0x47 +} + +fn looks_like_bmp_prefix(prefix: &[u8]) -> bool { + prefix.len() >= 2 && prefix[0] == b'B' && prefix[1] == b'M' +} + +fn looks_like_prores_prefix(prefix: &[u8]) -> bool { + if prefix.len() < 8 { + return false; + } + let frame_size = u32::from_be_bytes([prefix[0], prefix[1], prefix[2], prefix[3]]); + frame_size >= 28 && &prefix[4..8] == b"icpf" +} + +fn looks_like_y4m_prefix(prefix: &[u8]) -> bool { + prefix.starts_with(b"YUV4MPEG2 ") +} + +fn looks_like_j2k_prefix(prefix: &[u8]) -> bool { + if prefix.len() >= 12 + && prefix[..4] == 12_u32.to_be_bytes() + && &prefix[4..8] == b"jP " + && prefix[8..12] == [0x0D, 0x0A, 0x87, 0x0A] + { + return true; + } + prefix.len() >= 4 + && prefix[0] == 0xFF + && prefix[1] == 0x4F + && prefix[2] == 0xFF + && prefix[3] == 0x51 +} + +pub(in crate::mux) fn id3v2_size_from_prefix(prefix: &[u8]) -> Option { + if prefix.len() < 10 || &prefix[..3] != b"ID3" { + return None; + } + let size = [prefix[6], prefix[7], prefix[8], prefix[9]]; + if size.iter().any(|byte| byte & 0x80 != 0) { + return None; + } + Some( + 10 + ((usize::from(size[0]) & 0x7F) << 21) + + ((usize::from(size[1]) & 0x7F) << 14) + + ((usize::from(size[2]) & 0x7F) << 7) + + (usize::from(size[3]) & 0x7F), + ) +} + +fn looks_like_mp4_prefix(prefix: &[u8]) -> bool { + let mut offset = 0usize; + for _ in 0..4 { + if prefix.len().saturating_sub(offset) < 8 { + return false; + } + let size = u32::from_be_bytes([ + prefix[offset], + prefix[offset + 1], + prefix[offset + 2], + prefix[offset + 3], + ]); + let box_type = FourCc::from_bytes([ + prefix[offset + 4], + prefix[offset + 5], + prefix[offset + 6], + prefix[offset + 7], + ]); + if matches!(box_type, FTYP | STYP | MOOV | MOOF | MDAT) { + return true; + } + if !matches!(box_type, FREE | SKIP | WIDE) { + return false; + } + let size = match size { + 0 => return false, + 1 => { + if prefix.len().saturating_sub(offset) < 16 { + return false; + } + u64::from_be_bytes([ + prefix[offset + 8], + prefix[offset + 9], + prefix[offset + 10], + prefix[offset + 11], + prefix[offset + 12], + prefix[offset + 13], + prefix[offset + 14], + prefix[offset + 15], + ]) as usize + } + value => value as usize, + }; + if size < 8 { + return false; + } + offset = match offset.checked_add(size) { + Some(value) => value, + None => return false, + }; + if offset >= prefix.len() { + break; + } + } + false +} + +fn looks_like_adts_prefix(prefix: &[u8], offset: usize) -> bool { + if prefix.len().saturating_sub(offset) < 7 { + return false; + } + prefix[offset] == 0xFF + && prefix[offset + 1] & 0xF0 == 0xF0 + && ((prefix[offset + 2] >> 2) & 0x0F) < 13 +} + +fn looks_like_mp3_prefix(prefix: &[u8], offset: usize) -> bool { + if prefix.len().saturating_sub(offset) < 4 { + return false; + } + let header = u32::from_be_bytes([ + prefix[offset], + prefix[offset + 1], + prefix[offset + 2], + prefix[offset + 3], + ]); + let sync = (header >> 21) & 0x07FF; + let layer = (header >> 17) & 0x03; + let bitrate_index = (header >> 12) & 0x0F; + let sample_rate_index = (header >> 10) & 0x03; + sync == 0x07FF + && layer != 0 + && bitrate_index != 0 + && bitrate_index != 0x0F + && sample_rate_index != 0x03 +} + +fn looks_like_pcm_prefix(prefix: &[u8]) -> bool { + prefix.len() >= 12 + && ((&prefix[..4] == b"RIFF" && &prefix[8..12] == b"WAVE") + || (&prefix[..4] == b"FORM" + && (&prefix[8..12] == b"AIFF" || &prefix[8..12] == b"AIFC"))) +} + +fn looks_like_qcp_prefix(prefix: &[u8]) -> bool { + prefix.len() >= 12 && &prefix[..4] == b"RIFF" && &prefix[8..12] == b"QLCM" +} + +fn looks_like_jpeg_prefix(prefix: &[u8]) -> bool { + prefix.len() >= 3 && prefix[0] == 0xFF && prefix[1] == 0xD8 && prefix[2] == 0xFF +} + +fn looks_like_png_prefix(prefix: &[u8]) -> bool { + prefix.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) +} + +fn looks_like_latm_prefix(prefix: &[u8]) -> bool { + prefix.len() >= 3 && prefix[0] == 0x56 && (prefix[1] >> 5) == 0x07 +} + +fn looks_like_truehd_prefix(prefix: &[u8]) -> bool { + const TRUEHD_SYNC: [u8; 4] = [0xF8, 0x72, 0x6F, 0xBA]; + const TRUEHD_SIGNATURE: [u8; 2] = [0xB7, 0x52]; + + if prefix.len() < 20 { + return false; + } + for offset in 0..=prefix.len() - 20 { + if prefix[offset + 4..offset + 8] != TRUEHD_SYNC { + continue; + } + if prefix[offset + 12..offset + 14] != TRUEHD_SIGNATURE { + continue; + } + let packed = u16::from_be_bytes([prefix[offset], prefix[offset + 1]]); + let frame_size = u32::from(packed & 0x0FFF) * 2; + if frame_size < 20 { + continue; + } + let format_info = u32::from_be_bytes([ + prefix[offset + 8], + prefix[offset + 9], + prefix[offset + 10], + prefix[offset + 11], + ]); + let sample_rate_code = ((format_info >> 28) & 0x0F) as u8; + if matches!(sample_rate_code, 0 | 1 | 2 | 8 | 9 | 10) { + return true; + } + } + false +} + +fn detect_amr_prefix(prefix: &[u8]) -> Option { + if prefix.starts_with(b"#!AMR\n") { + return Some(DetectedPathTrackKind::Raw(MuxRawCodec::Amr)); + } + if prefix.starts_with(b"#!AMR-WB\n") { + return Some(DetectedPathTrackKind::Raw(MuxRawCodec::AmrWb)); + } + None +} + +fn detect_dolby_audio_prefix(prefix: &[u8]) -> Option { + if prefix.len() < 6 || prefix[0] != 0x0B || prefix[1] != 0x77 { + if prefix.len() >= 2 { + let syncword = u16::from_be_bytes([prefix[0], prefix[1]]); + if syncword == 0xAC40 || syncword == 0xAC41 { + return Some(DetectedPathTrackKind::Raw(MuxRawCodec::Ac4)); + } + } + return None; + } + let bsid = (prefix[5] >> 3) & 0x1F; + if bsid <= 10 { + Some(DetectedPathTrackKind::Raw(MuxRawCodec::Ac3)) + } else if bsid <= 16 { + Some(DetectedPathTrackKind::Raw(MuxRawCodec::Eac3)) + } else { + None + } +} + +fn detect_annex_b_video_prefix(prefix: &[u8]) -> Option { + let mut index = 0usize; + while index + 4 <= prefix.len() { + let start_code_len = if prefix[index..].starts_with(&[0, 0, 0, 1]) { + 4 + } else if prefix[index..].starts_with(&[0, 0, 1]) { + 3 + } else { + index += 1; + continue; + }; + let header_index = index + start_code_len; + let &nal_header = prefix.get(header_index)?; + let h264_type = nal_header & 0x1F; + if matches!(h264_type, 1..=5 | 7..=9) { + return Some(DetectedPathTrackKind::Raw(MuxRawCodec::H264)); + } + let h265_type = (nal_header >> 1) & 0x3F; + if matches!(h265_type, 16..=21 | 32..=35) { + return Some(DetectedPathTrackKind::Raw(MuxRawCodec::H265)); + } + let Some(&vvc_header_byte1) = prefix.get(header_index + 1) else { + index = header_index + 1; + continue; + }; + let vvc_type = vvc_header_byte1 >> 3; + if matches!(vvc_type, 7..=10 | 14..=17 | 20) { + return Some(DetectedPathTrackKind::Raw(MuxRawCodec::Vvc)); + } + index = header_index + 1; + } + None +} + +fn detect_ivf_prefix(prefix: &[u8]) -> Option { + if prefix.len() < 32 || &prefix[..4] != b"DKIF" { + return None; + } + match &prefix[8..12] { + b"AV01" => Some(DetectedPathTrackKind::Raw(MuxRawCodec::Av1)), + b"VP80" => Some(DetectedPathTrackKind::Raw(MuxRawCodec::Vp8)), + b"VP90" => Some(DetectedPathTrackKind::Raw(MuxRawCodec::Vp9)), + b"VP10" => Some(DetectedPathTrackKind::Raw(MuxRawCodec::Vp10)), + _ => Some(DetectedPathTrackKind::Unknown), + } +} + +fn looks_like_h263_prefix(prefix: &[u8]) -> bool { + if prefix.len() < 5 { + return false; + } + if (u32::from_be_bytes([prefix[0], prefix[1], prefix[2], prefix[3]]) >> 10) != 0x20 { + return false; + } + matches!((prefix[4] >> 2) & 0x07, 1..=5) +} + +fn looks_like_mpeg2v_prefix(prefix: &[u8]) -> bool { + let mut saw_sequence_header = false; + let mut saw_picture = false; + let mut index = 0usize; + while index + 4 <= prefix.len() { + if prefix[index..].starts_with(&[0x00, 0x00, 0x01]) { + match prefix[index + 3] { + 0xB3 => saw_sequence_header = true, + 0x00 => saw_picture = true, + _ => {} + } + if saw_sequence_header && saw_picture { + return true; + } + } + index += 1; + } + false +} + +fn looks_like_mp4v_prefix(prefix: &[u8]) -> bool { + let mut saw_vop = false; + let mut saw_config = false; + let mut index = 0usize; + while index + 4 <= prefix.len() { + if prefix[index..].starts_with(&[0x00, 0x00, 0x01]) { + let start_code = prefix[index + 3]; + if start_code == 0xB6 { + saw_vop = true; + } else if start_code == 0xB0 + || start_code == 0xB5 + || (0x20..=0x2F).contains(&start_code) + { + saw_config = true; + } + if saw_vop && saw_config { + return true; + } + } + index += 1; + } + false +} + +fn looks_like_mhas_prefix(prefix: &[u8]) -> bool { + prefix.starts_with(&[0xC0, 0x01, 0xA5]) +} + +fn detect_dts_prefix(prefix: &[u8]) -> Option { + if prefix.starts_with(&[0x7F, 0xFE, 0x80, 0x01]) + || prefix.starts_with(&[0xFE, 0x7F, 0x01, 0x80]) + || prefix.starts_with(&[0x1F, 0xFF, 0xE8, 0x00]) + || prefix.starts_with(&[0xFF, 0x1F, 0x00, 0xE8]) + { + return Some(DetectedPathTrackKind::Raw(MuxRawCodec::Dts)); + } + if prefix.starts_with(b"DTSHDHDR") { + if dts_wrapper_prefix_exposes_native_core_sync(prefix) { + return Some(DetectedPathTrackKind::Raw(MuxRawCodec::Dts)); + } + return Some(DetectedPathTrackKind::Mp4ImportOnly( + NON_CORE_DTS_IMPORT_ONLY_FAMILY, + )); + } + None +} + +fn dts_wrapper_prefix_exposes_native_core_sync(prefix: &[u8]) -> bool { + if prefix.len() <= 8 { + return false; + } + prefix[8..].windows(4).any(|window| { + matches!( + window, + [0x7F, 0xFE, 0x80, 0x01] + | [0xFE, 0x7F, 0x01, 0x80] + | [0x1F, 0xFF, 0xE8, 0x00] + | [0xFF, 0x1F, 0x00, 0xE8] + ) + }) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{ + DetectedContainerPathKind, DetectedPathTrackKind, + detect_container_path_kind_from_path_and_prefix, detect_path_track_kind_from_prefix, + }; + use crate::mux::MuxRawCodec; + + #[test] + fn dts_wrapper_prefix_with_visible_core_sync_detects_as_native_raw() { + let mut prefix = b"DTSHDHDRdemo".to_vec(); + prefix.extend_from_slice(&[0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00, 0x00]); + assert_eq!( + detect_path_track_kind_from_prefix(&prefix), + DetectedPathTrackKind::Raw(MuxRawCodec::Dts) + ); + } + + #[test] + fn dts_wrapper_prefix_without_visible_core_sync_stays_import_only() { + assert!(matches!( + detect_path_track_kind_from_prefix(b"DTSHDHDRdemo"), + DetectedPathTrackKind::Mp4ImportOnly(_) + )); + } + + #[test] + fn gsf_signature_detects_as_container_path() { + assert_eq!( + detect_container_path_kind_from_path_and_prefix(Path::new("demo.bin"), b"GS5F\x01demo"), + Some(DetectedContainerPathKind::Gsf) + ); + } + + #[test] + fn ghi_extension_detects_as_container_path() { + assert_eq!( + detect_container_path_kind_from_path_and_prefix(Path::new("demo.ghix"), b"; 16] = [ + None, + Some(8_000), + Some(16_000), + Some(32_000), + None, + None, + Some(11_025), + Some(22_050), + Some(44_100), + None, + None, + Some(12_000), + Some(24_000), + Some(48_000), + None, + None, +]; +const DTS_EXT_AUDIO_ID_VALID: [bool; 8] = [true, false, true, false, false, false, true, false]; +const DTS_CORE_CHANNELS_BY_AMODE: [u16; 16] = [1, 2, 2, 2, 2, 3, 3, 4, 4, 5, 6, 6, 7, 7, 7, 8]; +const RAW_DTS_DIRECT_INGEST_NOTE: &str = "native raw direct-ingest currently supports big-endian core DTS sync frames, little-endian core DTS sync frames, transformed 14-bit core DTS sync frames, and DTS-family wrappers that expose one contiguous core substream"; + +pub(in crate::mux) struct ParsedDtsTrack { + pub(in crate::mux) media_timescale: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, + pub(in crate::mux) transformed_source: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +struct DtsTrackDescriptor { + sample_rate: u32, + sample_duration: u32, + channel_count: u16, + sample_depth: u8, +} + +#[derive(Clone, Copy)] +struct ParsedDtsFrame { + descriptor: DtsTrackDescriptor, + frame_size: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DtsInputEncoding { + CoreBigEndian16, + CoreLittleEndian16, + CoreBigEndian14, + CoreLittleEndian14, +} + +struct NormalizedDtsStream { + descriptor: Option, + samples: Vec, + consumed_input_size: usize, + frame_input_sizes: Vec, +} + +pub(in crate::mux) fn scan_dts_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let (start_offset, encoding) = sniff_dts_payload_sync(&mut file, file_size, spec)?; + if start_offset == 0 && matches!(encoding, DtsInputEncoding::CoreBigEndian16) { + match parse_dts_stream_sync(&mut file, start_offset, file_size, spec) { + Ok(parsed) => Ok(parsed), + Err(MuxError::UnsupportedTrackImport { .. }) => parse_transformed_dts_stream_sync( + path, + &mut file, + start_offset, + file_size, + encoding, + spec, + ), + Err(error) => Err(error), + } + } else { + parse_transformed_dts_stream_sync(path, &mut file, start_offset, file_size, encoding, spec) + } +} + +pub(in crate::mux) fn scan_dts_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + parse_dts_segmented_stream_sync(file, segments, total_size, spec) +} + +fn sniff_dts_input_encoding_sync( + file: &mut File, + spec: &str, +) -> Result { + let mut sync = [0_u8; 4]; + read_exact_at_sync(file, 0, &mut sync, spec, "truncated DTS frame header")?; + dts_input_encoding_from_sync(sync, spec) +} + +pub(in crate::mux) fn wrapped_dts_family_has_native_core_sync_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result { + Ok(find_wrapped_dts_payload_sync(file, file_size, spec)?.is_some()) +} + +fn sniff_dts_payload_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result<(u64, DtsInputEncoding), MuxError> { + match sniff_dts_input_encoding_sync(file, spec) { + Ok(encoding) => Ok((0, encoding)), + Err(error) => { + if let Some((start_offset, encoding)) = + find_wrapped_dts_payload_sync(file, file_size, spec)? + { + Ok((start_offset, encoding)) + } else { + Err(error) + } + } + } +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_dts_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let (start_offset, encoding) = sniff_dts_payload_async(&mut file, file_size, spec).await?; + if start_offset == 0 && matches!(encoding, DtsInputEncoding::CoreBigEndian16) { + match parse_dts_stream_async(&mut file, start_offset, file_size, spec).await { + Ok(parsed) => Ok(parsed), + Err(MuxError::UnsupportedTrackImport { .. }) => { + parse_transformed_dts_stream_async( + path, + &mut file, + start_offset, + file_size, + encoding, + spec, + ) + .await + } + Err(error) => Err(error), + } + } else { + parse_transformed_dts_stream_async(path, &mut file, start_offset, file_size, encoding, spec) + .await + } +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn wrapped_dts_family_has_native_core_sync_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result { + Ok(find_wrapped_dts_payload_async(file, file_size, spec) + .await? + .is_some()) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_dts_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + parse_dts_segmented_stream_async(file, segments, total_size, spec).await +} + +fn parse_dts_stream_sync( + file: &mut File, + start_offset: u64, + file_size: u64, + spec: &str, +) -> Result { + let mut offset = start_offset; + let mut samples = Vec::new(); + let mut descriptor = None::; + + while offset < file_size { + if file_size - offset < DTS_MIN_HEADER_BYTES { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated DTS frame header".to_string(), + }); + } + let mut header = [0_u8; DTS_MIN_HEADER_BYTES as usize]; + read_exact_at_sync( + file, + offset, + &mut header, + spec, + "truncated DTS frame header", + )?; + let parsed = parse_dts_frame_header(&header, offset, spec)?; + let frame_size_u64 = u64::from(parsed.frame_size); + if offset + .checked_add(frame_size_u64) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated DTS frame at byte offset {offset}"), + }); + } + if let Some(current) = descriptor { + if !dts_stream_descriptor_matches(current, parsed.descriptor) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "DTS frames changed decoder configuration mid-stream".to_string(), + }); + } + } else { + descriptor = Some(parsed.descriptor); + } + samples.push(StagedSample { + data_offset: offset, + data_size: parsed.frame_size, + duration: parsed.descriptor.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(frame_size_u64) + .ok_or(MuxError::LayoutOverflow("DTS frame offset"))?; + } + + finalize_parsed_dts_track(spec, descriptor, samples, None, DTSC) +} + +fn parse_dts_segmented_stream_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut descriptor = None::; + + while offset < total_size { + if total_size - offset < DTS_MIN_HEADER_BYTES { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated DTS frame header".to_string(), + }); + } + let mut header = [0_u8; DTS_MIN_HEADER_BYTES as usize]; + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated DTS frame header", + )?; + let parsed = parse_dts_frame_header(&header, offset, spec)?; + let frame_size_u64 = u64::from(parsed.frame_size); + if offset + .checked_add(frame_size_u64) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated DTS frame at logical byte offset {offset}"), + }); + } + if let Some(current) = descriptor { + if current != parsed.descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "DTS frames changed decoder configuration mid-stream".to_string(), + }); + } + } else { + descriptor = Some(parsed.descriptor); + } + samples.push(StagedSample { + data_offset: offset, + data_size: parsed.frame_size, + duration: parsed.descriptor.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(frame_size_u64) + .ok_or(MuxError::LayoutOverflow("DTS frame offset"))?; + } + + finalize_parsed_dts_track(spec, descriptor, samples, None, DTSC) +} + +#[cfg(feature = "async")] +async fn parse_dts_stream_async( + file: &mut TokioFile, + start_offset: u64, + file_size: u64, + spec: &str, +) -> Result { + let mut offset = start_offset; + let mut samples = Vec::new(); + let mut descriptor = None::; + + while offset < file_size { + if file_size - offset < DTS_MIN_HEADER_BYTES { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated DTS frame header".to_string(), + }); + } + let mut header = [0_u8; DTS_MIN_HEADER_BYTES as usize]; + read_exact_at_async( + file, + offset, + &mut header, + spec, + "truncated DTS frame header", + ) + .await?; + let parsed = parse_dts_frame_header(&header, offset, spec)?; + let frame_size_u64 = u64::from(parsed.frame_size); + if offset + .checked_add(frame_size_u64) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated DTS frame at byte offset {offset}"), + }); + } + if let Some(current) = descriptor { + if current != parsed.descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "DTS frames changed decoder configuration mid-stream".to_string(), + }); + } + } else { + descriptor = Some(parsed.descriptor); + } + samples.push(StagedSample { + data_offset: offset, + data_size: parsed.frame_size, + duration: parsed.descriptor.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(frame_size_u64) + .ok_or(MuxError::LayoutOverflow("DTS frame offset"))?; + } + + finalize_parsed_dts_track(spec, descriptor, samples, None, DTSC) +} + +#[cfg(feature = "async")] +async fn parse_dts_segmented_stream_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut descriptor = None::; + + while offset < total_size { + if total_size - offset < DTS_MIN_HEADER_BYTES { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated DTS frame header".to_string(), + }); + } + let mut header = [0_u8; DTS_MIN_HEADER_BYTES as usize]; + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated DTS frame header", + ) + .await?; + let parsed = parse_dts_frame_header(&header, offset, spec)?; + let frame_size_u64 = u64::from(parsed.frame_size); + if offset + .checked_add(frame_size_u64) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated DTS frame at logical byte offset {offset}"), + }); + } + if let Some(current) = descriptor { + if current != parsed.descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "DTS frames changed decoder configuration mid-stream".to_string(), + }); + } + } else { + descriptor = Some(parsed.descriptor); + } + samples.push(StagedSample { + data_offset: offset, + data_size: parsed.frame_size, + duration: parsed.descriptor.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(frame_size_u64) + .ok_or(MuxError::LayoutOverflow("DTS frame offset"))?; + } + + finalize_parsed_dts_track(spec, descriptor, samples, None, DTSC) +} + +#[cfg(feature = "async")] +async fn sniff_dts_input_encoding_async( + file: &mut TokioFile, + spec: &str, +) -> Result { + let mut sync = [0_u8; 4]; + read_exact_at_async(file, 0, &mut sync, spec, "truncated DTS frame header").await?; + dts_input_encoding_from_sync(sync, spec) +} + +#[cfg(feature = "async")] +async fn sniff_dts_payload_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result<(u64, DtsInputEncoding), MuxError> { + match sniff_dts_input_encoding_async(file, spec).await { + Ok(encoding) => Ok((0, encoding)), + Err(error) => { + if let Some((start_offset, encoding)) = + find_wrapped_dts_payload_async(file, file_size, spec).await? + { + Ok((start_offset, encoding)) + } else { + Err(error) + } + } + } +} + +fn parse_transformed_dts_stream_sync( + path: &Path, + file: &mut File, + start_offset: u64, + file_size: u64, + encoding: DtsInputEncoding, + spec: &str, +) -> Result { + let wrapped_family = start_offset != 0; + if wrapped_family { + let wrapped_input = read_dts_stream_range_sync(file, 0, file_size, spec)?; + let start_offset_usize = usize::try_from(start_offset) + .map_err(|_| MuxError::LayoutOverflow("DTS wrapped start offset"))?; + let normalized = + normalize_dts_stream_bytes(&wrapped_input[start_offset_usize..], encoding, spec, true)?; + let wrapped_samples = rebuild_wrapped_dts_family_samples( + &normalized.samples, + &normalized.frame_input_sizes, + start_offset_usize, + wrapped_input.len(), + normalized.consumed_input_size, + )?; + let total_size = u64::try_from(wrapped_input.len()) + .map_err(|_| MuxError::LayoutOverflow("DTS wrapped source size"))?; + let transformed_source = SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: vec![SegmentedMuxSourceSegment { + logical_offset: 0, + data: SegmentedMuxSourceSegmentData::Bytes(wrapped_input), + }], + total_size, + }; + return finalize_parsed_dts_track( + spec, + normalized.descriptor, + wrapped_samples, + Some(transformed_source), + DTSX, + ); + } + + let input = read_dts_stream_range_sync(file, start_offset, file_size, spec)?; + let normalized = normalize_dts_stream_bytes(&input, encoding, spec, false)?; + let staged_bytes = input[..normalized.consumed_input_size].to_vec(); + let total_size = u64::try_from(staged_bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("DTS transformed source size"))?; + let transformed_source = SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: vec![SegmentedMuxSourceSegment { + logical_offset: 0, + data: SegmentedMuxSourceSegmentData::Bytes(staged_bytes), + }], + total_size, + }; + let carried_samples = rebuild_carried_dts_samples( + normalized + .frame_input_sizes + .iter() + .copied() + .zip(normalized.samples.iter().map(|sample| sample.duration)), + )?; + finalize_parsed_dts_track( + spec, + normalized.descriptor, + carried_samples, + Some(transformed_source), + DTSC, + ) +} + +#[cfg(feature = "async")] +async fn parse_transformed_dts_stream_async( + path: &Path, + file: &mut TokioFile, + start_offset: u64, + file_size: u64, + encoding: DtsInputEncoding, + spec: &str, +) -> Result { + let wrapped_family = start_offset != 0; + if wrapped_family { + let wrapped_input = read_dts_stream_range_async(file, 0, file_size, spec).await?; + let start_offset_usize = usize::try_from(start_offset) + .map_err(|_| MuxError::LayoutOverflow("DTS wrapped start offset"))?; + let normalized = + normalize_dts_stream_bytes(&wrapped_input[start_offset_usize..], encoding, spec, true)?; + let wrapped_samples = rebuild_wrapped_dts_family_samples( + &normalized.samples, + &normalized.frame_input_sizes, + start_offset_usize, + wrapped_input.len(), + normalized.consumed_input_size, + )?; + let total_size = u64::try_from(wrapped_input.len()) + .map_err(|_| MuxError::LayoutOverflow("DTS wrapped source size"))?; + let transformed_source = SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: vec![SegmentedMuxSourceSegment { + logical_offset: 0, + data: SegmentedMuxSourceSegmentData::Bytes(wrapped_input), + }], + total_size, + }; + return finalize_parsed_dts_track( + spec, + normalized.descriptor, + wrapped_samples, + Some(transformed_source), + DTSX, + ); + } + + let input = read_dts_stream_range_async(file, start_offset, file_size, spec).await?; + let normalized = normalize_dts_stream_bytes(&input, encoding, spec, false)?; + let staged_bytes = input[..normalized.consumed_input_size].to_vec(); + let total_size = u64::try_from(staged_bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("DTS transformed source size"))?; + let transformed_source = SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: vec![SegmentedMuxSourceSegment { + logical_offset: 0, + data: SegmentedMuxSourceSegmentData::Bytes(staged_bytes), + }], + total_size, + }; + let carried_samples = rebuild_carried_dts_samples( + normalized + .frame_input_sizes + .iter() + .copied() + .zip(normalized.samples.iter().map(|sample| sample.duration)), + )?; + finalize_parsed_dts_track( + spec, + normalized.descriptor, + carried_samples, + Some(transformed_source), + DTSC, + ) +} + +fn read_dts_stream_range_sync( + file: &mut File, + start_offset: u64, + file_size: u64, + spec: &str, +) -> Result, MuxError> { + let payload_size = file_size + .checked_sub(start_offset) + .ok_or(MuxError::LayoutOverflow("DTS input range"))?; + let mut bytes = vec![ + 0_u8; + usize::try_from(payload_size) + .map_err(|_| MuxError::LayoutOverflow("DTS input byte capacity"))? + ]; + if !bytes.is_empty() { + read_exact_at_sync( + file, + start_offset, + &mut bytes, + spec, + "truncated DTS input stream", + )?; + } + Ok(bytes) +} + +#[cfg(feature = "async")] +async fn read_dts_stream_range_async( + file: &mut TokioFile, + start_offset: u64, + file_size: u64, + spec: &str, +) -> Result, MuxError> { + let payload_size = file_size + .checked_sub(start_offset) + .ok_or(MuxError::LayoutOverflow("DTS input range"))?; + let mut bytes = vec![ + 0_u8; + usize::try_from(payload_size) + .map_err(|_| MuxError::LayoutOverflow("DTS input byte capacity"))? + ]; + if !bytes.is_empty() { + read_exact_at_async( + file, + start_offset, + &mut bytes, + spec, + "truncated DTS input stream", + ) + .await?; + } + Ok(bytes) +} + +fn normalize_dts_stream_bytes( + input: &[u8], + encoding: DtsInputEncoding, + spec: &str, + allow_non_core_tail: bool, +) -> Result { + let mut input_offset = 0usize; + let mut output = Vec::new(); + let mut samples = Vec::new(); + let mut descriptor = None::; + let mut frame_input_sizes = Vec::new(); + + while input_offset < input.len() { + if allow_non_core_tail + && descriptor.is_some() + && !input_starts_with_dts_encoding(input, input_offset, encoding) + { + break; + } + let frame_start = input_offset; + let (normalized_frame, mut parsed, frame_input_size) = + normalize_one_dts_frame(input, input_offset, encoding, spec)?; + let mut next_input_offset = frame_start + .checked_add(frame_input_size) + .ok_or(MuxError::LayoutOverflow("DTS transformed input offset"))?; + let mut sample_input_size = frame_input_size; + if let Some(next_sync_offset) = find_next_valid_dts_encoding_sync( + input, + frame_start.saturating_add(1), + encoding, + descriptor, + spec, + ) + .filter(|next_sync_offset| *next_sync_offset < next_input_offset) + { + sample_input_size = next_sync_offset + .checked_sub(frame_start) + .ok_or(MuxError::LayoutOverflow("DTS carried frame span"))?; + next_input_offset = next_sync_offset; + } + if next_input_offset < input.len() + && !input_starts_with_dts_encoding(input, next_input_offset, encoding) + { + if let Some(next_sync_offset) = find_next_valid_dts_encoding_sync( + input, + next_input_offset, + encoding, + descriptor, + spec, + ) { + sample_input_size = next_sync_offset + .checked_sub(frame_start) + .ok_or(MuxError::LayoutOverflow("DTS carried frame span"))?; + next_input_offset = next_sync_offset; + } else if allow_non_core_tail { + break; + } else if descriptor.is_some() { + sample_input_size = input + .len() + .checked_sub(frame_start) + .ok_or(MuxError::LayoutOverflow("DTS carried frame tail"))?; + next_input_offset = input.len(); + } else { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "missing core DTS sync word at byte offset {next_input_offset}" + ), + }); + } + } + if sample_input_size > frame_input_size { + parsed.descriptor.sample_depth = 24; + } + if let Some(current) = descriptor { + if !dts_stream_descriptor_matches(current, parsed.descriptor) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "DTS frames changed decoder configuration mid-stream".to_string(), + }); + } + } else { + descriptor = Some(parsed.descriptor); + } + let data_offset = u64::try_from(output.len()) + .map_err(|_| MuxError::LayoutOverflow("DTS transformed output offset"))?; + output.extend_from_slice(&normalized_frame); + frame_input_sizes.push( + u32::try_from(sample_input_size) + .map_err(|_| MuxError::LayoutOverflow("DTS transformed frame input size"))?, + ); + samples.push(StagedSample { + data_offset, + data_size: parsed.frame_size, + duration: parsed.descriptor.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + input_offset = next_input_offset; + } + + Ok(NormalizedDtsStream { + descriptor, + samples, + consumed_input_size: input_offset, + frame_input_sizes, + }) +} + +fn input_starts_with_dts_encoding( + input: &[u8], + input_offset: usize, + encoding: DtsInputEncoding, +) -> bool { + let Some(prefix) = input.get(input_offset..input_offset.saturating_add(4)) else { + return false; + }; + prefix == dts_encoding_sync_bytes(encoding) +} + +fn find_next_valid_dts_encoding_sync( + input: &[u8], + input_offset: usize, + encoding: DtsInputEncoding, + expected_descriptor: Option, + spec: &str, +) -> Option { + let sync = dts_encoding_sync_bytes(encoding); + let suffix = input.get(input_offset..)?; + for candidate_offset in suffix + .windows(sync.len()) + .enumerate() + .filter_map(|(offset, window)| (window == sync).then_some(input_offset + offset)) + { + let Ok((_, parsed, _)) = normalize_one_dts_frame(input, candidate_offset, encoding, spec) + else { + continue; + }; + if expected_descriptor + .is_none_or(|descriptor| dts_sync_descriptor_matches(descriptor, parsed.descriptor)) + { + return Some(candidate_offset); + } + } + None +} + +fn dts_sync_descriptor_matches( + expected: DtsTrackDescriptor, + candidate: DtsTrackDescriptor, +) -> bool { + expected.sample_rate == candidate.sample_rate + && expected.sample_duration == candidate.sample_duration + && expected.channel_count == candidate.channel_count +} + +fn dts_stream_descriptor_matches( + expected: DtsTrackDescriptor, + candidate: DtsTrackDescriptor, +) -> bool { + dts_sync_descriptor_matches(expected, candidate) +} + +fn dts_encoding_sync_bytes(encoding: DtsInputEncoding) -> &'static [u8; 4] { + match encoding { + DtsInputEncoding::CoreBigEndian16 => b"\x7F\xFE\x80\x01", + DtsInputEncoding::CoreLittleEndian16 => b"\xFE\x7F\x01\x80", + DtsInputEncoding::CoreBigEndian14 => b"\x1F\xFF\xE8\x00", + DtsInputEncoding::CoreLittleEndian14 => b"\xFF\x1F\x00\xE8", + } +} + +fn normalize_one_dts_frame( + input: &[u8], + input_offset: usize, + encoding: DtsInputEncoding, + spec: &str, +) -> Result<(Vec, ParsedDtsFrame, usize), MuxError> { + let normalized_header = normalize_dts_header_prefix(input, input_offset, encoding, spec)?; + let parsed = parse_dts_frame_header( + &normalized_header, + u64::try_from(input_offset).map_err(|_| MuxError::LayoutOverflow("DTS input offset"))?, + spec, + )?; + let normalized_frame_size = usize::try_from(parsed.frame_size) + .map_err(|_| MuxError::LayoutOverflow("DTS frame size"))?; + let mut frame_input_size = match encoding { + DtsInputEncoding::CoreBigEndian16 | DtsInputEncoding::CoreLittleEndian16 => { + normalized_frame_size + } + DtsInputEncoding::CoreBigEndian14 | DtsInputEncoding::CoreLittleEndian14 => { + packed_14bit_frame_size(normalized_frame_size)? + } + }; + let mut frame_end = input_offset + .checked_add(frame_input_size) + .ok_or(MuxError::LayoutOverflow("DTS frame end"))?; + if frame_end > input.len() { + if matches!( + encoding, + DtsInputEncoding::CoreBigEndian16 | DtsInputEncoding::CoreLittleEndian16 + ) { + frame_end = input.len(); + frame_input_size = input + .len() + .checked_sub(input_offset) + .ok_or(MuxError::LayoutOverflow("DTS frame end"))?; + } else { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated DTS frame at byte offset {input_offset}"), + }); + } + } + let normalized_frame = match encoding { + DtsInputEncoding::CoreBigEndian16 => input[input_offset..frame_end].to_vec(), + DtsInputEncoding::CoreLittleEndian16 => { + swap_dts_16bit_words(&input[input_offset..frame_end], spec, input_offset)? + } + DtsInputEncoding::CoreBigEndian14 => unpack_dts_14bit_words( + &input[input_offset..frame_end], + false, + normalized_frame_size, + )?, + DtsInputEncoding::CoreLittleEndian14 => { + unpack_dts_14bit_words(&input[input_offset..frame_end], true, normalized_frame_size)? + } + }; + Ok((normalized_frame, parsed, frame_input_size)) +} + +fn normalize_dts_header_prefix( + input: &[u8], + input_offset: usize, + encoding: DtsInputEncoding, + spec: &str, +) -> Result<[u8; DTS_MIN_HEADER_BYTES as usize], MuxError> { + match encoding { + DtsInputEncoding::CoreBigEndian16 => { + let header_end = input_offset + .checked_add(DTS_MIN_HEADER_BYTES as usize) + .ok_or(MuxError::LayoutOverflow("DTS header end"))?; + let Some(header) = input.get(input_offset..header_end) else { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated DTS frame header".to_string(), + }); + }; + Ok(header.try_into().unwrap()) + } + DtsInputEncoding::CoreLittleEndian16 => { + let header_end = input_offset + .checked_add(DTS_MIN_HEADER_BYTES as usize + 1) + .ok_or(MuxError::LayoutOverflow("DTS header end"))?; + let Some(header) = input.get(input_offset..header_end) else { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated DTS frame header".to_string(), + }); + }; + let swapped = swap_dts_16bit_words(header, spec, input_offset)?; + Ok(swapped[..DTS_MIN_HEADER_BYTES as usize].try_into().unwrap()) + } + DtsInputEncoding::CoreBigEndian14 | DtsInputEncoding::CoreLittleEndian14 => { + let header_input_size = packed_14bit_frame_size(DTS_MIN_HEADER_BYTES as usize)?; + let header_end = input_offset + .checked_add(header_input_size) + .ok_or(MuxError::LayoutOverflow("DTS header end"))?; + let Some(header) = input.get(input_offset..header_end) else { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated DTS frame header".to_string(), + }); + }; + let unpacked = unpack_dts_14bit_words( + header, + matches!(encoding, DtsInputEncoding::CoreLittleEndian14), + DTS_MIN_HEADER_BYTES as usize, + )?; + Ok(unpacked.try_into().unwrap()) + } + } +} + +fn find_wrapped_dts_payload_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result, MuxError> { + let scan_size = file_size.min(DTS_FAMILY_CORE_SCAN_LIMIT); + let scan_size = usize::try_from(scan_size) + .map_err(|_| MuxError::LayoutOverflow("DTS wrapped scan size"))?; + if scan_size < DTS_FAMILY_WRAPPER_HEADER.len() + 4 { + return Ok(None); + } + let mut prefix = vec![0_u8; scan_size]; + read_exact_at_sync( + file, + 0, + &mut prefix, + spec, + "truncated DTS-family input stream", + )?; + Ok(find_wrapped_dts_payload_in_bytes(&prefix)) +} + +#[cfg(feature = "async")] +async fn find_wrapped_dts_payload_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result, MuxError> { + let scan_size = file_size.min(DTS_FAMILY_CORE_SCAN_LIMIT); + let scan_size = usize::try_from(scan_size) + .map_err(|_| MuxError::LayoutOverflow("DTS wrapped scan size"))?; + if scan_size < DTS_FAMILY_WRAPPER_HEADER.len() + 4 { + return Ok(None); + } + let mut prefix = vec![0_u8; scan_size]; + read_exact_at_async( + file, + 0, + &mut prefix, + spec, + "truncated DTS-family input stream", + ) + .await?; + Ok(find_wrapped_dts_payload_in_bytes(&prefix)) +} + +fn find_wrapped_dts_payload_in_bytes(bytes: &[u8]) -> Option<(u64, DtsInputEncoding)> { + if !bytes.starts_with(DTS_FAMILY_WRAPPER_HEADER) { + return None; + } + (DTS_FAMILY_WRAPPER_HEADER.len()..bytes.len().saturating_sub(3)).find_map(|offset| { + let encoding = dts_input_encoding_from_sync_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ])?; + Some((u64::try_from(offset).unwrap(), encoding)) + }) +} + +fn dts_input_encoding_from_sync_bytes(sync: [u8; 4]) -> Option { + match sync { + [0x7F, 0xFE, 0x80, 0x01] => Some(DtsInputEncoding::CoreBigEndian16), + [0xFE, 0x7F, 0x01, 0x80] => Some(DtsInputEncoding::CoreLittleEndian16), + [0x1F, 0xFF, 0xE8, 0x00] => Some(DtsInputEncoding::CoreBigEndian14), + [0xFF, 0x1F, 0x00, 0xE8] => Some(DtsInputEncoding::CoreLittleEndian14), + _ => None, + } +} + +fn dts_input_encoding_from_sync(sync: [u8; 4], spec: &str) -> Result { + dts_input_encoding_from_sync_bytes(sync).ok_or_else(|| { + unsupported_raw_dts( + spec, + "missing core DTS sync word at byte offset 0".to_string(), + ) + }) +} + +fn swap_dts_16bit_words( + input: &[u8], + spec: &str, + input_offset: usize, +) -> Result, MuxError> { + if !input.len().is_multiple_of(2) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "little-endian DTS frame at byte offset {input_offset} had an odd byte length" + ), + }); + } + let mut output = vec![0_u8; input.len()]; + for (word, chunk) in input.chunks_exact(2).enumerate() { + output[word * 2] = chunk[1]; + output[word * 2 + 1] = chunk[0]; + } + Ok(output) +} + +fn packed_14bit_frame_size(normalized_frame_size: usize) -> Result { + let payload_bits = normalized_frame_size + .checked_mul(8) + .ok_or(MuxError::LayoutOverflow("DTS 14-bit payload bits"))?; + let words = payload_bits.div_ceil(14); + words + .checked_mul(2) + .ok_or(MuxError::LayoutOverflow("DTS 14-bit packed frame size")) +} + +fn unpack_dts_14bit_words( + input: &[u8], + little_endian: bool, + output_size: usize, +) -> Result, MuxError> { + if !input.len().is_multiple_of(2) { + return Err(MuxError::LayoutOverflow("DTS 14-bit input word size")); + } + + let mut output = Vec::with_capacity(output_size.saturating_add(2)); + let mut bit_buffer = 0_u64; + let mut buffered_bits = 0usize; + for chunk in input.chunks_exact(2) { + let word = if little_endian { + u16::from_le_bytes([chunk[0], chunk[1]]) + } else { + u16::from_be_bytes([chunk[0], chunk[1]]) + }; + bit_buffer = (bit_buffer << 14) | u64::from(word & 0x3FFF); + buffered_bits += 14; + while buffered_bits >= 8 { + buffered_bits -= 8; + output.push(((bit_buffer >> buffered_bits) & 0xFF) as u8); + } + } + output.truncate(output_size); + Ok(output) +} + +fn finalize_parsed_dts_track( + spec: &str, + descriptor: Option, + samples: Vec, + transformed_source: Option, + sample_entry_type: FourCc, +) -> Result { + let descriptor = descriptor.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "DTS input contained no frames".to_string(), + })?; + let samples = samples + .into_iter() + .map(|sample| { + let duration = u64::from(sample.duration) + .checked_mul(u64::from(DTS_MEDIA_TIMESCALE)) + .ok_or(MuxError::LayoutOverflow("DTS media duration"))? + / u64::from(descriptor.sample_rate); + let duration = u32::try_from(duration) + .map_err(|_| MuxError::LayoutOverflow("DTS media duration"))?; + if duration == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "DTS frame duration underflowed after media-timescale normalization" + .to_string(), + }); + } + Ok(StagedSample { duration, ..sample }) + }) + .collect::, _>>()?; + if samples.iter().all(|sample| sample.duration == 0) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "DTS input contained frames with zero duration".to_string(), + }); + } + Ok(ParsedDtsTrack { + media_timescale: DTS_MEDIA_TIMESCALE, + sample_entry_box: build_dts_sample_entry_box( + descriptor, + build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + DTS_MEDIA_TIMESCALE, + )?, + sample_entry_type, + )?, + samples, + transformed_source, + }) +} + +fn rebuild_carried_dts_samples( + sample_spans_and_durations: I, +) -> Result, MuxError> +where + I: IntoIterator, +{ + let mut data_offset = 0_u64; + let mut samples = Vec::new(); + for (data_size, duration) in sample_spans_and_durations { + samples.push(StagedSample { + data_offset, + data_size, + duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + data_offset = data_offset + .checked_add(u64::from(data_size)) + .ok_or(MuxError::LayoutOverflow("DTS carried sample offset"))?; + } + Ok(samples) +} + +fn rebuild_wrapped_dts_family_samples( + normalized_samples: &[StagedSample], + frame_input_sizes: &[u32], + wrapper_prefix_size: usize, + wrapped_input_size: usize, + consumed_core_input_size: usize, +) -> Result, MuxError> { + if normalized_samples.len() != frame_input_sizes.len() { + return Err(MuxError::LayoutOverflow( + "wrapped DTS sample and frame-size mismatch", + )); + } + let wrapper_prefix_size = u32::try_from(wrapper_prefix_size) + .map_err(|_| MuxError::LayoutOverflow("wrapped DTS prefix size"))?; + let trailing_family_tail_size = wrapped_input_size + .checked_sub(usize::try_from(wrapper_prefix_size).unwrap()) + .and_then(|value| value.checked_sub(consumed_core_input_size)) + .ok_or(MuxError::LayoutOverflow("wrapped DTS trailing tail size"))?; + let trailing_family_tail_size = u32::try_from(trailing_family_tail_size) + .map_err(|_| MuxError::LayoutOverflow("wrapped DTS trailing tail size"))?; + let mut data_offset = 0_u64; + normalized_samples + .iter() + .zip(frame_input_sizes) + .enumerate() + .map(|(index, (sample, frame_input_size))| { + let mut data_size = *frame_input_size; + if index == 0 { + data_size = data_size + .checked_add(wrapper_prefix_size) + .ok_or(MuxError::LayoutOverflow("wrapped DTS first-sample size"))?; + } + if index + 1 == normalized_samples.len() { + data_size = data_size + .checked_add(trailing_family_tail_size) + .ok_or(MuxError::LayoutOverflow("wrapped DTS last-sample size"))?; + } + let rebuilt = StagedSample { + data_offset, + data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }; + data_offset = data_offset + .checked_add(u64::from(data_size)) + .ok_or(MuxError::LayoutOverflow("wrapped DTS sample offset"))?; + Ok(rebuilt) + }) + .collect() +} + +fn parse_dts_frame_header( + header: &[u8; DTS_MIN_HEADER_BYTES as usize], + offset: u64, + spec: &str, +) -> Result { + let mut reader = BitReader::new(Cursor::new(header.as_slice())); + let sync_word = u32::from_be_bytes(read_bits_exact::<4, _>(&mut reader, spec, "DTS")?); + if sync_word != DTS_SYNC_WORD { + return Err(unsupported_raw_dts( + spec, + format!("missing core DTS sync word at byte offset {offset}"), + )); + } + skip_bits_labeled(&mut reader, 1 + 5, spec, "DTS")?; + if read_bit_labeled(&mut reader, spec, "DTS")? { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "DTS frames with CRC protection are not supported".to_string(), + }); + } + let blocks_per_frame_minus_one = read_bits_u8_labeled(&mut reader, 7, spec, "DTS")?; + if blocks_per_frame_minus_one < 5 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "unsupported DTS PCM sample-block count {}", + blocks_per_frame_minus_one + 1 + ), + }); + } + let frame_size_minus_one = read_bits_u16_labeled(&mut reader, 14, spec, "DTS")?; + if frame_size_minus_one < 95 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "unsupported DTS frame size {}", + u32::from(frame_size_minus_one) + 1 + ), + }); + } + let amode = read_bits_u8_labeled(&mut reader, 6, spec, "DTS")?; + let sample_rate_code = read_bits_u8_labeled(&mut reader, 4, spec, "DTS")?; + let sample_rate = DTS_SAMPLE_RATE_BY_CODE + .get(usize::from(sample_rate_code)) + .and_then(|value| *value) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported DTS sample-rate code {sample_rate_code}"), + })?; + let bitrate_code = read_bits_u8_labeled(&mut reader, 5, spec, "DTS")?; + if bitrate_code > 25 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported DTS bitrate code {bitrate_code}"), + }); + } + let reserved = read_bit_labeled(&mut reader, spec, "DTS")?; + if reserved { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "reserved DTS header bit was set".to_string(), + }); + } + skip_bits_labeled(&mut reader, 1 + 1 + 1 + 1, spec, "DTS")?; + let ext_audio_id = read_bits_u8_labeled(&mut reader, 3, spec, "DTS")?; + if !DTS_EXT_AUDIO_ID_VALID[usize::from(ext_audio_id)] { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported DTS extension-audio descriptor flag {ext_audio_id}"), + }); + } + skip_bits_labeled(&mut reader, 1 + 1, spec, "DTS")?; + let lfe_flag = read_bits_u8_labeled(&mut reader, 2, spec, "DTS")?; + if lfe_flag == 3 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "reserved DTS low-frequency-effects flag value".to_string(), + }); + } + + let sample_duration = u32::from(blocks_per_frame_minus_one + 1) * 32; + dts_frame_duration_code(sample_duration).ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported DTS frame duration {sample_duration}"), + })?; + let channel_count = dts_channel_count(amode, lfe_flag != 0).ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported DTS channel arrangement code {amode}"), + } + })?; + let frame_size = u32::from(frame_size_minus_one) + 1; + Ok(ParsedDtsFrame { + descriptor: DtsTrackDescriptor { + sample_rate, + sample_duration, + channel_count, + sample_depth: 16, + }, + frame_size, + }) +} + +fn build_dts_sample_entry_box( + descriptor: DtsTrackDescriptor, + btrt: Btrt, + sample_entry_type: FourCc, +) -> Result, MuxError> { + let mut sample_entry = AudioSampleEntry::default(); + sample_entry.set_box_type(sample_entry_type); + sample_entry.sample_entry = SampleEntry { + box_type: sample_entry_type, + data_reference_index: 1, + }; + sample_entry.channel_count = descriptor.channel_count; + sample_entry.sample_size = u16::from(descriptor.sample_depth); + sample_entry.sample_rate = if sample_entry_type == DTSX { + 0 + } else { + descriptor.sample_rate << 16 + }; + + let child_box_bytes = super::super::mp4::encode_typed_box(&btrt, &[])?; + super::super::mp4::encode_typed_box(&sample_entry, &child_box_bytes) +} + +/// Rewrites carried transport DTS sample entries onto the transport-oriented box type. +pub(in crate::mux) fn retune_carried_dts_sample_entry_box( + sample_entry_box: &[u8], +) -> Result, MuxError> { + if sample_entry_box.len() < 36 { + return Err(MuxError::UnsupportedTrackImport { + spec: "dts".to_string(), + message: + "carried DTS sample entry is truncated before the fixed audio sample-entry fields" + .to_string(), + }); + } + if sample_entry_box[4..8] != DTSC.into_bytes() { + return Err(MuxError::UnsupportedTrackImport { + spec: "dts".to_string(), + message: "carried DTS sample entry did not use the expected core DTS box type" + .to_string(), + }); + } + + let mut rebuilt = sample_entry_box.to_vec(); + rebuilt[4..8].copy_from_slice(&DTSX.into_bytes()); + rebuilt[24..26].copy_from_slice(&2_u16.to_be_bytes()); + rebuilt[32..36].copy_from_slice(&0_u32.to_be_bytes()); + Ok(rebuilt) +} + +const fn dts_frame_duration_code(sample_duration: u32) -> Option { + match sample_duration { + 512 => Some(0), + 1024 => Some(1), + 2048 => Some(2), + 4096 => Some(3), + _ => None, + } +} + +const fn dts_channel_count(amode: u8, lfe_present: bool) -> Option { + if amode > 15 { + return None; + } + Some(DTS_CORE_CHANNELS_BY_AMODE[amode as usize] + if lfe_present { 1 } else { 0 }) +} + +fn skip_bits_labeled( + reader: &mut BitReader, + width: usize, + spec: &str, + label: &str, +) -> Result<(), MuxError> +where + R: std::io::Read, +{ + reader + .read_bits(width) + .map(|_| ()) + .map_err(|error| truncated_dts_error(spec, label, error)) +} + +fn read_bit_labeled(reader: &mut BitReader, spec: &str, label: &str) -> Result +where + R: std::io::Read, +{ + reader + .read_bit() + .map_err(|error| truncated_dts_error(spec, label, error)) +} + +fn read_bits_u8_labeled( + reader: &mut BitReader, + width: usize, + spec: &str, + label: &str, +) -> Result +where + R: std::io::Read, +{ + let bytes = reader + .read_bits(width) + .map_err(|error| truncated_dts_error(spec, label, error))?; + Ok(bytes[0]) +} + +fn read_bits_u16_labeled( + reader: &mut BitReader, + width: usize, + spec: &str, + label: &str, +) -> Result +where + R: std::io::Read, +{ + let bytes = reader + .read_bits(width) + .map_err(|error| truncated_dts_error(spec, label, error))?; + Ok(u16::from_be_bytes([bytes[0], bytes[1]])) +} + +fn read_bits_exact( + reader: &mut BitReader, + spec: &str, + label: &str, +) -> Result<[u8; N], MuxError> +where + R: std::io::Read, +{ + let bytes = reader + .read_bits(N * 8) + .map_err(|error| truncated_dts_error(spec, label, error))?; + Ok(bytes.try_into().unwrap()) +} + +fn truncated_dts_error(spec: &str, label: &str, error: std::io::Error) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("{label} parsing failed: {error}"), + } +} + +fn unsupported_raw_dts(spec: &str, message: String) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("{message}; {RAW_DTS_DIRECT_INGEST_NOTE}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dts_core_sample_entry_uses_btrt_child() { + let sample_entry_box = build_dts_sample_entry_box( + DtsTrackDescriptor { + sample_rate: 48_000, + sample_duration: 4096, + channel_count: 4, + sample_depth: 24, + }, + Btrt::default(), + DTSC, + ) + .unwrap(); + + assert!(sample_entry_box.windows(4).any(|window| window == b"btrt")); + assert!(!sample_entry_box.windows(4).any(|window| window == b"ddts")); + } +} diff --git a/src/mux/demux/eac3.rs b/src/mux/demux/eac3.rs new file mode 100644 index 0000000..b803311 --- /dev/null +++ b/src/mux/demux/eac3.rs @@ -0,0 +1,1323 @@ +use std::fs::File; +use std::io::Cursor; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::bitio::BitReader; +use crate::boxes::AnyTypeBox; +use crate::boxes::etsi_ts_102_366::{Dec3, Ec3Substream}; +use crate::boxes::iso14496_12::{AudioSampleEntry, Btrt, SampleEntry}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{SegmentedMuxSourceSegment, StagedSample, read_exact_at_sync}; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::read_segmented_bytes_sync; + +pub(in crate::mux) struct ParsedEac3Track { + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) decoder_config: Eac3DecoderConfig, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::mux) struct Eac3DecoderConfig { + sample_rate: u32, + channel_count: u16, + fscod: u8, + bsid: u8, + bsmod: u8, + acmod: u8, + lfe_on: u8, + num_dep_sub: u8, + chan_loc: u16, + atmos_ec3_ext: u8, + complexity_index_type: u8, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ParsedEac3Syncframe { + decoder_config: Eac3DecoderConfig, + frame_size: u64, + sample_duration: u32, + stream_type: u8, + substream_id: u8, +} + +pub(in crate::mux) fn scan_eac3_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::; + while offset < file_size { + if file_size - offset < 6 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated E-AC-3 syncframe header".to_string(), + }); + } + let mut header = [0_u8; 6]; + read_exact_at_sync( + &mut file, + offset, + &mut header, + spec, + "truncated E-AC-3 syncframe header", + )?; + let syncframe_header = parse_eac3_frame_header(&header, offset, spec)?; + if offset + .checked_add(syncframe_header.frame_size) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated E-AC-3 syncframe at byte offset {offset}"), + }); + } + let mut frame = vec![ + 0_u8; + usize::try_from(syncframe_header.frame_size) + .map_err(|_| MuxError::LayoutOverflow("E-AC-3 frame size"))? + ]; + read_exact_at_sync( + &mut file, + offset, + &mut frame, + spec, + "truncated E-AC-3 syncframe", + )?; + let syncframe = parse_eac3_syncframe(&frame, offset, spec)?; + if syncframe.stream_type != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "raw E-AC-3 access units must begin with an independent substream" + .to_string(), + }); + } + let mut decoder_config = syncframe.decoder_config; + let mut access_unit_size = syncframe.frame_size; + let sample_duration = syncframe.sample_duration; + let mut next_offset = offset + .checked_add(syncframe.frame_size) + .ok_or(MuxError::LayoutOverflow("E-AC-3 frame offset"))?; + while next_offset < file_size { + if file_size - next_offset < 6 { + break; + } + let mut next_header = [0_u8; 6]; + read_exact_at_sync( + &mut file, + next_offset, + &mut next_header, + spec, + "truncated E-AC-3 syncframe header", + )?; + let next_syncframe_header = parse_eac3_frame_header(&next_header, next_offset, spec)?; + if next_syncframe_header.stream_type != 1 + || next_syncframe_header.substream_id != syncframe.substream_id + { + break; + } + if sample_duration != next_syncframe_header.sample_duration { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "E-AC-3 dependent substream changed frame duration mid-stream" + .to_string(), + }); + } + if next_offset + .checked_add(next_syncframe_header.frame_size) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated E-AC-3 syncframe at byte offset {next_offset}"), + }); + } + let mut next_frame = vec![ + 0_u8; + usize::try_from(next_syncframe_header.frame_size).map_err( + |_| MuxError::LayoutOverflow("E-AC-3 frame size") + )? + ]; + read_exact_at_sync( + &mut file, + next_offset, + &mut next_frame, + spec, + "truncated E-AC-3 syncframe", + )?; + let dependent_syncframe = parse_eac3_syncframe(&next_frame, next_offset, spec)?; + merge_eac3_dependent_substream( + &mut decoder_config, + &dependent_syncframe.decoder_config, + dependent_syncframe.substream_id, + spec, + )?; + access_unit_size = access_unit_size + .checked_add(dependent_syncframe.frame_size) + .ok_or(MuxError::LayoutOverflow("E-AC-3 access unit size"))?; + next_offset = next_offset + .checked_add(dependent_syncframe.frame_size) + .ok_or(MuxError::LayoutOverflow("E-AC-3 frame offset"))?; + } + if let Some(current) = &expected { + if !same_eac3_config(current, &decoder_config) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "E-AC-3 syncframes changed decoder configuration mid-stream" + .to_string(), + }); + } + } else { + expected = Some(decoder_config); + } + samples.push(StagedSample { + data_offset: offset, + data_size: u32::try_from(access_unit_size) + .map_err(|_| MuxError::LayoutOverflow("E-AC-3 access unit size"))?, + duration: sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = next_offset; + } + + let decoder_config = expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "E-AC-3 input contained no syncframes".to_string(), + })?; + Ok(ParsedEac3Track { + sample_rate: decoder_config.sample_rate, + decoder_config, + sample_entry_box: build_eac3_sample_entry_box(&decoder_config, &samples)?, + samples, + }) +} + +pub(in crate::mux) fn scan_eac3_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::; + while offset < total_size { + if total_size - offset < 6 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated E-AC-3 syncframe header".to_string(), + }); + } + let mut header = [0_u8; 6]; + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated E-AC-3 syncframe header", + )?; + let syncframe_header = parse_eac3_frame_header(&header, offset, spec)?; + if offset + .checked_add(syncframe_header.frame_size) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated E-AC-3 syncframe at logical byte offset {offset}"), + }); + } + let mut frame = vec![ + 0_u8; + usize::try_from(syncframe_header.frame_size) + .map_err(|_| MuxError::LayoutOverflow("E-AC-3 frame size"))? + ]; + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut frame, + spec, + "truncated E-AC-3 syncframe", + )?; + let syncframe = parse_eac3_syncframe(&frame, offset, spec)?; + if syncframe.stream_type != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "raw E-AC-3 access units must begin with an independent substream" + .to_string(), + }); + } + let mut decoder_config = syncframe.decoder_config; + let mut access_unit_size = syncframe.frame_size; + let sample_duration = syncframe.sample_duration; + let mut next_offset = offset + .checked_add(syncframe.frame_size) + .ok_or(MuxError::LayoutOverflow("E-AC-3 frame offset"))?; + while next_offset < total_size { + if total_size - next_offset < 6 { + break; + } + let mut next_header = [0_u8; 6]; + read_segmented_bytes_sync( + file, + segments, + total_size, + next_offset, + &mut next_header, + spec, + "truncated E-AC-3 syncframe header", + )?; + let next_syncframe_header = parse_eac3_frame_header(&next_header, next_offset, spec)?; + if next_syncframe_header.stream_type != 1 + || next_syncframe_header.substream_id != syncframe.substream_id + { + break; + } + if sample_duration != next_syncframe_header.sample_duration { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "E-AC-3 dependent substream changed frame duration mid-stream" + .to_string(), + }); + } + if next_offset + .checked_add(next_syncframe_header.frame_size) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "truncated E-AC-3 syncframe at logical byte offset {next_offset}" + ), + }); + } + let mut next_frame = vec![ + 0_u8; + usize::try_from(next_syncframe_header.frame_size).map_err( + |_| MuxError::LayoutOverflow("E-AC-3 frame size") + )? + ]; + read_segmented_bytes_sync( + file, + segments, + total_size, + next_offset, + &mut next_frame, + spec, + "truncated E-AC-3 syncframe", + )?; + let dependent_syncframe = parse_eac3_syncframe(&next_frame, next_offset, spec)?; + merge_eac3_dependent_substream( + &mut decoder_config, + &dependent_syncframe.decoder_config, + dependent_syncframe.substream_id, + spec, + )?; + access_unit_size = access_unit_size + .checked_add(dependent_syncframe.frame_size) + .ok_or(MuxError::LayoutOverflow("E-AC-3 access unit size"))?; + next_offset = next_offset + .checked_add(dependent_syncframe.frame_size) + .ok_or(MuxError::LayoutOverflow("E-AC-3 frame offset"))?; + } + if let Some(current) = &expected { + if !same_eac3_config(current, &decoder_config) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "E-AC-3 syncframes changed decoder configuration mid-stream" + .to_string(), + }); + } + } else { + expected = Some(decoder_config); + } + samples.push(StagedSample { + data_offset: offset, + data_size: u32::try_from(access_unit_size) + .map_err(|_| MuxError::LayoutOverflow("E-AC-3 access unit size"))?, + duration: sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = next_offset; + } + + let decoder_config = expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "E-AC-3 input contained no syncframes".to_string(), + })?; + Ok(ParsedEac3Track { + sample_rate: decoder_config.sample_rate, + decoder_config, + sample_entry_box: build_eac3_sample_entry_box(&decoder_config, &samples)?, + samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_eac3_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::; + while offset < file_size { + if file_size - offset < 6 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated E-AC-3 syncframe header".to_string(), + }); + } + let mut header = [0_u8; 6]; + read_exact_at_async( + &mut file, + offset, + &mut header, + spec, + "truncated E-AC-3 syncframe header", + ) + .await?; + let syncframe_header = parse_eac3_frame_header(&header, offset, spec)?; + if offset + .checked_add(syncframe_header.frame_size) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated E-AC-3 syncframe at byte offset {offset}"), + }); + } + let mut frame = vec![ + 0_u8; + usize::try_from(syncframe_header.frame_size) + .map_err(|_| MuxError::LayoutOverflow("E-AC-3 frame size"))? + ]; + read_exact_at_async( + &mut file, + offset, + &mut frame, + spec, + "truncated E-AC-3 syncframe", + ) + .await?; + let syncframe = parse_eac3_syncframe(&frame, offset, spec)?; + if syncframe.stream_type != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "raw E-AC-3 access units must begin with an independent substream" + .to_string(), + }); + } + let mut decoder_config = syncframe.decoder_config; + let mut access_unit_size = syncframe.frame_size; + let sample_duration = syncframe.sample_duration; + let mut next_offset = offset + .checked_add(syncframe.frame_size) + .ok_or(MuxError::LayoutOverflow("E-AC-3 frame offset"))?; + while next_offset < file_size { + if file_size - next_offset < 6 { + break; + } + let mut next_header = [0_u8; 6]; + read_exact_at_async( + &mut file, + next_offset, + &mut next_header, + spec, + "truncated E-AC-3 syncframe header", + ) + .await?; + let next_syncframe_header = parse_eac3_frame_header(&next_header, next_offset, spec)?; + if next_syncframe_header.stream_type != 1 + || next_syncframe_header.substream_id != syncframe.substream_id + { + break; + } + if sample_duration != next_syncframe_header.sample_duration { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "E-AC-3 dependent substream changed frame duration mid-stream" + .to_string(), + }); + } + if next_offset + .checked_add(next_syncframe_header.frame_size) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated E-AC-3 syncframe at byte offset {next_offset}"), + }); + } + let mut next_frame = vec![ + 0_u8; + usize::try_from(next_syncframe_header.frame_size).map_err( + |_| MuxError::LayoutOverflow("E-AC-3 frame size") + )? + ]; + read_exact_at_async( + &mut file, + next_offset, + &mut next_frame, + spec, + "truncated E-AC-3 syncframe", + ) + .await?; + let dependent_syncframe = parse_eac3_syncframe(&next_frame, next_offset, spec)?; + merge_eac3_dependent_substream( + &mut decoder_config, + &dependent_syncframe.decoder_config, + dependent_syncframe.substream_id, + spec, + )?; + access_unit_size = access_unit_size + .checked_add(dependent_syncframe.frame_size) + .ok_or(MuxError::LayoutOverflow("E-AC-3 access unit size"))?; + next_offset = next_offset + .checked_add(dependent_syncframe.frame_size) + .ok_or(MuxError::LayoutOverflow("E-AC-3 frame offset"))?; + } + if let Some(current) = &expected { + if !same_eac3_config(current, &decoder_config) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "E-AC-3 syncframes changed decoder configuration mid-stream" + .to_string(), + }); + } + } else { + expected = Some(decoder_config); + } + samples.push(StagedSample { + data_offset: offset, + data_size: u32::try_from(access_unit_size) + .map_err(|_| MuxError::LayoutOverflow("E-AC-3 access unit size"))?, + duration: sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = next_offset; + } + + let decoder_config = expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "E-AC-3 input contained no syncframes".to_string(), + })?; + Ok(ParsedEac3Track { + sample_rate: decoder_config.sample_rate, + decoder_config, + sample_entry_box: build_eac3_sample_entry_box(&decoder_config, &samples)?, + samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_eac3_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::; + while offset < total_size { + if total_size - offset < 6 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated E-AC-3 syncframe header".to_string(), + }); + } + let mut header = [0_u8; 6]; + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated E-AC-3 syncframe header", + ) + .await?; + let syncframe_header = parse_eac3_frame_header(&header, offset, spec)?; + if offset + .checked_add(syncframe_header.frame_size) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated E-AC-3 syncframe at logical byte offset {offset}"), + }); + } + let mut frame = vec![ + 0_u8; + usize::try_from(syncframe_header.frame_size) + .map_err(|_| MuxError::LayoutOverflow("E-AC-3 frame size"))? + ]; + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut frame, + spec, + "truncated E-AC-3 syncframe", + ) + .await?; + let syncframe = parse_eac3_syncframe(&frame, offset, spec)?; + if syncframe.stream_type != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "raw E-AC-3 access units must begin with an independent substream" + .to_string(), + }); + } + let mut decoder_config = syncframe.decoder_config; + let mut access_unit_size = syncframe.frame_size; + let sample_duration = syncframe.sample_duration; + let mut next_offset = offset + .checked_add(syncframe.frame_size) + .ok_or(MuxError::LayoutOverflow("E-AC-3 frame offset"))?; + while next_offset < total_size { + if total_size - next_offset < 6 { + break; + } + let mut next_header = [0_u8; 6]; + read_segmented_bytes_async( + file, + segments, + total_size, + next_offset, + &mut next_header, + spec, + "truncated E-AC-3 syncframe header", + ) + .await?; + let next_syncframe_header = parse_eac3_frame_header(&next_header, next_offset, spec)?; + if next_syncframe_header.stream_type != 1 + || next_syncframe_header.substream_id != syncframe.substream_id + { + break; + } + if sample_duration != next_syncframe_header.sample_duration { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "E-AC-3 dependent substream changed frame duration mid-stream" + .to_string(), + }); + } + if next_offset + .checked_add(next_syncframe_header.frame_size) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "truncated E-AC-3 syncframe at logical byte offset {next_offset}" + ), + }); + } + let mut next_frame = vec![ + 0_u8; + usize::try_from(next_syncframe_header.frame_size).map_err( + |_| MuxError::LayoutOverflow("E-AC-3 frame size") + )? + ]; + read_segmented_bytes_async( + file, + segments, + total_size, + next_offset, + &mut next_frame, + spec, + "truncated E-AC-3 syncframe", + ) + .await?; + let dependent_syncframe = parse_eac3_syncframe(&next_frame, next_offset, spec)?; + merge_eac3_dependent_substream( + &mut decoder_config, + &dependent_syncframe.decoder_config, + dependent_syncframe.substream_id, + spec, + )?; + access_unit_size = access_unit_size + .checked_add(dependent_syncframe.frame_size) + .ok_or(MuxError::LayoutOverflow("E-AC-3 access unit size"))?; + next_offset = next_offset + .checked_add(dependent_syncframe.frame_size) + .ok_or(MuxError::LayoutOverflow("E-AC-3 frame offset"))?; + } + if let Some(current) = &expected { + if !same_eac3_config(current, &decoder_config) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "E-AC-3 syncframes changed decoder configuration mid-stream" + .to_string(), + }); + } + } else { + expected = Some(decoder_config); + } + samples.push(StagedSample { + data_offset: offset, + data_size: u32::try_from(access_unit_size) + .map_err(|_| MuxError::LayoutOverflow("E-AC-3 access unit size"))?, + duration: sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = next_offset; + } + + let decoder_config = expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "E-AC-3 input contained no syncframes".to_string(), + })?; + Ok(ParsedEac3Track { + sample_rate: decoder_config.sample_rate, + decoder_config, + sample_entry_box: build_eac3_sample_entry_box(&decoder_config, &samples)?, + samples, + }) +} + +fn same_eac3_config(left: &Eac3DecoderConfig, right: &Eac3DecoderConfig) -> bool { + left.sample_rate == right.sample_rate + && left.channel_count == right.channel_count + && left.fscod == right.fscod + && left.bsid == right.bsid + && left.bsmod == right.bsmod + && left.acmod == right.acmod + && left.lfe_on == right.lfe_on + && left.num_dep_sub == right.num_dep_sub + && left.chan_loc == right.chan_loc + && left.atmos_ec3_ext == right.atmos_ec3_ext + && left.complexity_index_type == right.complexity_index_type +} + +fn compatible_eac3_dependent_config( + primary: &Eac3DecoderConfig, + dependent: &Eac3DecoderConfig, +) -> bool { + primary.sample_rate == dependent.sample_rate + && primary.fscod == dependent.fscod + && primary.bsid == dependent.bsid +} + +fn eac3_chanmap_to_chan_loc(chan_map: u16) -> u16 { + let mut chan_loc = 0_u16; + if chan_map & (1 << 10) != 0 { + chan_loc |= 1; + } + if chan_map & (1 << 9) != 0 { + chan_loc |= 1 << 1; + } + if chan_map & (1 << 8) != 0 { + chan_loc |= 1 << 2; + } + if chan_map & (1 << 7) != 0 { + chan_loc |= 1 << 3; + } + if chan_map & (1 << 6) != 0 { + chan_loc |= 1 << 4; + } + if chan_map & (1 << 5) != 0 { + chan_loc |= 1 << 5; + } + if chan_map & (1 << 4) != 0 { + chan_loc |= 1 << 6; + } + if chan_map & (1 << 3) != 0 { + chan_loc |= 1 << 7; + } + if chan_map & (1 << 1) != 0 { + chan_loc |= 1 << 8; + } + chan_loc +} + +fn merge_eac3_dependent_substream( + primary: &mut Eac3DecoderConfig, + dependent: &Eac3DecoderConfig, + dependent_substream_id: u8, + spec: &str, +) -> Result<(), MuxError> { + if !compatible_eac3_dependent_config(primary, dependent) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "E-AC-3 dependent substream changed decoder timing mid-stream".to_string(), + }); + } + primary.num_dep_sub = primary.num_dep_sub.max( + dependent_substream_id + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("E-AC-3 dependent substream id"))?, + ); + primary.chan_loc |= dependent.chan_loc; + primary.atmos_ec3_ext |= dependent.atmos_ec3_ext; + primary.complexity_index_type = primary + .complexity_index_type + .max(dependent.complexity_index_type); + Ok(()) +} + +fn parse_eac3_syncframe( + frame: &[u8], + offset: u64, + spec: &str, +) -> Result { + if frame.len() < 6 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated E-AC-3 syncframe".to_string(), + }); + } + let header: [u8; 6] = frame[..6] + .try_into() + .map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated E-AC-3 syncframe header".to_string(), + })?; + let mut parsed = parse_eac3_frame_header(&header, offset, spec)?; + parse_eac3_additional_config(frame, spec, &mut parsed.decoder_config)?; + Ok(parsed) +} + +fn parse_eac3_frame_header( + header: &[u8; 6], + offset: u64, + spec: &str, +) -> Result { + if header[0] != 0x0B || header[1] != 0x77 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing E-AC-3 sync word at byte offset {offset}"), + }); + } + let mut reader = BitReader::new(Cursor::new(&header[2..])); + let stream_type = read_bits_u8_labeled(&mut reader, 2, spec, "E-AC-3")?; + let substream_id = read_bits_u8_labeled(&mut reader, 3, spec, "E-AC-3")?; + let frame_size_words_minus_one = read_bits_u16_labeled(&mut reader, 11, spec, "E-AC-3")?; + let frame_size = u64::from(frame_size_words_minus_one.saturating_add(1)) + .checked_mul(2) + .ok_or(MuxError::LayoutOverflow("E-AC-3 frame size"))?; + let fscod = read_bits_u8_labeled(&mut reader, 2, spec, "E-AC-3")?; + let (sample_rate, sample_duration) = if fscod == 0x03 { + let fscod2 = read_bits_u8_labeled(&mut reader, 2, spec, "E-AC-3")?; + let sample_rate = match fscod2 { + 0 => 24_000, + 1 => 22_050, + 2 => 16_000, + _ => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported E-AC-3 half-rate code {fscod2}"), + }); + } + }; + (sample_rate, 1536) + } else { + let numblkscod = read_bits_u8_labeled(&mut reader, 2, spec, "E-AC-3")?; + let sample_rate = + ac3_sample_rate(fscod).ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported E-AC-3 sample-rate code {fscod}"), + })?; + let sample_duration = match numblkscod { + 0 => 256, + 1 => 512, + 2 => 768, + 3 => 1536, + _ => unreachable!(), + }; + (sample_rate, sample_duration) + }; + let acmod = read_bits_u8_labeled(&mut reader, 3, spec, "E-AC-3")?; + let lfe_on = u8::from(read_bit_labeled(&mut reader, spec, "E-AC-3")?); + let bsid = read_bits_u8_labeled(&mut reader, 5, spec, "E-AC-3")?; + let channel_count = + ac3_channel_count(acmod, lfe_on != 0).ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported E-AC-3 channel mode {acmod}"), + })?; + Ok(ParsedEac3Syncframe { + decoder_config: Eac3DecoderConfig { + sample_rate, + channel_count, + fscod: match sample_rate { + 48_000 => 0, + 44_100 => 1, + 32_000 => 2, + _ => 3, + }, + bsid, + bsmod: 0, + acmod, + lfe_on, + num_dep_sub: 0, + chan_loc: 0, + atmos_ec3_ext: 0, + complexity_index_type: 0, + }, + frame_size, + sample_duration, + stream_type, + substream_id, + }) +} + +fn parse_eac3_additional_config( + frame: &[u8], + spec: &str, + decoder_config: &mut Eac3DecoderConfig, +) -> Result<(), MuxError> { + if frame.len() < 6 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated E-AC-3 syncframe".to_string(), + }); + } + + let mut reader = BitReader::new(Cursor::new(&frame[2..])); + let strmtyp = read_bits_u8_labeled(&mut reader, 2, spec, "E-AC-3")?; + let _substream_id = read_bits_u8_labeled(&mut reader, 3, spec, "E-AC-3")?; + let _frame_size_words_minus_one = read_bits_u16_labeled(&mut reader, 11, spec, "E-AC-3")?; + let fscod = read_bits_u8_labeled(&mut reader, 2, spec, "E-AC-3")?; + let numblkscod = if fscod == 0x03 { + let _fscod2 = read_bits_u8_labeled(&mut reader, 2, spec, "E-AC-3")?; + 3 + } else { + read_bits_u8_labeled(&mut reader, 2, spec, "E-AC-3")? + }; + let acmod = read_bits_u8_labeled(&mut reader, 3, spec, "E-AC-3")?; + let lfe_on = u8::from(read_bit_labeled(&mut reader, spec, "E-AC-3")?); + let _bsid = read_bits_u8_labeled(&mut reader, 5, spec, "E-AC-3")?; + + skip_bits_labeled(&mut reader, 5, spec, "E-AC-3 dialnorm")?; + if read_bit_labeled(&mut reader, spec, "E-AC-3 compre")? { + skip_bits_labeled(&mut reader, 8, spec, "E-AC-3 compr")?; + } + if acmod == 0 { + skip_bits_labeled(&mut reader, 5, spec, "E-AC-3 dialnorm2")?; + if read_bit_labeled(&mut reader, spec, "E-AC-3 compr2e")? { + skip_bits_labeled(&mut reader, 8, spec, "E-AC-3 compr2")?; + } + } + if strmtyp == 0x1 && read_bit_labeled(&mut reader, spec, "E-AC-3 chanmape")? { + decoder_config.chan_loc = eac3_chanmap_to_chan_loc(read_bits_u16_labeled( + &mut reader, + 16, + spec, + "E-AC-3 chanmap", + )?); + } + + if read_bit_labeled(&mut reader, spec, "E-AC-3 mix metadata")? { + if acmod > 0x2 { + skip_bits_labeled(&mut reader, 2, spec, "E-AC-3 dmixmod")?; + } + if (acmod & 0x1) != 0 && acmod > 0x2 { + skip_bits_labeled(&mut reader, 6, spec, "E-AC-3 ltrtcmixlev")?; + } + if (acmod & 0x4) != 0 { + skip_bits_labeled(&mut reader, 6, spec, "E-AC-3 ltrtsurmixlev")?; + } + if lfe_on != 0 && read_bit_labeled(&mut reader, spec, "E-AC-3 lfemixlevcode")? { + skip_bits_labeled(&mut reader, 5, spec, "E-AC-3 lfemixlevcod")?; + } + if strmtyp == 0 { + if read_bit_labeled(&mut reader, spec, "E-AC-3 pgmscle")? { + skip_bits_labeled(&mut reader, 6, spec, "E-AC-3 pgmscl")?; + } + if acmod == 0 && read_bit_labeled(&mut reader, spec, "E-AC-3 pgmscl2e")? { + skip_bits_labeled(&mut reader, 6, spec, "E-AC-3 pgmscl2")?; + } + if read_bit_labeled(&mut reader, spec, "E-AC-3 extpgmscle")? { + skip_bits_labeled(&mut reader, 6, spec, "E-AC-3 extpgmscl")?; + } + match read_bits_u8_labeled(&mut reader, 2, spec, "E-AC-3 mixdef")? { + 0x1 => skip_bits_labeled(&mut reader, 5, spec, "E-AC-3 mixdef data")?, + 0x2 => skip_bits_labeled(&mut reader, 12, spec, "E-AC-3 mixdef data")?, + 0x3 => { + let mixdeflen = usize::from(read_bits_u8_labeled( + &mut reader, + 5, + spec, + "E-AC-3 mixdeflen", + )?) + 2; + skip_bits_labeled( + &mut reader, + mixdeflen + .checked_mul(8) + .ok_or(MuxError::LayoutOverflow("E-AC-3 mixdeflen"))?, + spec, + "E-AC-3 mixdef payload", + )?; + } + _ => {} + } + if acmod < 0x2 { + if read_bit_labeled(&mut reader, spec, "E-AC-3 paninfoe")? { + skip_bits_labeled(&mut reader, 14, spec, "E-AC-3 paninfo")?; + } + if acmod == 0 && read_bit_labeled(&mut reader, spec, "E-AC-3 paninfo2e")? { + skip_bits_labeled(&mut reader, 14, spec, "E-AC-3 paninfo2")?; + } + } + if read_bit_labeled(&mut reader, spec, "E-AC-3 frmmixcfginfoe")? { + if numblkscod == 0x0 { + skip_bits_labeled(&mut reader, 5, spec, "E-AC-3 frmmixcfginfo")?; + } else { + for _ in 0..eac3_num_blocks(numblkscod)? { + if read_bit_labeled(&mut reader, spec, "E-AC-3 blkfrmmixcfginfoe")? { + skip_bits_labeled(&mut reader, 5, spec, "E-AC-3 blkfrmmixcfginfo")?; + } + } + } + } + } + } + + if read_bit_labeled(&mut reader, spec, "E-AC-3 infomdate")? { + skip_bits_labeled(&mut reader, 5, spec, "E-AC-3 bsmod metadata")?; + if acmod == 0x2 { + skip_bits_labeled(&mut reader, 4, spec, "E-AC-3 dsurmod")?; + } + if acmod >= 0x6 { + skip_bits_labeled(&mut reader, 2, spec, "E-AC-3 dheadphonmod")?; + } + if read_bit_labeled(&mut reader, spec, "E-AC-3 audprodie")? { + skip_bits_labeled(&mut reader, 8, spec, "E-AC-3 audprodinfo")?; + } + if acmod == 0 && read_bit_labeled(&mut reader, spec, "E-AC-3 audprodi2e")? { + skip_bits_labeled(&mut reader, 8, spec, "E-AC-3 audprodinfo2")?; + } + if fscod < 0x3 { + skip_bits_labeled(&mut reader, 1, spec, "E-AC-3 sourcefscod")?; + } + } + if strmtyp == 0 && numblkscod != 0x3 { + skip_bits_labeled(&mut reader, 1, spec, "E-AC-3 convsync")?; + } + if strmtyp == 0x2 { + let blkid = if numblkscod == 0x3 { + 1 + } else { + u8::from(read_bit_labeled(&mut reader, spec, "E-AC-3 blkid")?) + }; + if blkid != 0 { + skip_bits_labeled(&mut reader, 6, spec, "E-AC-3 frmsizecod")?; + } + } + if read_bit_labeled(&mut reader, spec, "E-AC-3 addbsie")? { + let addbsil = usize::from(read_bits_u8_labeled( + &mut reader, + 6, + spec, + "E-AC-3 addbsil", + )?) + 1; + if addbsil >= 2 { + skip_bits_labeled(&mut reader, 7, spec, "E-AC-3 addbsi reserved")?; + if read_bit_labeled(&mut reader, spec, "E-AC-3 atmos extension")? { + decoder_config.atmos_ec3_ext = 1; + decoder_config.complexity_index_type = + read_bits_u8_labeled(&mut reader, 8, spec, "E-AC-3 complexity index")?; + } + } + } + + Ok(()) +} + +pub(in crate::mux) fn build_eac3_sample_entry_box( + parsed: &Eac3DecoderConfig, + samples: &[StagedSample], +) -> Result, MuxError> { + build_eac3_sample_entry_box_with_timescale(parsed, samples, parsed.sample_rate) +} + +pub(in crate::mux) fn build_eac3_sample_entry_box_with_timescale( + parsed: &Eac3DecoderConfig, + samples: &[StagedSample], + timescale: u32, +) -> Result, MuxError> { + build_eac3_sample_entry_box_with_btrt(parsed, build_eac3_btrt(samples, timescale)?) +} + +pub(in crate::mux) fn build_eac3_sample_entry_box_with_btrt( + parsed: &Eac3DecoderConfig, + btrt: Btrt, +) -> Result, MuxError> { + let mut sample_entry = AudioSampleEntry::default(); + sample_entry.set_box_type(FourCc::from_bytes(*b"ec-3")); + sample_entry.sample_entry = SampleEntry { + box_type: FourCc::from_bytes(*b"ec-3"), + data_reference_index: 1, + }; + sample_entry.channel_count = eac3_sample_entry_channel_count(parsed); + sample_entry.sample_size = 16; + sample_entry.sample_rate = parsed.sample_rate << 16; + + let dec3 = super::super::mp4::encode_typed_box( + &Dec3 { + data_rate: u16::try_from(btrt.avg_bitrate / 1_000) + .map_err(|_| MuxError::LayoutOverflow("E-AC-3 data_rate"))?, + num_ind_sub: 0, + ec3_substreams: vec![Ec3Substream { + fscod: parsed.fscod, + bsid: parsed.bsid, + asvc: 0, + bsmod: parsed.bsmod, + acmod: parsed.acmod, + lfe_on: parsed.lfe_on, + num_dep_sub: parsed.num_dep_sub, + chan_loc: parsed.chan_loc, + }], + reserved: eac3_dec3_reserved_bytes(parsed), + }, + &[], + )?; + let btrt = super::super::mp4::encode_typed_box(&btrt, &[])?; + let mut children = dec3; + children.extend_from_slice(&btrt); + super::super::mp4::encode_typed_box(&sample_entry, &children) +} + +fn eac3_dec3_reserved_bytes(parsed: &Eac3DecoderConfig) -> Vec { + let atmos_ec3_ext = parsed.atmos_ec3_ext & 0x01; + match (atmos_ec3_ext, parsed.complexity_index_type) { + (0, 0) => Vec::new(), + (_, 0) => vec![atmos_ec3_ext], + _ => vec![atmos_ec3_ext, parsed.complexity_index_type], + } +} + +const fn eac3_sample_entry_channel_count(parsed: &Eac3DecoderConfig) -> u16 { + let _ = parsed; + 2 +} + +fn build_eac3_btrt(samples: &[StagedSample], sample_rate: u32) -> Result { + if samples.is_empty() || sample_rate == 0 { + return Ok(Btrt::default()); + } + + let mut buffer_size_db = 0_u32; + let mut total_payload_bytes = 0_u64; + let mut total_duration = 0_u64; + let mut max_window_payload_bytes = 0_u64; + let mut current_window_payload_bytes = 0_u64; + let mut window_start_decode_time = 0_u64; + let mut sample_decode_time = 0_u64; + + for sample in samples { + buffer_size_db = buffer_size_db.max(sample.data_size); + total_payload_bytes = total_payload_bytes + .checked_add(u64::from(sample.data_size)) + .ok_or(MuxError::LayoutOverflow("E-AC-3 total payload bytes"))?; + total_duration = total_duration + .checked_add(u64::from(sample.duration)) + .ok_or(MuxError::LayoutOverflow("E-AC-3 total duration"))?; + current_window_payload_bytes = current_window_payload_bytes + .checked_add(u64::from(sample.data_size)) + .ok_or(MuxError::LayoutOverflow("E-AC-3 bitrate window payload"))?; + if sample_decode_time > window_start_decode_time.saturating_add(u64::from(sample_rate)) { + max_window_payload_bytes = max_window_payload_bytes.max(current_window_payload_bytes); + window_start_decode_time = sample_decode_time; + current_window_payload_bytes = 0; + } + sample_decode_time = sample_decode_time + .checked_add(u64::from(sample.duration)) + .ok_or(MuxError::LayoutOverflow("E-AC-3 decode time"))?; + } + + if total_duration == 0 { + return Ok(Btrt::default()); + } + + let avg_bitrate = total_payload_bytes + .checked_mul(8) + .and_then(|bits| bits.checked_mul(u64::from(sample_rate))) + .ok_or(MuxError::LayoutOverflow("E-AC-3 average bitrate"))? + / total_duration; + let avg_bitrate = avg_bitrate & !7; + let max_bitrate = if max_window_payload_bytes == 0 { + avg_bitrate + } else { + max_window_payload_bytes + .checked_mul(8) + .ok_or(MuxError::LayoutOverflow("E-AC-3 maximum bitrate"))? + }; + + Ok(Btrt { + buffer_size_db, + max_bitrate: u32::try_from(max_bitrate) + .map_err(|_| MuxError::LayoutOverflow("E-AC-3 maximum bitrate"))?, + avg_bitrate: u32::try_from(avg_bitrate) + .map_err(|_| MuxError::LayoutOverflow("E-AC-3 average bitrate"))?, + }) +} + +#[cfg(test)] +#[allow(clippy::items_after_test_module)] +mod tests { + use super::{Eac3DecoderConfig, eac3_dec3_reserved_bytes}; + + #[test] + fn eac3_dec3_reserved_bytes_omit_trailing_zero_complexity_byte() { + let config = Eac3DecoderConfig { + sample_rate: 48_000, + channel_count: 2, + fscod: 0, + bsid: 16, + bsmod: 0, + acmod: 2, + lfe_on: 0, + num_dep_sub: 0, + chan_loc: 0, + atmos_ec3_ext: 1, + complexity_index_type: 0, + }; + + assert_eq!(eac3_dec3_reserved_bytes(&config), vec![1]); + } +} + +const fn ac3_sample_rate(fscod: u8) -> Option { + match fscod { + 0 => Some(48_000), + 1 => Some(44_100), + 2 => Some(32_000), + _ => None, + } +} + +const fn ac3_channel_count(acmod: u8, lfe_on: bool) -> Option { + let base = match acmod { + 0 => 2, + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 3, + 5 => 4, + 6 => 4, + 7 => 5, + _ => return None, + }; + Some(base + if lfe_on { 1 } else { 0 }) +} + +fn read_bit_labeled(reader: &mut BitReader, spec: &str, label: &str) -> Result +where + R: std::io::Read, +{ + reader + .read_bit() + .map_err(|error| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("failed to read {label} bitstream: {error}"), + }) +} + +fn read_bits_u8_labeled( + reader: &mut BitReader, + width: usize, + spec: &str, + label: &str, +) -> Result +where + R: std::io::Read, +{ + let bits = reader + .read_bits(width) + .map_err(|error| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("failed to read {label} bitstream: {error}"), + })?; + let mut value = 0_u16; + for byte in bits { + value = (value << 8) | u16::from(byte); + } + u8::try_from(value).map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("{label} bitfield does not fit in u8"), + }) +} + +fn read_bits_u16_labeled( + reader: &mut BitReader, + width: usize, + spec: &str, + label: &str, +) -> Result +where + R: std::io::Read, +{ + let bits = reader + .read_bits(width) + .map_err(|error| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("failed to read {label} bitstream: {error}"), + })?; + let mut value = 0_u32; + for byte in bits { + value = (value << 8) | u32::from(byte); + } + u16::try_from(value).map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("{label} bitfield does not fit in u16"), + }) +} + +fn skip_bits_labeled( + reader: &mut BitReader, + width: usize, + spec: &str, + label: &str, +) -> Result<(), MuxError> +where + R: std::io::Read, +{ + if width == 0 { + return Ok(()); + } + reader + .read_bits(width) + .map(|_| ()) + .map_err(|error| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("failed to read {label} bitstream: {error}"), + }) +} + +fn eac3_num_blocks(numblkscod: u8) -> Result { + match numblkscod { + 0 => Ok(1), + 1 => Ok(2), + 2 => Ok(3), + 3 => Ok(6), + _ => Err(MuxError::UnsupportedTrackImport { + spec: "E-AC-3".to_string(), + message: format!("unsupported E-AC-3 block-count code {numblkscod}"), + }), + } +} diff --git a/src/mux/demux/flac.rs b/src/mux/demux/flac.rs new file mode 100644 index 0000000..c4abbdb --- /dev/null +++ b/src/mux/demux/flac.rs @@ -0,0 +1,1473 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::flac::{DfLa, FlacMetadataBlock}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +#[cfg(feature = "async")] +use super::super::import::read_spans_async; +use super::super::import::{ + SegmentedMuxSourceSegment, SegmentedMuxSourceSegmentData, SegmentedMuxSourceSpec, StagedSample, + build_btrt_from_sample_sizes, build_generic_audio_sample_entry_box, read_exact_at_sync, + read_spans_sync, +}; +#[cfg(feature = "async")] +use super::ogg_common::read_ogg_page_header_async; +use super::ogg_common::{OggPacketBuilder, read_ogg_page_header_sync}; + +const FLAC_ENTRY: FourCc = FourCc::from_bytes(*b"fLaC"); +const OGG_FLAC_PACKET_CLOCK_TIMESCALE: u32 = 1_000; +const FLAC_SCAN_CHUNK_SIZE: usize = 64 * 1024; +const FLAC_BLOCK_SIZE_TABLE: [u32; 16] = [ + 0, 192, 576, 1_152, 2_304, 4_608, 0, 0, 256, 512, 1_024, 2_048, 4_096, 8_192, 16_384, 32_768, +]; +const FLAC_SAMPLE_RATE_TABLE: [u32; 12] = [ + 0, 88_200, 176_400, 192_000, 8_000, 16_000, 22_050, 24_000, 32_000, 44_100, 48_000, 96_000, +]; + +pub(in crate::mux) struct ParsedFlacTrack { + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +pub(in crate::mux) struct ParsedOggFlacTrack { + pub(in crate::mux) segmented_source: SegmentedMuxSourceSpec, + pub(in crate::mux) media_timescale: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +#[derive(Clone)] +struct ParsedFlacMetadataBlock { + block_type: u8, + length: u32, + block_data: Vec, +} + +#[derive(Clone)] +struct ParsedFlacStreamInfo { + sample_rate: u32, + channel_count: u16, + bits_per_sample: u16, + total_samples: u64, +} + +struct ParsedFlacFrameHeader { + block_size: u32, +} + +struct OggFlacHeaderState { + header_bytes: Vec, + mode: OggFlacHeaderMode, +} + +struct ParsedOggFlacHeaderPacket<'a> { + native_header_bytes: &'a [u8], + mode: OggFlacHeaderMode, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum OggFlacHeaderMode { + NativeSplit, + MappingExtraPacketsRemaining(u16), +} + +pub(in crate::mux) fn scan_flac_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + if file_size < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "FLAC input is truncated before the 4-byte stream marker".to_string(), + }); + } + + let mut signature = [0_u8; 4]; + read_exact_at_sync( + &mut file, + 0, + &mut signature, + spec, + "FLAC input is truncated before the 4-byte stream marker", + )?; + if &signature != b"fLaC" { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "FLAC input did not start with the `fLaC` stream marker".to_string(), + }); + } + + let mut offset = 4_u64; + let mut metadata_blocks = Vec::new(); + let mut stream_info = None::; + loop { + if file_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "FLAC metadata block header is truncated".to_string(), + }); + } + let mut header = [0_u8; 4]; + read_exact_at_sync( + &mut file, + offset, + &mut header, + spec, + "FLAC metadata block header is truncated", + )?; + let last_metadata_block_flag = header[0] & 0x80 != 0; + let block_type = header[0] & 0x7F; + let length = + (u32::from(header[1]) << 16) | (u32::from(header[2]) << 8) | u32::from(header[3]); + offset = offset + .checked_add(4) + .ok_or(MuxError::LayoutOverflow("FLAC metadata header offset"))?; + if offset + .checked_add(u64::from(length)) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("FLAC metadata block type {block_type} overruns the input length"), + }); + } + let mut block_data = vec![0_u8; usize::try_from(length).unwrap()]; + read_exact_at_sync( + &mut file, + offset, + &mut block_data, + spec, + "FLAC metadata block payload is truncated", + )?; + if block_type == 0 { + stream_info = Some(parse_flac_stream_info(&block_data, spec)?); + } + metadata_blocks.push(ParsedFlacMetadataBlock { + block_type, + length, + block_data, + }); + offset = offset + .checked_add(u64::from(length)) + .ok_or(MuxError::LayoutOverflow("FLAC metadata offset"))?; + if last_metadata_block_flag { + break; + } + } + + let stream_info = stream_info.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "FLAC input did not contain a STREAMINFO metadata block".to_string(), + })?; + if file_size == offset { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "FLAC input did not contain any frame payload after metadata".to_string(), + }); + } + let samples = scan_native_flac_frames_sync(&mut file, file_size, offset, spec, &stream_info)?; + let sample_entry_box = build_flac_sample_entry_box( + stream_info.sample_rate, + stream_info.channel_count, + stream_info.bits_per_sample, + &metadata_blocks, + Some(build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + stream_info.sample_rate, + )?), + )?; + Ok(ParsedFlacTrack { + sample_rate: stream_info.sample_rate, + sample_entry_box, + samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_flac_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + if file_size < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "FLAC input is truncated before the 4-byte stream marker".to_string(), + }); + } + + let mut signature = [0_u8; 4]; + read_exact_at_async( + &mut file, + 0, + &mut signature, + spec, + "FLAC input is truncated before the 4-byte stream marker", + ) + .await?; + if &signature != b"fLaC" { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "FLAC input did not start with the `fLaC` stream marker".to_string(), + }); + } + + let mut offset = 4_u64; + let mut metadata_blocks = Vec::new(); + let mut stream_info = None::; + loop { + if file_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "FLAC metadata block header is truncated".to_string(), + }); + } + let mut header = [0_u8; 4]; + read_exact_at_async( + &mut file, + offset, + &mut header, + spec, + "FLAC metadata block header is truncated", + ) + .await?; + let last_metadata_block_flag = header[0] & 0x80 != 0; + let block_type = header[0] & 0x7F; + let length = + (u32::from(header[1]) << 16) | (u32::from(header[2]) << 8) | u32::from(header[3]); + offset = offset + .checked_add(4) + .ok_or(MuxError::LayoutOverflow("FLAC metadata header offset"))?; + if offset + .checked_add(u64::from(length)) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("FLAC metadata block type {block_type} overruns the input length"), + }); + } + let mut block_data = vec![0_u8; usize::try_from(length).unwrap()]; + read_exact_at_async( + &mut file, + offset, + &mut block_data, + spec, + "FLAC metadata block payload is truncated", + ) + .await?; + if block_type == 0 { + stream_info = Some(parse_flac_stream_info(&block_data, spec)?); + } + metadata_blocks.push(ParsedFlacMetadataBlock { + block_type, + length, + block_data, + }); + offset = offset + .checked_add(u64::from(length)) + .ok_or(MuxError::LayoutOverflow("FLAC metadata offset"))?; + if last_metadata_block_flag { + break; + } + } + + let stream_info = stream_info.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "FLAC input did not contain a STREAMINFO metadata block".to_string(), + })?; + if file_size == offset { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "FLAC input did not contain any frame payload after metadata".to_string(), + }); + } + let samples = + scan_native_flac_frames_async(&mut file, file_size, offset, spec, &stream_info).await?; + let sample_entry_box = build_flac_sample_entry_box( + stream_info.sample_rate, + stream_info.channel_count, + stream_info.bits_per_sample, + &metadata_blocks, + Some(build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + stream_info.sample_rate, + )?), + )?; + Ok(ParsedFlacTrack { + sample_rate: stream_info.sample_rate, + sample_entry_box, + samples, + }) +} + +pub(in crate::mux) fn scan_ogg_flac_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let mut offset = 0_u64; + let mut packet_builder = OggPacketBuilder::default(); + let mut stream_info = None::; + let mut sample_entry_box = None::>; + let mut header_state = None::; + let mut logical_size = 0_u64; + let mut transformed_segments = Vec::new(); + let mut samples = Vec::new(); + let mut audio_packet_count = 0_u64; + let mut started_media_packets = false; + while offset < file_size { + let page = read_ogg_page_header_sync(&mut file, offset, spec)?; + if packet_builder.is_empty() && page.header_type & 0x01 != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg FLAC input started in the middle of a continued packet".to_string(), + }); + } + offset = page + .payload_offset + .checked_add(page.payload_size) + .ok_or(MuxError::LayoutOverflow("Ogg page range"))?; + let mut page_cursor = page.payload_offset; + for lacing in &page.lacing_values { + packet_builder.push_span(page_cursor, u32::from(*lacing))?; + page_cursor += u64::from(*lacing); + if *lacing == 255 { + continue; + } + let packet = packet_builder.finish(); + if packet.total_size == 0 { + continue; + } + let needs_packet_bytes = sample_entry_box.is_none() + || header_state + .as_ref() + .is_some_and(OggFlacHeaderState::awaiting_mapping_packets) + || !started_media_packets; + let mut packet_bytes = None::>; + if needs_packet_bytes { + packet_bytes = Some(read_spans_sync( + &mut file, + &packet.spans, + packet.total_size, + spec, + "Ogg FLAC identification packet is truncated", + )?); + } + if sample_entry_box.is_none() + || header_state + .as_ref() + .is_some_and(OggFlacHeaderState::awaiting_mapping_packets) + { + let packet_bytes = packet_bytes.as_deref().unwrap(); + if let Some(state) = &mut header_state { + state.append_extra_packet(packet_bytes); + } else { + header_state = Some(parse_ogg_flac_header_start(packet_bytes, spec)?); + } + if sample_entry_box.is_none() { + let header_bytes = &header_state.as_ref().unwrap().header_bytes; + if let Some(parsed_stream_info) = + try_parse_ogg_flac_stream_info_from_header_prefix(header_bytes, spec)? + { + stream_info = Some(parsed_stream_info.clone()); + sample_entry_box = Some(build_ogg_flac_sample_entry_box( + parsed_stream_info.channel_count, + parsed_stream_info.bits_per_sample, + None, + )?); + } + } + if sample_entry_box.is_none() { + continue; + } + if !started_media_packets && !ogg_flac_packet_should_stage_as_media(packet_bytes) { + continue; + } + if header_state + .as_ref() + .is_some_and(|state| state.is_complete() && !state.awaiting_mapping_packets()) + { + header_state = None; + } + } else if !started_media_packets + && !ogg_flac_packet_should_stage_as_media(packet_bytes.as_deref().unwrap()) + { + continue; + } + let data_offset = logical_size; + for span in &packet.spans { + transformed_segments.push(SegmentedMuxSourceSegment { + logical_offset: logical_size, + data: SegmentedMuxSourceSegmentData::FileRange { + source_offset: span.source_offset, + size: span.size, + }, + }); + logical_size = logical_size + .checked_add(u64::from(span.size)) + .ok_or(MuxError::LayoutOverflow("Ogg FLAC logical source size"))?; + } + samples.push(StagedSample { + data_offset, + data_size: packet.total_size, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }); + audio_packet_count += 1; + started_media_packets = true; + } + } + if !packet_builder.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg FLAC input ended in the middle of a packet".to_string(), + }); + } + if header_state + .as_ref() + .is_some_and(OggFlacHeaderState::awaiting_mapping_packets) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg FLAC input ended before all mapping-header metadata packets were present" + .to_string(), + }); + } + stream_info.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg FLAC input did not contain an identification packet".to_string(), + })?; + let sample_entry_box = sample_entry_box.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg FLAC input did not yield any FLAC metadata blocks".to_string(), + })?; + if audio_packet_count == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg FLAC input did not contain any audio packets after headers".to_string(), + }); + } + samples.last_mut().unwrap().duration = 0; + Ok(ParsedOggFlacTrack { + segmented_source: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: transformed_segments, + total_size: logical_size, + }, + media_timescale: OGG_FLAC_PACKET_CLOCK_TIMESCALE, + sample_entry_box, + samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_ogg_flac_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let mut offset = 0_u64; + let mut packet_builder = OggPacketBuilder::default(); + let mut stream_info = None::; + let mut sample_entry_box = None::>; + let mut header_state = None::; + let mut logical_size = 0_u64; + let mut transformed_segments = Vec::new(); + let mut samples = Vec::new(); + let mut audio_packet_count = 0_u64; + let mut started_media_packets = false; + while offset < file_size { + let page = read_ogg_page_header_async(&mut file, offset, spec).await?; + if packet_builder.is_empty() && page.header_type & 0x01 != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg FLAC input started in the middle of a continued packet".to_string(), + }); + } + offset = page + .payload_offset + .checked_add(page.payload_size) + .ok_or(MuxError::LayoutOverflow("Ogg page range"))?; + let mut page_cursor = page.payload_offset; + for lacing in &page.lacing_values { + packet_builder.push_span(page_cursor, u32::from(*lacing))?; + page_cursor += u64::from(*lacing); + if *lacing == 255 { + continue; + } + let packet = packet_builder.finish(); + if packet.total_size == 0 { + continue; + } + let needs_packet_bytes = sample_entry_box.is_none() + || header_state + .as_ref() + .is_some_and(OggFlacHeaderState::awaiting_mapping_packets) + || !started_media_packets; + let mut packet_bytes = None::>; + if needs_packet_bytes { + packet_bytes = Some( + read_spans_async( + &mut file, + &packet.spans, + packet.total_size, + spec, + "Ogg FLAC identification packet is truncated", + ) + .await?, + ); + } + if sample_entry_box.is_none() + || header_state + .as_ref() + .is_some_and(OggFlacHeaderState::awaiting_mapping_packets) + { + let packet_bytes = packet_bytes.as_deref().unwrap(); + if let Some(state) = &mut header_state { + state.append_extra_packet(packet_bytes); + } else { + header_state = Some(parse_ogg_flac_header_start(packet_bytes, spec)?); + } + if sample_entry_box.is_none() { + let header_bytes = &header_state.as_ref().unwrap().header_bytes; + if let Some(parsed_stream_info) = + try_parse_ogg_flac_stream_info_from_header_prefix(header_bytes, spec)? + { + stream_info = Some(parsed_stream_info.clone()); + sample_entry_box = Some(build_ogg_flac_sample_entry_box( + parsed_stream_info.channel_count, + parsed_stream_info.bits_per_sample, + None, + )?); + } + } + if sample_entry_box.is_none() { + continue; + } + if !started_media_packets && !ogg_flac_packet_should_stage_as_media(packet_bytes) { + continue; + } + if header_state + .as_ref() + .is_some_and(|state| state.is_complete() && !state.awaiting_mapping_packets()) + { + header_state = None; + } + } else if !started_media_packets + && !ogg_flac_packet_should_stage_as_media(packet_bytes.as_deref().unwrap()) + { + continue; + } + let data_offset = logical_size; + for span in &packet.spans { + transformed_segments.push(SegmentedMuxSourceSegment { + logical_offset: logical_size, + data: SegmentedMuxSourceSegmentData::FileRange { + source_offset: span.source_offset, + size: span.size, + }, + }); + logical_size = logical_size + .checked_add(u64::from(span.size)) + .ok_or(MuxError::LayoutOverflow("Ogg FLAC logical source size"))?; + } + samples.push(StagedSample { + data_offset, + data_size: packet.total_size, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }); + audio_packet_count += 1; + started_media_packets = true; + } + } + if !packet_builder.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg FLAC input ended in the middle of a packet".to_string(), + }); + } + if header_state + .as_ref() + .is_some_and(OggFlacHeaderState::awaiting_mapping_packets) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg FLAC input ended before all mapping-header metadata packets were present" + .to_string(), + }); + } + stream_info.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg FLAC input did not contain an identification packet".to_string(), + })?; + let sample_entry_box = sample_entry_box.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg FLAC input did not yield any FLAC metadata blocks".to_string(), + })?; + if audio_packet_count == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg FLAC input did not contain any audio packets after headers".to_string(), + }); + } + samples.last_mut().unwrap().duration = 0; + Ok(ParsedOggFlacTrack { + segmented_source: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: transformed_segments, + total_size: logical_size, + }, + media_timescale: OGG_FLAC_PACKET_CLOCK_TIMESCALE, + sample_entry_box, + samples, + }) +} + +fn build_flac_sample_entry_box( + sample_rate: u32, + channel_count: u16, + sample_size: u16, + metadata_blocks: &[ParsedFlacMetadataBlock], + btrt: Option, +) -> Result, MuxError> { + let mut dfla = DfLa::default(); + dfla.metadata_blocks = minimal_flac_sample_entry_metadata_blocks(metadata_blocks, sample_rate)?; + let mut dfla_box = super::super::mp4::encode_typed_box(&dfla, &[])?; + // The typed `dfLa` model stays strict about the final-block bit, but the flat authored sample + // entry preserves the retained one-block payload shape used by the fixture. + if let Some(first_metadata_block) = dfla_box.get_mut(12) { + *first_metadata_block &= 0x7F; + } else { + return Err(MuxError::LayoutOverflow("dfLa metadata header")); + } + let mut child_boxes = vec![dfla_box]; + if let Some(btrt) = btrt { + child_boxes.push(super::super::mp4::encode_typed_box(&btrt, &[])?); + } + build_generic_audio_sample_entry_box( + FLAC_ENTRY, + sample_rate, + channel_count, + sample_size, + &child_boxes, + ) +} + +fn build_ogg_flac_sample_entry_box( + channel_count: u16, + sample_size: u16, + btrt: Option, +) -> Result, MuxError> { + let mut child_boxes = vec![super::super::mp4::encode_typed_box(&DfLa::default(), &[])?]; + if let Some(btrt) = btrt { + child_boxes.push(super::super::mp4::encode_typed_box(&btrt, &[])?); + } + build_generic_audio_sample_entry_box( + FLAC_ENTRY, + OGG_FLAC_PACKET_CLOCK_TIMESCALE, + channel_count, + sample_size, + &child_boxes, + ) +} + +fn scan_native_flac_frames_sync( + file: &mut File, + file_size: u64, + frame_data_offset: u64, + spec: &str, + stream_info: &ParsedFlacStreamInfo, +) -> Result, MuxError> { + let mut scan_offset = frame_data_offset; + let mut frame_offset = frame_data_offset; + let mut frame_buffer = Vec::new(); + let mut samples = Vec::new(); + let mut decoded_samples = 0_u64; + + loop { + if let Some((frame_size, block_size)) = + split_scanned_flac_frame(&frame_buffer, spec, frame_offset, stream_info)? + { + push_flac_frame_sample( + &mut samples, + &mut decoded_samples, + frame_offset, + frame_size, + block_size, + stream_info, + spec, + )?; + frame_buffer.drain(..frame_size); + frame_offset = frame_offset + .checked_add(u64::try_from(frame_size).unwrap()) + .ok_or(MuxError::LayoutOverflow("FLAC frame offset"))?; + continue; + } + + if scan_offset >= file_size { + break; + } + let chunk_size = + usize::try_from((file_size - scan_offset).min(FLAC_SCAN_CHUNK_SIZE as u64)).unwrap(); + let buffer_len = frame_buffer.len(); + frame_buffer.resize(buffer_len + chunk_size, 0); + read_exact_at_sync( + file, + scan_offset, + &mut frame_buffer[buffer_len..], + spec, + "FLAC frame payload is truncated while scanning native frame boundaries", + )?; + scan_offset = scan_offset + .checked_add(u64::try_from(chunk_size).unwrap()) + .ok_or(MuxError::LayoutOverflow("FLAC scan offset"))?; + } + + finalize_native_flac_frame_scan( + frame_buffer, + frame_offset, + &mut samples, + &mut decoded_samples, + stream_info, + spec, + )?; + Ok(samples) +} + +#[cfg(feature = "async")] +async fn scan_native_flac_frames_async( + file: &mut TokioFile, + file_size: u64, + frame_data_offset: u64, + spec: &str, + stream_info: &ParsedFlacStreamInfo, +) -> Result, MuxError> { + let mut scan_offset = frame_data_offset; + let mut frame_offset = frame_data_offset; + let mut frame_buffer = Vec::new(); + let mut samples = Vec::new(); + let mut decoded_samples = 0_u64; + + loop { + if let Some((frame_size, block_size)) = + split_scanned_flac_frame(&frame_buffer, spec, frame_offset, stream_info)? + { + push_flac_frame_sample( + &mut samples, + &mut decoded_samples, + frame_offset, + frame_size, + block_size, + stream_info, + spec, + )?; + frame_buffer.drain(..frame_size); + frame_offset = frame_offset + .checked_add(u64::try_from(frame_size).unwrap()) + .ok_or(MuxError::LayoutOverflow("FLAC frame offset"))?; + continue; + } + + if scan_offset >= file_size { + break; + } + let chunk_size = + usize::try_from((file_size - scan_offset).min(FLAC_SCAN_CHUNK_SIZE as u64)).unwrap(); + let buffer_len = frame_buffer.len(); + frame_buffer.resize(buffer_len + chunk_size, 0); + read_exact_at_async( + file, + scan_offset, + &mut frame_buffer[buffer_len..], + spec, + "FLAC frame payload is truncated while scanning native frame boundaries", + ) + .await?; + scan_offset = scan_offset + .checked_add(u64::try_from(chunk_size).unwrap()) + .ok_or(MuxError::LayoutOverflow("FLAC scan offset"))?; + } + + finalize_native_flac_frame_scan( + frame_buffer, + frame_offset, + &mut samples, + &mut decoded_samples, + stream_info, + spec, + )?; + Ok(samples) +} + +fn finalize_native_flac_frame_scan( + frame_buffer: Vec, + frame_offset: u64, + samples: &mut Vec, + decoded_samples: &mut u64, + stream_info: &ParsedFlacStreamInfo, + spec: &str, +) -> Result<(), MuxError> { + if frame_buffer.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "FLAC input did not contain any native audio frames after metadata" + .to_string(), + }); + } + let header = parse_flac_frame_packet(&frame_buffer, spec, frame_offset, stream_info)?; + push_flac_frame_sample( + samples, + decoded_samples, + frame_offset, + frame_buffer.len(), + header.block_size, + stream_info, + spec, + )?; + if stream_info.total_samples != 0 && *decoded_samples != stream_info.total_samples { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "FLAC frame durations summed to {} samples, but STREAMINFO declared {}", + *decoded_samples, stream_info.total_samples + ), + }); + } + Ok(()) +} + +fn split_scanned_flac_frame( + frame_buffer: &[u8], + spec: &str, + frame_offset: u64, + stream_info: &ParsedFlacStreamInfo, +) -> Result, MuxError> { + if frame_buffer.len() < 2 { + return Ok(None); + } + if !looks_like_flac_frame_start(frame_buffer) { + return Err(invalid_flac_frame( + spec, + frame_offset, + "FLAC frame payload did not start with the expected sync code", + )); + } + let mut candidate_index = 2_usize; + while let Some(next_index) = find_next_flac_frame_start(frame_buffer, candidate_index) { + if let Ok(header) = + parse_flac_frame_packet(&frame_buffer[..next_index], spec, frame_offset, stream_info) + { + return Ok(Some((next_index, header.block_size))); + } + candidate_index = next_index + 1; + } + Ok(None) +} + +fn push_flac_frame_sample( + samples: &mut Vec, + decoded_samples: &mut u64, + frame_offset: u64, + frame_size: usize, + block_size: u32, + stream_info: &ParsedFlacStreamInfo, + spec: &str, +) -> Result<(), MuxError> { + if frame_size == 0 { + return Err(MuxError::LayoutOverflow("FLAC frame size")); + } + let remaining_samples = if stream_info.total_samples == 0 { + u64::from(block_size) + } else { + let remaining = stream_info.total_samples.saturating_sub(*decoded_samples); + if remaining == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "FLAC input carried more frame data than STREAMINFO declared".to_string(), + }); + } + remaining.min(u64::from(block_size)) + }; + let duration = u32::try_from(remaining_samples) + .map_err(|_| MuxError::LayoutOverflow("FLAC frame duration"))?; + samples.push(StagedSample { + data_offset: frame_offset, + data_size: u32::try_from(frame_size) + .map_err(|_| MuxError::LayoutOverflow("FLAC frame size"))?, + duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + *decoded_samples = decoded_samples + .checked_add(u64::from(duration)) + .ok_or(MuxError::LayoutOverflow("FLAC decoded sample count"))?; + Ok(()) +} + +fn looks_like_flac_frame_start(bytes: &[u8]) -> bool { + bytes.len() >= 2 && bytes[0] == 0xFF && (bytes[1] & 0xFE) == 0xF8 +} + +fn find_next_flac_frame_start(bytes: &[u8], start: usize) -> Option { + if bytes.len() < 2 || start >= bytes.len().saturating_sub(1) { + return None; + } + (start..bytes.len() - 1).find(|&index| looks_like_flac_frame_start(&bytes[index..])) +} + +fn minimal_flac_sample_entry_metadata_blocks( + metadata_blocks: &[ParsedFlacMetadataBlock], + sample_rate: u32, +) -> Result, MuxError> { + let stream_info = metadata_blocks + .iter() + .find(|block| block.block_type == 0) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: "FLAC sample entry".to_string(), + message: format!( + "missing required STREAMINFO metadata block for {sample_rate} Hz FLAC sample entry" + ), + })?; + Ok(vec![FlacMetadataBlock { + last_metadata_block_flag: true, + block_type: stream_info.block_type, + length: stream_info.length, + block_data: stream_info.block_data.clone(), + }]) +} + +impl OggFlacHeaderState { + fn append_extra_packet(&mut self, packet: &[u8]) { + self.header_bytes.extend_from_slice(packet); + if let OggFlacHeaderMode::MappingExtraPacketsRemaining(remaining) = &mut self.mode { + *remaining = remaining.saturating_sub(1); + } + } + + fn is_complete(&self) -> bool { + match self.mode { + OggFlacHeaderMode::NativeSplit => { + ogg_flac_native_header_is_complete(&self.header_bytes) + } + OggFlacHeaderMode::MappingExtraPacketsRemaining(remaining) => remaining == 0, + } + } + + fn awaiting_mapping_packets(&self) -> bool { + matches!( + self.mode, + OggFlacHeaderMode::MappingExtraPacketsRemaining(remaining) if remaining != 0 + ) + } +} + +fn parse_ogg_flac_header_start(packet: &[u8], spec: &str) -> Result { + let parsed = normalize_ogg_flac_header_packet(packet, spec)?; + Ok(OggFlacHeaderState { + header_bytes: parsed.native_header_bytes.to_vec(), + mode: parsed.mode, + }) +} + +fn try_parse_ogg_flac_stream_info_from_header_prefix( + packet: &[u8], + spec: &str, +) -> Result, MuxError> { + if !packet.starts_with(b"fLaC") { + return Ok(None); + } + if packet.len() < 8 { + return Ok(None); + } + let header = &packet[4..8]; + let block_type = header[0] & 0x7F; + if block_type != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg FLAC header prefix did not begin with a STREAMINFO metadata block" + .to_string(), + }); + } + let length = (u32::from(header[1]) << 16) | (u32::from(header[2]) << 8) | u32::from(header[3]); + let end = 8usize + .checked_add(usize::try_from(length).unwrap()) + .ok_or(MuxError::LayoutOverflow("Ogg FLAC STREAMINFO prefix size"))?; + if end > packet.len() { + return Ok(None); + } + Ok(Some(parse_flac_stream_info(&packet[8..end], spec)?)) +} + +fn parse_ogg_flac_standalone_metadata_block_type(packet: &[u8]) -> Option { + if packet.len() < 4 { + return None; + } + let length = (u32::from(packet[1]) << 16) | (u32::from(packet[2]) << 8) | u32::from(packet[3]); + let expected_len = 4usize.checked_add(usize::try_from(length).ok()?)?; + (packet.len() == expected_len).then_some(packet[0] & 0x7F) +} + +fn ogg_flac_packet_should_stage_as_media(packet: &[u8]) -> bool { + if packet.starts_with(b"fLaC") { + return false; + } + if packet.len() >= 13 && packet[0] == 0x7F && &packet[1..5] == b"FLAC" { + return false; + } + match parse_ogg_flac_standalone_metadata_block_type(packet) { + Some(0 | 4) => false, + Some(_) | None => true, + } +} + +fn ogg_flac_native_header_is_complete(packet: &[u8]) -> bool { + if !packet.starts_with(b"fLaC") { + return false; + } + let mut offset = 4usize; + loop { + if packet.len().saturating_sub(offset) < 4 { + return false; + } + let header = &packet[offset..offset + 4]; + let last_metadata_block_flag = header[0] & 0x80 != 0; + let length = + (u32::from(header[1]) << 16) | (u32::from(header[2]) << 8) | u32::from(header[3]); + let Some(block_offset) = offset.checked_add(4) else { + return false; + }; + let Some(end) = block_offset.checked_add(usize::try_from(length).unwrap()) else { + return false; + }; + if end > packet.len() { + return false; + } + offset = end; + if last_metadata_block_flag { + return offset == packet.len(); + } + } +} + +fn normalize_ogg_flac_header_packet<'a>( + packet: &'a [u8], + spec: &str, +) -> Result, MuxError> { + if packet.starts_with(b"fLaC") { + return Ok(ParsedOggFlacHeaderPacket { + native_header_bytes: packet, + mode: OggFlacHeaderMode::NativeSplit, + }); + } + if packet.len() >= 13 && packet[0] == 0x7F && &packet[1..5] == b"FLAC" { + let major_version = packet[5]; + let minor_version = packet[6]; + let header_packet_count = u16::from_be_bytes([packet[7], packet[8]]); + if major_version != 1 || minor_version != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "Ogg FLAC mapping header used unsupported version {}.{}", + major_version, minor_version + ), + }); + } + if header_packet_count == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg FLAC mapping header declared zero FLAC metadata packets".to_string(), + }); + } + let native_packet = &packet[9..]; + if native_packet.starts_with(b"fLaC") { + return Ok(ParsedOggFlacHeaderPacket { + native_header_bytes: native_packet, + mode: OggFlacHeaderMode::MappingExtraPacketsRemaining(header_packet_count), + }); + } + } + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg FLAC identification packet did not use a supported native or mapping-header signature".to_string(), + }) +} + +fn parse_flac_stream_info(block_data: &[u8], spec: &str) -> Result { + if block_data.len() != 34 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "FLAC STREAMINFO metadata block must be 34 bytes, not {}", + block_data.len() + ), + }); + } + let sample_rate = (u32::from(block_data[10]) << 12) + | (u32::from(block_data[11]) << 4) + | (u32::from(block_data[12] >> 4)); + let channel_count = u16::from(((block_data[12] >> 1) & 0x07) + 1); + let bits_per_sample = u16::from((((block_data[12] & 0x01) << 4) | (block_data[13] >> 4)) + 1); + let total_samples = ((u64::from(block_data[13] & 0x0F)) << 32) + | (u64::from(block_data[14]) << 24) + | (u64::from(block_data[15]) << 16) + | (u64::from(block_data[16]) << 8) + | u64::from(block_data[17]); + if sample_rate == 0 || channel_count == 0 || bits_per_sample == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "FLAC STREAMINFO declared a zero-valued audio parameter".to_string(), + }); + } + Ok(ParsedFlacStreamInfo { + sample_rate, + channel_count, + bits_per_sample, + total_samples, + }) +} + +fn parse_flac_frame_packet( + frame: &[u8], + spec: &str, + offset: u64, + stream_info: &ParsedFlacStreamInfo, +) -> Result { + if frame.len() < 6 { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet is truncated before the frame footer", + )); + } + let mut reader = SliceBitReader::new(frame); + if reader.read_bits_u32(15) != Some(0x7FFC) { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet did not start with the 14-bit sync code", + )); + } + let _ = reader.read_bits_u32(1).ok_or_else(|| { + invalid_flac_frame( + spec, + offset, + "FLAC frame packet is truncated in its frame header", + ) + })?; + let block_size_code = reader.read_bits_u32(4).ok_or_else(|| { + invalid_flac_frame( + spec, + offset, + "FLAC frame packet is truncated in its frame header", + ) + })?; + if block_size_code == 0 { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet used the reserved block-size code 0", + )); + } + let sample_rate_code = reader.read_bits_u32(4).ok_or_else(|| { + invalid_flac_frame( + spec, + offset, + "FLAC frame packet is truncated in its frame header", + ) + })?; + if sample_rate_code == 0x0F { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet used the reserved sample-rate code 15", + )); + } + let channel_assignment = reader.read_bits_u32(4).ok_or_else(|| { + invalid_flac_frame( + spec, + offset, + "FLAC frame packet is truncated in its frame header", + ) + })?; + let channel_count = match channel_assignment { + 0..=7 => u16::try_from(channel_assignment + 1).unwrap(), + 8..=10 => 2, + _ => { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet used an unsupported channel-assignment code", + )); + } + }; + if channel_count != stream_info.channel_count { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet changed the declared channel count", + )); + } + let bits_per_sample_code = reader.read_bits_u32(3).ok_or_else(|| { + invalid_flac_frame( + spec, + offset, + "FLAC frame packet is truncated in its frame header", + ) + })?; + let frame_bits_per_sample = match bits_per_sample_code { + 0 => stream_info.bits_per_sample, + 1 => 8, + 2 => 12, + 3 => { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet used the reserved bits-per-sample code 3", + )); + } + 4 => 16, + 5 => 20, + 6 => 24, + 7 => { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet used the reserved bits-per-sample code 7", + )); + } + _ => unreachable!(), + }; + if frame_bits_per_sample != stream_info.bits_per_sample { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet changed the declared bits-per-sample value", + )); + } + if reader.read_bits_u32(1) != Some(0) { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet set the reserved frame-header bit", + )); + } + read_flac_utf8_like_value(&mut reader, spec, offset)?; + let block_size = match block_size_code { + 6 => u32::from(read_flac_aligned_u8(&mut reader, spec, offset)?) + 1, + 7 => u32::from(read_flac_aligned_u16(&mut reader, spec, offset)?) + 1, + value => FLAC_BLOCK_SIZE_TABLE[usize::try_from(value).unwrap()], + }; + let sample_rate = match sample_rate_code { + 0 => stream_info.sample_rate, + 12 => u32::from(read_flac_aligned_u8(&mut reader, spec, offset)?), + 13 => u32::from(read_flac_aligned_u16(&mut reader, spec, offset)?), + 14 => u32::from(read_flac_aligned_u16(&mut reader, spec, offset)?) * 10, + value => FLAC_SAMPLE_RATE_TABLE + .get(usize::try_from(value).unwrap()) + .copied() + .unwrap_or(0), + }; + if sample_rate == 0 || sample_rate != stream_info.sample_rate { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet changed or omitted the declared sample rate", + )); + } + let header_crc_position = reader.position_byte().ok_or_else(|| { + invalid_flac_frame( + spec, + offset, + "FLAC frame packet lost byte alignment before the header CRC", + ) + })?; + let stored_crc8 = read_flac_aligned_u8(&mut reader, spec, offset)?; + if stored_crc8 != flac_crc8(&frame[..header_crc_position]) { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet failed its header CRC8 check", + )); + } + if reader.read_bits_u32(1) != Some(0) { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet set the reserved subframe bit", + )); + } + let subframe_type = reader.read_bits_u32(6).ok_or_else(|| { + invalid_flac_frame( + spec, + offset, + "FLAC frame packet is truncated in its first subframe", + ) + })?; + if !matches!(subframe_type, 0 | 1 | 8..=12 | 32..=63) { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet used an unsupported first-subframe type", + )); + } + let stored_crc16 = u16::from_be_bytes([frame[frame.len() - 2], frame[frame.len() - 1]]); + if stored_crc16 != flac_crc16(&frame[..frame.len() - 2]) { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet failed its frame CRC16 check", + )); + } + Ok(ParsedFlacFrameHeader { block_size }) +} + +fn read_flac_utf8_like_value( + reader: &mut SliceBitReader<'_>, + spec: &str, + offset: u64, +) -> Result<(), MuxError> { + let mut value = u32::from(read_flac_aligned_u8(reader, spec, offset)?); + let mut top = (value & 0x80) >> 1; + if (value & 0xC0) == 0x80 || value >= 0xFE { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet used an invalid UTF-8 coded frame or sample number", + )); + } + while value & top != 0 { + let continuation = read_flac_aligned_u8(reader, spec, offset)?; + if continuation & 0xC0 != 0x80 { + return Err(invalid_flac_frame( + spec, + offset, + "FLAC frame packet used a malformed UTF-8 continuation byte", + )); + } + value = (value << 6) | u32::from(continuation & 0x3F); + top <<= 5; + } + Ok(()) +} + +fn read_flac_aligned_u8( + reader: &mut SliceBitReader<'_>, + spec: &str, + offset: u64, +) -> Result { + reader + .read_aligned_u8() + .ok_or_else(|| invalid_flac_frame(spec, offset, "FLAC frame packet is truncated")) +} + +fn read_flac_aligned_u16( + reader: &mut SliceBitReader<'_>, + spec: &str, + offset: u64, +) -> Result { + let high = read_flac_aligned_u8(reader, spec, offset)?; + let low = read_flac_aligned_u8(reader, spec, offset)?; + Ok(u16::from_be_bytes([high, low])) +} + +fn invalid_flac_frame(spec: &str, offset: u64, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("{message} at byte offset {offset}"), + } +} + +fn flac_crc8(data: &[u8]) -> u8 { + let mut crc = 0_u8; + for byte in data { + crc ^= *byte; + for _ in 0..8 { + crc = if crc & 0x80 != 0 { + (crc << 1) ^ 0x07 + } else { + crc << 1 + }; + } + } + crc +} + +fn flac_crc16(data: &[u8]) -> u16 { + let mut crc = 0_u16; + for byte in data { + crc ^= u16::from(*byte) << 8; + for _ in 0..8 { + crc = if crc & 0x8000 != 0 { + (crc << 1) ^ 0x8005 + } else { + crc << 1 + }; + } + } + crc +} + +struct SliceBitReader<'a> { + bytes: &'a [u8], + bit_offset: usize, +} + +impl<'a> SliceBitReader<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { + bytes, + bit_offset: 0, + } + } + + fn read_bits_u32(&mut self, width: usize) -> Option { + let mut value = 0_u32; + for _ in 0..width { + value = (value << 1) | u32::from(self.read_bit()?); + } + Some(value) + } + + fn read_bit(&mut self) -> Option { + let byte = *self.bytes.get(self.bit_offset / 8)?; + let shift = 7 - (self.bit_offset % 8); + self.bit_offset += 1; + Some((byte >> shift) & 0x01) + } + + fn read_aligned_u8(&mut self) -> Option { + if !self.bit_offset.is_multiple_of(8) { + return None; + } + let byte = *self.bytes.get(self.bit_offset / 8)?; + self.bit_offset += 8; + Some(byte) + } + + fn position_byte(&self) -> Option { + self.bit_offset + .is_multiple_of(8) + .then_some(self.bit_offset / 8) + } +} diff --git a/src/mux/demux/h263.rs b/src/mux/demux/h263.rs new file mode 100644 index 0000000..6cdc985 --- /dev/null +++ b/src/mux/demux/h263.rs @@ -0,0 +1,525 @@ +use std::fs::File; +use std::io::Cursor; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::bitio::BitReader; +use crate::boxes::iso14496_12::{Btrt, Colr, Pasp, SampleEntry, VisualSampleEntry}; +use crate::boxes::threegpp::D263; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{ + StagedSample, build_visual_sample_entry_box_with_compressor_name, read_exact_at_sync, +}; +use super::annexb_common::{read_bits_u8_labeled, read_bits_u32_labeled}; + +const SAMPLE_ENTRY_S263: FourCc = FourCc::from_bytes(*b"s263"); +const AVI_SAMPLE_ENTRY_H263: FourCc = FourCc::from_bytes(*b"H263"); +const DEFAULT_TIMESCALE: u32 = 1_200_000; +const DEFAULT_SAMPLE_DURATION: u32 = 40_040; +const DEFAULT_FIRST_SAMPLE_DURATION: u32 = 48_000; +const DEFAULT_H263_LEVEL: u8 = 10; +const DEFAULT_H263_PROFILE: u8 = 0; +const DEFAULT_H263_COLOUR_PRIMARIES: u16 = 2; +const DEFAULT_H263_TRANSFER_CHARACTERISTICS: u16 = 2; +const DEFAULT_H263_MATRIX_COEFFICIENTS: u16 = 0; +const H263_HEADER_BYTES: usize = 5; +const SCAN_CHUNK_SIZE: usize = 16 * 1024; + +pub(in crate::mux) struct ParsedH263Track { + pub(in crate::mux) width: u16, + pub(in crate::mux) height: u16, + pub(in crate::mux) timescale: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +#[derive(Clone, Copy)] +struct ParsedH263PictureHeader { + width: u16, + height: u16, + is_sync_sample: bool, +} + +pub(in crate::mux) fn scan_h263_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + parse_h263_stream_sync(&mut file, file_size, spec) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_h263_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + parse_h263_stream_async(&mut file, file_size, spec).await +} + +pub(in crate::mux) fn parse_h263_picture_bytes( + spec: &str, + bytes: &[u8], +) -> Result<(u16, u16, bool), MuxError> { + if bytes.len() < H263_HEADER_BYTES { + return Err(invalid_h263(spec, "H.263 picture header is truncated")); + } + let mut header = [0_u8; H263_HEADER_BYTES]; + header.copy_from_slice(&bytes[..H263_HEADER_BYTES]); + let parsed = parse_picture_header_bytes(&header, spec)?; + Ok((parsed.width, parsed.height, parsed.is_sync_sample)) +} + +fn parse_h263_stream_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result { + if file_size < u64::try_from(H263_HEADER_BYTES).unwrap() { + return Err(invalid_h263( + spec, + "H.263 input is truncated before the first picture header", + )); + } + + let mut samples = Vec::new(); + let mut current_sample_start = None::; + let mut current_sync_sample = false; + let mut width = None::; + let mut height = None::; + let mut carry = Vec::new(); + let mut offset = 0_u64; + + while offset < file_size { + let read_len = + usize::try_from((file_size - offset).min(u64::try_from(SCAN_CHUNK_SIZE).unwrap())) + .map_err(|_| MuxError::LayoutOverflow("H.263 scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_exact_at_sync( + file, + offset, + &mut chunk, + spec, + "H.263 scan chunk is truncated", + )?; + + let combined_offset = offset + .checked_sub(u64::try_from(carry.len()).unwrap()) + .ok_or(MuxError::LayoutOverflow("H.263 combined scan offset"))?; + let mut combined = carry; + combined.extend_from_slice(&chunk); + + if combined.len() >= 4 { + for index in 0..=combined.len() - 4 { + if !looks_like_h263_start_code(&combined[index..index + 4]) { + continue; + } + let picture_start = combined_offset + .checked_add(u64::try_from(index).unwrap()) + .ok_or(MuxError::LayoutOverflow("H.263 picture start"))?; + let parsed = parse_picture_header_sync(file, file_size, picture_start, spec)?; + let Some(current_width) = width else { + width = Some(parsed.width); + height = Some(parsed.height); + current_sample_start = Some(picture_start); + current_sync_sample = parsed.is_sync_sample; + continue; + }; + if current_width != parsed.width || height.unwrap() != parsed.height { + return Err(invalid_h263( + spec, + "H.263 input changed coded picture size mid-stream", + )); + } + if let Some(sample_start) = current_sample_start + && picture_start > sample_start + { + samples.push(StagedSample { + data_offset: sample_start, + data_size: u32::try_from(picture_start - sample_start) + .map_err(|_| MuxError::LayoutOverflow("H.263 frame size"))?, + duration: DEFAULT_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: current_sync_sample, + }); + } + current_sample_start = Some(picture_start); + current_sync_sample = parsed.is_sync_sample; + } + } + + carry = if combined.len() > 3 { + combined[combined.len() - 3..].to_vec() + } else { + combined + }; + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("H.263 scan offset"))?; + } + + finalize_h263_track( + spec, + file_size, + width, + height, + current_sample_start, + current_sync_sample, + samples, + ) +} + +#[cfg(feature = "async")] +async fn parse_h263_stream_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result { + if file_size < u64::try_from(H263_HEADER_BYTES).unwrap() { + return Err(invalid_h263( + spec, + "H.263 input is truncated before the first picture header", + )); + } + + let mut samples = Vec::new(); + let mut current_sample_start = None::; + let mut current_sync_sample = false; + let mut width = None::; + let mut height = None::; + let mut carry = Vec::new(); + let mut offset = 0_u64; + + while offset < file_size { + let read_len = + usize::try_from((file_size - offset).min(u64::try_from(SCAN_CHUNK_SIZE).unwrap())) + .map_err(|_| MuxError::LayoutOverflow("H.263 scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_exact_at_async( + file, + offset, + &mut chunk, + spec, + "H.263 scan chunk is truncated", + ) + .await?; + + let combined_offset = offset + .checked_sub(u64::try_from(carry.len()).unwrap()) + .ok_or(MuxError::LayoutOverflow("H.263 combined scan offset"))?; + let mut combined = carry; + combined.extend_from_slice(&chunk); + + if combined.len() >= 4 { + for index in 0..=combined.len() - 4 { + if !looks_like_h263_start_code(&combined[index..index + 4]) { + continue; + } + let picture_start = combined_offset + .checked_add(u64::try_from(index).unwrap()) + .ok_or(MuxError::LayoutOverflow("H.263 picture start"))?; + let parsed = + parse_picture_header_async(file, file_size, picture_start, spec).await?; + let Some(current_width) = width else { + width = Some(parsed.width); + height = Some(parsed.height); + current_sample_start = Some(picture_start); + current_sync_sample = parsed.is_sync_sample; + continue; + }; + if current_width != parsed.width || height.unwrap() != parsed.height { + return Err(invalid_h263( + spec, + "H.263 input changed coded picture size mid-stream", + )); + } + if let Some(sample_start) = current_sample_start + && picture_start > sample_start + { + samples.push(StagedSample { + data_offset: sample_start, + data_size: u32::try_from(picture_start - sample_start) + .map_err(|_| MuxError::LayoutOverflow("H.263 frame size"))?, + duration: DEFAULT_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: current_sync_sample, + }); + } + current_sample_start = Some(picture_start); + current_sync_sample = parsed.is_sync_sample; + } + } + + carry = if combined.len() > 3 { + combined[combined.len() - 3..].to_vec() + } else { + combined + }; + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("H.263 scan offset"))?; + } + + finalize_h263_track( + spec, + file_size, + width, + height, + current_sample_start, + current_sync_sample, + samples, + ) +} + +fn finalize_h263_track( + spec: &str, + file_size: u64, + width: Option, + height: Option, + current_sample_start: Option, + current_sync_sample: bool, + mut samples: Vec, +) -> Result { + let width = width.ok_or_else(|| { + invalid_h263( + spec, + "H.263 input did not expose a supported picture-start header", + ) + })?; + let height = height.ok_or_else(|| { + invalid_h263( + spec, + "H.263 input did not expose a supported picture-start header", + ) + })?; + let Some(sample_start) = current_sample_start else { + return Err(invalid_h263( + spec, + "H.263 input did not contain any complete picture starts", + )); + }; + if sample_start >= file_size { + return Err(invalid_h263( + spec, + "H.263 final frame start ran past the end of the file", + )); + } + samples.push(StagedSample { + data_offset: sample_start, + data_size: u32::try_from(file_size - sample_start) + .map_err(|_| MuxError::LayoutOverflow("H.263 trailing frame size"))?, + duration: DEFAULT_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: current_sync_sample, + }); + for sample in &mut samples { + sample.is_sync_sample = true; + } + if let Some(first_sample) = samples.first_mut() { + first_sample.duration = DEFAULT_FIRST_SAMPLE_DURATION; + } + let (display_width, display_height) = display_dimensions_from_coded(width, height); + Ok(ParsedH263Track { + width: display_width, + height: display_height, + timescale: DEFAULT_TIMESCALE, + sample_entry_box: build_h263_sample_entry_box(width, height)?, + samples, + }) +} + +fn parse_picture_header_sync( + file: &mut File, + file_size: u64, + picture_start: u64, + spec: &str, +) -> Result { + if picture_start + .checked_add(u64::try_from(H263_HEADER_BYTES).unwrap()) + .is_none_or(|end| end > file_size) + { + return Err(invalid_h263(spec, "H.263 picture header is truncated")); + } + let mut header = [0_u8; H263_HEADER_BYTES]; + read_exact_at_sync( + file, + picture_start, + &mut header, + spec, + "H.263 picture header is truncated", + )?; + parse_picture_header_bytes(&header, spec) +} + +#[cfg(feature = "async")] +async fn parse_picture_header_async( + file: &mut TokioFile, + file_size: u64, + picture_start: u64, + spec: &str, +) -> Result { + if picture_start + .checked_add(u64::try_from(H263_HEADER_BYTES).unwrap()) + .is_none_or(|end| end > file_size) + { + return Err(invalid_h263(spec, "H.263 picture header is truncated")); + } + let mut header = [0_u8; H263_HEADER_BYTES]; + read_exact_at_async( + file, + picture_start, + &mut header, + spec, + "H.263 picture header is truncated", + ) + .await?; + parse_picture_header_bytes(&header, spec) +} + +fn parse_picture_header_bytes( + bytes: &[u8; H263_HEADER_BYTES], + spec: &str, +) -> Result { + let mut reader = BitReader::new(Cursor::new(bytes.as_slice())); + let picture_start_code = read_bits_u32_labeled(&mut reader, 22, spec, "H.263")?; + if picture_start_code != 0x20 { + return Err(invalid_h263( + spec, + "H.263 picture header did not start with the mandatory PSC pattern", + )); + } + let _temporal_reference = read_bits_u8_labeled(&mut reader, 8, spec, "H.263")?; + let _mandatory_bits = read_bits_u8_labeled(&mut reader, 5, spec, "H.263")?; + let picture_size_format = read_bits_u8_labeled(&mut reader, 3, spec, "H.263")?; + let (width, height) = picture_size_from_format(picture_size_format).ok_or_else(|| { + invalid_h263( + spec, + "H.263 picture header used an unsupported picture-size format", + ) + })?; + Ok(ParsedH263PictureHeader { + width, + height, + is_sync_sample: bytes[4] & 0x02 == 0, + }) +} + +fn picture_size_from_format(format: u8) -> Option<(u16, u16)> { + match format { + 1 => Some((128, 96)), + 2 => Some((176, 144)), + 3 => Some((352, 288)), + 4 => Some((704, 576)), + 5 => Some((1408, 1152)), + _ => None, + } +} + +pub(in crate::mux) fn build_h263_sample_entry_box( + width: u16, + height: u16, +) -> Result, MuxError> { + let d263 = super::super::mp4::encode_typed_box( + &D263 { + vendor: 0, + decoder_version: 0, + h263_level: DEFAULT_H263_LEVEL, + h263_profile: DEFAULT_H263_PROFILE, + }, + &[], + )?; + let mut child_boxes = vec![d263]; + if let Some((h_spacing, v_spacing)) = default_h263_pixel_aspect_ratio(width, height) { + child_boxes.push(super::super::mp4::encode_typed_box( + &Pasp { + h_spacing, + v_spacing, + }, + &[], + )?); + } + child_boxes.push(super::super::mp4::encode_typed_box( + &Colr { + colour_type: FourCc::from_bytes(*b"nclx"), + colour_primaries: DEFAULT_H263_COLOUR_PRIMARIES, + transfer_characteristics: DEFAULT_H263_TRANSFER_CHARACTERISTICS, + matrix_coefficients: DEFAULT_H263_MATRIX_COEFFICIENTS, + full_range_flag: false, + reserved: 0, + profile: Vec::new(), + unknown: Vec::new(), + }, + &[], + )?); + build_visual_sample_entry_box_with_compressor_name( + SAMPLE_ENTRY_S263, + width, + height, + &[], + &child_boxes, + ) +} + +pub(in crate::mux) fn build_avi_h263_sample_entry_box( + width: u16, + height: u16, + btrt: Btrt, +) -> Result, MuxError> { + let btrt = super::super::mp4::encode_typed_box(&btrt, &[])?; + let mut compressorname = [0_u8; 32]; + compressorname[0] = 4; + compressorname[1..5].copy_from_slice(b"H263"); + super::super::mp4::encode_typed_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: AVI_SAMPLE_ENTRY_H263, + data_reference_index: 1, + }, + width, + height, + horizresolution: 72, + vertresolution: 72, + frame_count: 1, + compressorname, + depth: 0x0018, + pre_defined3: -1, + ..VisualSampleEntry::default() + }, + &btrt, + ) +} + +fn looks_like_h263_start_code(bytes: &[u8]) -> bool { + bytes.len() >= 4 && (u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) >> 10) == 0x20 +} + +fn default_h263_pixel_aspect_ratio(width: u16, height: u16) -> Option<(u32, u32)> { + match (width, height) { + (176, 144) | (352, 288) | (704, 576) | (1408, 1152) => Some((12, 11)), + _ => None, + } +} + +fn display_dimensions_from_coded(width: u16, height: u16) -> (u16, u16) { + if let Some((h_spacing, v_spacing)) = default_h263_pixel_aspect_ratio(width, height) { + let widened = u64::from(width) * u64::from(h_spacing); + let display_width = widened.div_ceil(u64::from(v_spacing)); + return (u16::try_from(display_width).unwrap_or(u16::MAX), height); + } + (width, height) +} + +fn invalid_h263(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} diff --git a/src/mux/demux/h264.rs b/src/mux/demux/h264.rs new file mode 100644 index 0000000..72b2c62 --- /dev/null +++ b/src/mux/demux/h264.rs @@ -0,0 +1,2013 @@ +use std::fs::File; +use std::io::{Cursor, Read}; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::AsyncReadExt; + +use crate::FourCc; +use crate::bitio::BitReader; +use crate::boxes::AnyTypeBox; +use crate::boxes::iso14496_12::{ + AVCDecoderConfiguration, AVCParameterSet, Colr, Pasp, SampleEntry, VisualSampleEntry, +}; + +use super::super::MuxError; +use super::super::import::{ + SegmentedMuxSourceSegment, SegmentedMuxSourceSegmentData, SegmentedMuxSourceSpec, StagedSample, + build_btrt_from_sample_sizes_with_total_duration, +}; +use super::annexb_common::{ + AnnexBNal, AnnexBNalScanner, IndexedAnnexBTrack, nal_to_rbsp, push_unique_nal, + read_bit_labeled, read_bits_u8_labeled, read_bits_u16_labeled, read_bits_u32_labeled, + read_se_labeled, read_ue_labeled, +}; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::read_segmented_bytes_sync; + +const DEFAULT_RAW_H264_TIMESCALE: u32 = 25_000; +const DEFAULT_RAW_H264_SAMPLE_DURATION: u32 = 1_000; + +pub(in crate::mux) fn stage_annex_b_h264_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let mut scanner = AnnexBNalScanner::default(); + let (sps_list, pps_list) = collect_h264_parameter_sets_sync(path)?; + let mut state = H264StageState::with_parameter_sets(sps_list, pps_list, false); + let mut chunk = [0_u8; 16 * 1024]; + + loop { + let read = file.read(&mut chunk)?; + if read == 0 { + break; + } + scanner.push(&chunk[..read], |nal| stage_h264_nal(&mut state, nal))?; + } + scanner.finish(|nal| stage_h264_nal(&mut state, nal))?; + finalize_h264_staged_track(path, state, spec) +} + +pub(in crate::mux) fn build_h264_sample_entry_from_avc_config_with_options( + avcc: &AVCDecoderConfiguration, + spec: &str, + include_colr: bool, +) -> Result<(Vec, u16, u16), MuxError> { + build_h264_sample_entry_from_avc_config_with_box_type_and_options( + avcc, + FourCc::from_bytes(*b"avc1"), + spec, + include_colr, + ) +} + +pub(in crate::mux) fn build_h264_sample_entry_from_avc_config_with_box_type_and_options( + avcc: &AVCDecoderConfiguration, + sample_entry_type: FourCc, + spec: &str, + include_colr: bool, +) -> Result<(Vec, u16, u16), MuxError> { + if avcc.sequence_parameter_sets.is_empty() || avcc.picture_parameter_sets.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.264 configuration input must include SPS and PPS parameter sets" + .to_string(), + }); + } + let sequence_parameter_sets = avcc + .sequence_parameter_sets + .iter() + .map(|parameter_set| parameter_set.nal_unit.clone()) + .collect::>(); + let sps_info = parse_h264_sps(&sequence_parameter_sets[0], spec)?; + let mut authored_avcc = avcc.clone(); + if h264_profile_supports_config_extensions(authored_avcc.profile) + && !authored_avcc.high_profile_fields_enabled + { + authored_avcc.high_profile_fields_enabled = true; + authored_avcc.chroma_format = sps_info.chroma_format; + authored_avcc.bit_depth_luma_minus8 = sps_info.bit_depth_luma_minus8; + authored_avcc.bit_depth_chroma_minus8 = sps_info.bit_depth_chroma_minus8; + } + let sample_entry_box = build_h264_sample_entry_box_from_avc_config( + &sps_info, + authored_avcc, + sample_entry_type, + include_colr, + )?; + Ok((sample_entry_box, sps_info.width, sps_info.height)) +} + +pub(in crate::mux) fn stage_annex_b_h264_segmented_sync( + path: &Path, + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut scanner = AnnexBNalScanner::default(); + let (sps_list, pps_list) = + collect_h264_parameter_sets_segmented_sync(file, segments, total_size, spec)?; + let mut state = H264StageState::with_parameter_sets(sps_list, pps_list, true); + let mut offset = 0_u64; + + while offset < total_size { + let read_len = usize::try_from((total_size - offset).min(16 * 1024)) + .map_err(|_| MuxError::LayoutOverflow("segmented H.264 scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut chunk, + spec, + "segmented H.264 scan chunk is truncated", + )?; + for nal in scanner.collect(&chunk) { + stage_h264_nal_segmented(&mut state, nal)?; + } + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("segmented H.264 scan offset"))?; + } + for nal in scanner.finish_collect() { + stage_h264_nal_segmented(&mut state, nal)?; + } + finalize_h264_staged_track(path, state, spec) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn stage_annex_b_h264_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let mut scanner = AnnexBNalScanner::default(); + let (sps_list, pps_list) = collect_h264_parameter_sets_async(path).await?; + let mut state = H264StageState::with_parameter_sets(sps_list, pps_list, false); + let mut chunk = [0_u8; 16 * 1024]; + + loop { + let read = file.read(&mut chunk).await?; + if read == 0 { + break; + } + for nal in scanner.collect(&chunk[..read]) { + stage_h264_nal(&mut state, nal)?; + } + } + for nal in scanner.finish_collect() { + stage_h264_nal(&mut state, nal)?; + } + finalize_h264_staged_track(path, state, spec) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn stage_annex_b_h264_segmented_async( + path: &Path, + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut scanner = AnnexBNalScanner::default(); + let (sps_list, pps_list) = + collect_h264_parameter_sets_segmented_async(file, segments, total_size, spec).await?; + let mut state = H264StageState::with_parameter_sets(sps_list, pps_list, true); + let mut offset = 0_u64; + + while offset < total_size { + let read_len = usize::try_from((total_size - offset).min(16 * 1024)) + .map_err(|_| MuxError::LayoutOverflow("segmented H.264 scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut chunk, + spec, + "segmented H.264 scan chunk is truncated", + ) + .await?; + for nal in scanner.collect(&chunk) { + stage_h264_nal_segmented(&mut state, nal)?; + } + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("segmented H.264 scan offset"))?; + } + for nal in scanner.finish_collect() { + stage_h264_nal_segmented(&mut state, nal)?; + } + finalize_h264_staged_track(path, state, spec) +} + +fn collect_h264_parameter_sets_sync(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut scanner = AnnexBNalScanner::default(); + let mut sps_list = Vec::new(); + let mut pps_list = Vec::new(); + let mut chunk = [0_u8; 16 * 1024]; + + loop { + let read = file.read(&mut chunk)?; + if read == 0 { + break; + } + scanner.push(&chunk[..read], |nal| { + collect_h264_parameter_set_nal(&mut sps_list, &mut pps_list, nal); + Ok(()) + })?; + } + scanner.finish(|nal| { + collect_h264_parameter_set_nal(&mut sps_list, &mut pps_list, nal); + Ok(()) + })?; + Ok((sps_list, pps_list)) +} + +fn collect_h264_parameter_sets_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut scanner = AnnexBNalScanner::default(); + let mut sps_list = Vec::new(); + let mut pps_list = Vec::new(); + let mut offset = 0_u64; + + while offset < total_size { + let read_len = usize::try_from((total_size - offset).min(16 * 1024)) + .map_err(|_| MuxError::LayoutOverflow("segmented H.264 scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut chunk, + spec, + "segmented H.264 scan chunk is truncated", + )?; + for nal in scanner.collect(&chunk) { + collect_h264_parameter_set_nal(&mut sps_list, &mut pps_list, nal); + } + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("segmented H.264 scan offset"))?; + } + for nal in scanner.finish_collect() { + collect_h264_parameter_set_nal(&mut sps_list, &mut pps_list, nal); + } + Ok((sps_list, pps_list)) +} + +#[cfg(feature = "async")] +async fn collect_h264_parameter_sets_async( + path: &Path, +) -> Result<(Vec>, Vec>), MuxError> { + let mut file = TokioFile::open(path).await?; + let mut scanner = AnnexBNalScanner::default(); + let mut sps_list = Vec::new(); + let mut pps_list = Vec::new(); + let mut chunk = [0_u8; 16 * 1024]; + + loop { + let read = file.read(&mut chunk).await?; + if read == 0 { + break; + } + for nal in scanner.collect(&chunk[..read]) { + collect_h264_parameter_set_nal(&mut sps_list, &mut pps_list, nal); + } + } + for nal in scanner.finish_collect() { + collect_h264_parameter_set_nal(&mut sps_list, &mut pps_list, nal); + } + Ok((sps_list, pps_list)) +} + +#[cfg(feature = "async")] +async fn collect_h264_parameter_sets_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result<(Vec>, Vec>), MuxError> { + let mut scanner = AnnexBNalScanner::default(); + let mut sps_list = Vec::new(); + let mut pps_list = Vec::new(); + let mut offset = 0_u64; + + while offset < total_size { + let read_len = usize::try_from((total_size - offset).min(16 * 1024)) + .map_err(|_| MuxError::LayoutOverflow("segmented H.264 scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut chunk, + spec, + "segmented H.264 scan chunk is truncated", + ) + .await?; + for nal in scanner.collect(&chunk) { + collect_h264_parameter_set_nal(&mut sps_list, &mut pps_list, nal); + } + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("segmented H.264 scan offset"))?; + } + for nal in scanner.finish_collect() { + collect_h264_parameter_set_nal(&mut sps_list, &mut pps_list, nal); + } + Ok((sps_list, pps_list)) +} + +fn collect_h264_parameter_set_nal( + sps_list: &mut Vec>, + pps_list: &mut Vec>, + nal: AnnexBNal, +) { + if nal.bytes.is_empty() { + return; + } + match nal.bytes[0] & 0x1F { + 7 => push_unique_nal(sps_list, nal.bytes), + 8 => push_unique_nal(pps_list, nal.bytes), + _ => {} + } +} + +struct H264StageState { + segmented_mode: bool, + sps_list: Vec>, + pps_list: Vec>, + samples: Vec, + sample_first_vcl_nals: Vec>, + segments: Vec, + pending_prefix_nals: Vec, + current_sample_offset: Option, + current_sample_first_vcl_nal: Option>, + current_access_unit_info: Option, + current_sample_poc: Option, + current_sample_size: u32, + current_sync: bool, + current_has_vcl: bool, + logical_size: u64, + prev_poc_lsb: u32, + prev_poc_msb: i32, +} + +type H264ParameterSetLists = (Vec>, Vec>); + +impl H264StageState { + fn with_parameter_sets( + sps_list: Vec>, + pps_list: Vec>, + segmented_mode: bool, + ) -> Self { + Self { + segmented_mode, + sps_list, + pps_list, + samples: Vec::new(), + sample_first_vcl_nals: Vec::new(), + segments: Vec::new(), + pending_prefix_nals: Vec::new(), + current_sample_offset: None, + current_sample_first_vcl_nal: None, + current_access_unit_info: None, + current_sample_poc: None, + current_sample_size: 0, + current_sync: false, + current_has_vcl: false, + logical_size: 0, + prev_poc_lsb: 0, + prev_poc_msb: 0, + } + } + + fn finish_current_sample(&mut self) { + if let Some(data_offset) = self.current_sample_offset.take() { + if self.current_has_vcl { + self.samples.push(StagedSample { + data_offset, + data_size: self.current_sample_size, + duration: 0, + composition_time_offset: 0, + is_sync_sample: self.current_sync, + }); + self.sample_first_vcl_nals + .push(self.current_sample_first_vcl_nal.take().unwrap_or_default()); + } else { + self.current_sample_first_vcl_nal = None; + } + self.current_access_unit_info = None; + self.current_sample_poc = None; + self.current_sample_size = 0; + self.current_sync = false; + self.current_has_vcl = false; + } + } + + fn flush_pending_prefix_nals(&mut self) -> Result<(), MuxError> { + for nal in std::mem::take(&mut self.pending_prefix_nals) { + if self.segmented_mode { + self.append_sample_bytes(nal.bytes, false, false)?; + } else { + let source_size = u32::try_from(nal.bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("raw H.264 NAL length"))?; + self.append_sample_nal(nal.source_offset, source_size, false, false)?; + } + } + Ok(()) + } + + fn trim_leading_non_sync_samples(&mut self, spec: &str) -> Result<(), MuxError> { + let Some(first_sync_index) = self.samples.iter().position(|sample| sample.is_sync_sample) + else { + return Ok(()); + }; + if first_sync_index == 0 { + return Ok(()); + } + + let trim_offset = self.samples[first_sync_index].data_offset; + self.samples.drain(0..first_sync_index); + self.sample_first_vcl_nals.drain(0..first_sync_index); + for sample in &mut self.samples { + sample.data_offset = sample + .data_offset + .checked_sub(trim_offset) + .ok_or(MuxError::LayoutOverflow("raw H.264 trimmed sample offset"))?; + } + + let mut rebased_segments = Vec::with_capacity(self.segments.len()); + for mut segment in self.segments.drain(..) { + if segment.logical_end() <= trim_offset { + continue; + } + if segment.logical_offset < trim_offset { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "raw H.264 leading trim crossed a transformed sample boundary" + .to_string(), + }); + } + segment.logical_offset = segment + .logical_offset + .checked_sub(trim_offset) + .ok_or(MuxError::LayoutOverflow("raw H.264 trimmed segment offset"))?; + rebased_segments.push(segment); + } + self.segments = rebased_segments; + self.logical_size = self + .logical_size + .checked_sub(trim_offset) + .ok_or(MuxError::LayoutOverflow("raw H.264 trimmed logical size"))?; + Ok(()) + } + + fn append_sample_nal( + &mut self, + source_offset: u64, + source_size: u32, + is_sync_sample: bool, + is_vcl: bool, + ) -> Result<(), MuxError> { + if self.current_sample_offset.is_none() { + self.current_sample_offset = Some(self.logical_size); + } + let prefix = source_size.to_be_bytes(); + self.segments.push(SegmentedMuxSourceSegment { + logical_offset: self.logical_size, + data: SegmentedMuxSourceSegmentData::Prefix(prefix), + }); + self.logical_size = self + .logical_size + .checked_add(4) + .ok_or(MuxError::LayoutOverflow("raw H.264 transformed payload"))?; + self.segments.push(SegmentedMuxSourceSegment { + logical_offset: self.logical_size, + data: SegmentedMuxSourceSegmentData::FileRange { + source_offset, + size: source_size, + }, + }); + self.current_sample_size = self + .current_sample_size + .checked_add( + 4_u32 + .checked_add(source_size) + .ok_or(MuxError::LayoutOverflow( + "raw H.264 transformed sample size", + ))?, + ) + .ok_or(MuxError::LayoutOverflow("raw H.264 staged sample size"))?; + self.logical_size = self + .logical_size + .checked_add(u64::from(source_size)) + .ok_or(MuxError::LayoutOverflow("raw H.264 transformed payload"))?; + self.current_sync |= is_sync_sample; + self.current_has_vcl |= is_vcl; + Ok(()) + } + + fn append_sample_bytes( + &mut self, + bytes: Vec, + is_sync_sample: bool, + is_vcl: bool, + ) -> Result<(), MuxError> { + let source_size = u32::try_from(bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("segmented H.264 NAL length"))?; + if self.current_sample_offset.is_none() { + self.current_sample_offset = Some(self.logical_size); + } + let prefix = source_size.to_be_bytes(); + self.segments.push(SegmentedMuxSourceSegment { + logical_offset: self.logical_size, + data: SegmentedMuxSourceSegmentData::Prefix(prefix), + }); + self.logical_size = self + .logical_size + .checked_add(4) + .ok_or(MuxError::LayoutOverflow( + "segmented H.264 transformed payload", + ))?; + self.segments.push(SegmentedMuxSourceSegment { + logical_offset: self.logical_size, + data: SegmentedMuxSourceSegmentData::Bytes(bytes), + }); + self.current_sample_size = self + .current_sample_size + .checked_add( + 4_u32 + .checked_add(source_size) + .ok_or(MuxError::LayoutOverflow( + "segmented H.264 transformed sample size", + ))?, + ) + .ok_or(MuxError::LayoutOverflow( + "segmented H.264 staged sample size", + ))?; + self.logical_size = self + .logical_size + .checked_add(u64::from(source_size)) + .ok_or(MuxError::LayoutOverflow( + "segmented H.264 transformed payload", + ))?; + self.current_sync |= is_sync_sample; + self.current_has_vcl |= is_vcl; + Ok(()) + } +} + +fn stage_h264_nal(state: &mut H264StageState, nal: AnnexBNal) -> Result<(), MuxError> { + if nal.bytes.is_empty() { + return Ok(()); + } + let nal_type = nal.bytes[0] & 0x1F; + match nal_type { + 7 => push_unique_nal(&mut state.sps_list, nal.bytes), + 8 => push_unique_nal(&mut state.pps_list, nal.bytes), + 9 => state.finish_current_sample(), + _ => { + let is_vcl = is_h264_vcl_nal_type(nal_type); + if is_vcl { + let access_unit_info = parse_h264_stage_access_unit_info(state, &nal.bytes, "h264"); + if let Some(access_unit_info) = access_unit_info { + if state.current_has_vcl + && state + .current_access_unit_info + .as_ref() + .is_some_and(|current| { + h264_starts_new_access_unit(current, &access_unit_info) + }) + { + state.finish_current_sample(); + } + if state.current_access_unit_info.is_none() { + state.current_access_unit_info = Some(access_unit_info); + } + if let Some(parsed_poc) = access_unit_info.poc { + if state.current_sample_poc.is_none() { + state.current_sample_poc = Some(parsed_poc.poc); + } + state.prev_poc_lsb = parsed_poc.poc_lsb; + state.prev_poc_msb = parsed_poc.poc_msb; + } + } else if h264_first_mb_in_slice(&nal.bytes, "h264")? == 0 && state.current_has_vcl + { + state.finish_current_sample(); + } + state.flush_pending_prefix_nals()?; + } + if is_vcl && state.current_sample_first_vcl_nal.is_none() { + state.current_sample_first_vcl_nal = Some(nal.bytes.clone()); + } + if is_vcl { + let nal_len = u32::try_from(nal.bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("H.264 NAL length"))?; + state.append_sample_nal(nal.source_offset, nal_len, nal_type == 5, true)?; + } else { + state.pending_prefix_nals.push(nal); + } + } + } + Ok(()) +} + +fn stage_h264_nal_segmented(state: &mut H264StageState, nal: AnnexBNal) -> Result<(), MuxError> { + if nal.bytes.is_empty() { + return Ok(()); + } + let nal_type = nal.bytes[0] & 0x1F; + match nal_type { + 7 => push_unique_nal(&mut state.sps_list, nal.bytes), + 8 => push_unique_nal(&mut state.pps_list, nal.bytes), + 9 => state.finish_current_sample(), + _ => { + let is_vcl = is_h264_vcl_nal_type(nal_type); + if is_vcl { + let access_unit_info = parse_h264_stage_access_unit_info(state, &nal.bytes, "h264"); + if let Some(access_unit_info) = access_unit_info { + if state.current_has_vcl + && state + .current_access_unit_info + .as_ref() + .is_some_and(|current| { + h264_starts_new_access_unit(current, &access_unit_info) + }) + { + state.finish_current_sample(); + } + if state.current_access_unit_info.is_none() { + state.current_access_unit_info = Some(access_unit_info); + } + if let Some(parsed_poc) = access_unit_info.poc { + if state.current_sample_poc.is_none() { + state.current_sample_poc = Some(parsed_poc.poc); + } + state.prev_poc_lsb = parsed_poc.poc_lsb; + state.prev_poc_msb = parsed_poc.poc_msb; + } + } else if h264_first_mb_in_slice(&nal.bytes, "h264")? == 0 && state.current_has_vcl + { + state.finish_current_sample(); + } + state.flush_pending_prefix_nals()?; + } + if is_vcl && state.current_sample_first_vcl_nal.is_none() { + state.current_sample_first_vcl_nal = Some(nal.bytes.clone()); + } + if is_vcl { + state.append_sample_bytes(nal.bytes, nal_type == 5, true)?; + } else { + state.pending_prefix_nals.push(nal); + } + } + } + Ok(()) +} + +fn parse_h264_stage_access_unit_info( + state: &H264StageState, + nal: &[u8], + spec: &str, +) -> Option { + let sps = state.sps_list.first()?; + let pps = state.pps_list.first()?; + let sps_info = parse_h264_sps(sps, spec).ok()?; + let pps_info = parse_h264_pps(pps, spec).ok()?; + parse_h264_access_unit_info( + nal, + &sps_info, + &pps_info, + state.prev_poc_lsb, + state.prev_poc_msb, + spec, + ) +} + +fn finalize_h264_staged_track( + path: &Path, + mut state: H264StageState, + spec: &str, +) -> Result { + if state.current_has_vcl { + state.flush_pending_prefix_nals()?; + } + state.finish_current_sample(); + if state.sps_list.is_empty() || state.pps_list.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.264 input must include SPS and PPS NAL units".to_string(), + }); + } + if state.samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.264 input contained parameter sets but no media samples".to_string(), + }); + } + + let sps_info = parse_h264_sps(&state.sps_list[0], spec)?; + let pps_info = parse_h264_pps(&state.pps_list[0], spec)?; + let (timescale, sample_duration) = if let (Some(time_scale), Some(num_units_in_tick)) = ( + sps_info.timing_time_scale, + sps_info.timing_num_units_in_tick, + ) { + if time_scale == 0 || num_units_in_tick == 0 { + default_raw_h264_sample_timing() + } else { + let first_field_pic_flag = state + .sample_first_vcl_nals + .first() + .and_then(|nal| parse_h264_slice_poc(nal, &sps_info, &pps_info, 0, 0, spec)) + .map(|parsed| parsed.field_pic_flag) + .unwrap_or(false); + derive_raw_h264_sample_timing( + time_scale, + num_units_in_tick, + sps_info.vui_pic_struct_present_flag, + first_field_pic_flag, + spec, + )? + } + } else { + default_raw_h264_sample_timing() + }; + for sample in &mut state.samples { + sample.duration = sample_duration; + } + state.trim_leading_non_sync_samples(spec)?; + + let single_sample = state.samples.len() == 1; + let sample_timing = (!single_sample).then(|| { + derive_h264_sample_timing_from_poc( + &state.sample_first_vcl_nals, + &state.samples, + &sps_info, + &pps_info, + sample_duration, + u64::from(sample_duration), + spec, + ) + }); + let mut source_edit_media_time = None; + if let Some(Some(sample_timing)) = sample_timing { + for (sample, composition_time_offset) in state + .samples + .iter_mut() + .zip(sample_timing.composition_offsets) + { + sample.composition_time_offset = composition_time_offset; + } + source_edit_media_time = sample_timing.source_edit_media_time; + } + + let authored_sample_entry_box = build_h264_sample_entry_box( + &sps_info, + &state.sps_list, + &state.pps_list, + sps_info.color_info.is_some(), + )?; + let authored_media_duration = authored_h264_media_duration( + state + .samples + .iter() + .map(|sample| (sample.duration, sample.composition_time_offset)), + )?; + let sample_entry_box = if single_sample { + authored_sample_entry_box + } else { + retune_carried_h264_sample_entry_box( + &authored_sample_entry_box, + timescale, + Some(authored_media_duration), + state + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + false, + false, + )? + }; + let track_width = display_track_width(sps_info.width, sps_info.pixel_aspect_ratio.as_ref()); + Ok(IndexedAnnexBTrack { + segmented_source: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: state.segments, + total_size: state.logical_size, + }, + track_width, + track_height: sps_info.height, + timescale, + sample_entry_box, + source_edit_media_time, + samples: state.samples, + }) +} + +const fn is_h264_vcl_nal_type(nal_type: u8) -> bool { + matches!(nal_type, 1..=5) +} + +fn h264_first_mb_in_slice(nal: &[u8], spec: &str) -> Result { + if nal.len() < 2 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.264 VCL NAL is too short".to_string(), + }); + } + let rbsp = nal_to_rbsp(&nal[1..]); + let mut reader = BitReader::new(Cursor::new(rbsp)); + Ok(u64::from(read_ue(&mut reader, spec)?)) +} + +fn build_h264_sample_entry_box( + sps_info: &H264SpsInfo, + sequence_parameter_sets: &[Vec], + picture_parameter_sets: &[Vec], + include_colr: bool, +) -> Result, MuxError> { + let avcc = AVCDecoderConfiguration { + configuration_version: 1, + profile: sps_info.profile, + profile_compatibility: sps_info.profile_compatibility, + level: sps_info.level, + length_size_minus_one: 3, + num_of_sequence_parameter_sets: u8::try_from(sequence_parameter_sets.len()) + .map_err(|_| MuxError::LayoutOverflow("AVC SPS count"))?, + sequence_parameter_sets: sequence_parameter_sets + .iter() + .map(|nal| -> Result { + Ok(AVCParameterSet { + length: u16::try_from(nal.len()) + .map_err(|_| MuxError::LayoutOverflow("AVC SPS length"))?, + nal_unit: nal.clone(), + }) + }) + .collect::, _>>()?, + num_of_picture_parameter_sets: u8::try_from(picture_parameter_sets.len()) + .map_err(|_| MuxError::LayoutOverflow("AVC PPS count"))?, + picture_parameter_sets: picture_parameter_sets + .iter() + .map(|nal| -> Result { + Ok(AVCParameterSet { + length: u16::try_from(nal.len()) + .map_err(|_| MuxError::LayoutOverflow("AVC PPS length"))?, + nal_unit: nal.clone(), + }) + }) + .collect::, _>>()?, + high_profile_fields_enabled: sps_info.high_profile_fields_enabled, + chroma_format: sps_info.chroma_format, + bit_depth_luma_minus8: sps_info.bit_depth_luma_minus8, + bit_depth_chroma_minus8: sps_info.bit_depth_chroma_minus8, + num_of_sequence_parameter_set_ext: 0, + sequence_parameter_sets_ext: Vec::new(), + }; + + build_h264_sample_entry_box_from_avc_config( + sps_info, + avcc, + FourCc::from_bytes(*b"avc1"), + include_colr, + ) +} + +fn build_h264_sample_entry_box_from_avc_config( + sps_info: &H264SpsInfo, + avcc: AVCDecoderConfiguration, + sample_entry_type: FourCc, + include_colr: bool, +) -> Result, MuxError> { + let mut avc1 = VisualSampleEntry::default(); + avc1.set_box_type(sample_entry_type); + avc1.sample_entry = SampleEntry { + box_type: sample_entry_type, + data_reference_index: 1, + }; + avc1.width = sps_info.width; + avc1.height = sps_info.height; + avc1.horizresolution = 72_u32 << 16; + avc1.vertresolution = 72_u32 << 16; + avc1.frame_count = 1; + avc1.depth = 0x0018; + avc1.pre_defined3 = -1; + + let mut child_boxes = vec![super::super::mp4::encode_typed_box(&avcc, &[])?]; + if let Some(pixel_aspect_ratio) = sps_info.pixel_aspect_ratio.as_ref() { + child_boxes.push(super::super::mp4::encode_typed_box( + &Pasp { + h_spacing: pixel_aspect_ratio.h_spacing, + v_spacing: pixel_aspect_ratio.v_spacing, + }, + &[], + )?); + } + if include_colr { + let color_info = sps_info.color_info.as_ref().map_or( + Colr { + colour_type: FourCc::from_bytes(*b"nclx"), + colour_primaries: 1, + transfer_characteristics: 1, + matrix_coefficients: 1, + full_range_flag: false, + reserved: 0, + profile: Vec::new(), + unknown: Vec::new(), + }, + |color_info| Colr { + colour_type: FourCc::from_bytes(*b"nclx"), + colour_primaries: color_info.colour_primaries, + transfer_characteristics: color_info.transfer_characteristics, + matrix_coefficients: color_info.matrix_coefficients, + full_range_flag: color_info.full_range_flag, + reserved: 0, + profile: Vec::new(), + unknown: Vec::new(), + }, + ); + child_boxes.push(super::super::mp4::encode_typed_box(&color_info, &[])?); + } + + super::super::mp4::encode_typed_box(&avc1, &child_boxes.concat()) +} + +pub(in crate::mux) fn retune_carried_h264_sample_entry_box( + sample_entry_box: &[u8], + timescale: u32, + total_duration_override: Option, + samples: I, + include_pasp: bool, + include_default_colr: bool, +) -> Result, MuxError> +where + I: IntoIterator, +{ + if sample_entry_box.len() < 8 || &sample_entry_box[4..8] != b"avc1" { + return Err(MuxError::UnsupportedTrackImport { + spec: "h264".to_string(), + message: "carried H.264 sample entry did not use the `avc1` sample entry type" + .to_string(), + }); + } + + let child_boxes = super::super::mp4::visual_sample_entry_immediate_children(sample_entry_box)?; + let mut avcc_box = None::>; + let mut preserved_pasp_box = None::>; + let mut preserved_colr_box = None::>; + let mut preserved_other_boxes = Vec::new(); + for child_box in child_boxes { + let child_type = FourCc::from_bytes( + child_box + .get(4..8) + .ok_or(MuxError::LayoutOverflow("carried H.264 child-box type"))? + .try_into() + .map_err(|_| MuxError::LayoutOverflow("carried H.264 child-box type"))?, + ); + match child_type { + value if value == FourCc::from_bytes(*b"avcC") => avcc_box = Some(child_box), + value if value == FourCc::from_bytes(*b"pasp") => preserved_pasp_box = Some(child_box), + value if value == FourCc::from_bytes(*b"colr") => preserved_colr_box = Some(child_box), + value if value == FourCc::from_bytes(*b"btrt") => {} + _ => preserved_other_boxes.push(child_box), + } + } + + let avcc_box = avcc_box.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: "h264".to_string(), + message: "carried H.264 sample entry did not contain an `avcC` decoder configuration box" + .to_string(), + })?; + let btrt_box = super::super::mp4::encode_typed_box( + &build_btrt_from_sample_sizes_with_total_duration( + samples, + timescale, + total_duration_override, + ) + .map_err(|error| match error { + MuxError::LayoutOverflow(_) => error, + _ => MuxError::LayoutOverflow("carried H.264 bitrate box"), + })?, + &[], + )?; + let pasp_box = if include_pasp { + preserved_pasp_box.or(Some(super::super::mp4::encode_typed_box( + &Pasp { + h_spacing: 1, + v_spacing: 1, + }, + &[], + )?)) + } else { + None + }; + let colr_box = match preserved_colr_box { + Some(colr_box) => Some(colr_box), + None if include_default_colr => Some(super::super::mp4::encode_typed_box( + &Colr { + colour_type: FourCc::from_bytes(*b"nclx"), + colour_primaries: 1, + transfer_characteristics: 1, + matrix_coefficients: 1, + full_range_flag: false, + reserved: 0, + profile: Vec::new(), + unknown: Vec::new(), + }, + &[], + )?), + None => None, + }; + let mut rebuilt_children = Vec::with_capacity( + 2 + usize::from(pasp_box.is_some()) + + usize::from(colr_box.is_some()) + + preserved_other_boxes.len(), + ); + rebuilt_children.push(avcc_box); + if let Some(pasp_box) = pasp_box.as_ref() { + rebuilt_children.push(pasp_box.clone()); + } + if let Some(colr_box) = colr_box.as_ref() { + rebuilt_children.push(colr_box.clone()); + } + rebuilt_children.extend(preserved_other_boxes); + rebuilt_children.push(btrt_box); + super::super::mp4::replace_visual_sample_entry_immediate_children( + sample_entry_box, + &rebuilt_children, + ) +} + +pub(super) fn authored_h264_media_duration(samples: I) -> Result +where + I: IntoIterator, +{ + let mut decode_time = 0_u64; + let mut max_presentation_end = 0_u64; + for (duration, composition_time_offset) in samples { + let presentation_end = i128::from(decode_time) + .saturating_add(i128::from(composition_time_offset)) + .saturating_add(i128::from(duration)); + if presentation_end > 0 { + max_presentation_end = max_presentation_end.max( + u64::try_from(presentation_end) + .map_err(|_| MuxError::LayoutOverflow("carried H.264 media duration"))?, + ); + } + decode_time = decode_time + .checked_add(u64::from(duration)) + .ok_or(MuxError::LayoutOverflow("carried H.264 media duration"))?; + } + Ok(max_presentation_end.max(decode_time)) +} + +const fn h264_profile_supports_config_extensions(profile: u8) -> bool { + matches!(profile, 100 | 110 | 122 | 144) +} + +struct H264SpsInfo { + seq_parameter_set_id: u32, + width: u16, + height: u16, + profile: u8, + profile_compatibility: u8, + level: u8, + high_profile_fields_enabled: bool, + separate_colour_plane_flag: bool, + frame_mbs_only_flag: bool, + log2_max_frame_num: u8, + pic_order_cnt_type: u8, + log2_max_pic_order_cnt_lsb: Option, + chroma_format: u8, + bit_depth_luma_minus8: u8, + bit_depth_chroma_minus8: u8, + timing_time_scale: Option, + timing_num_units_in_tick: Option, + vui_pic_struct_present_flag: bool, + pixel_aspect_ratio: Option, + color_info: Option, +} + +struct H264PpsInfo { + pic_parameter_set_id: u32, + seq_parameter_set_id: u32, + bottom_field_pic_order_in_frame_present_flag: bool, +} + +struct H264PixelAspectRatio { + h_spacing: u32, + v_spacing: u32, +} + +struct H264ColorInfo { + colour_primaries: u16, + transfer_characteristics: u16, + matrix_coefficients: u16, + full_range_flag: bool, +} + +type H264VuiInfo = ( + Option, + Option, + bool, + Option, + Option, +); + +#[derive(Clone, Copy)] +struct ParsedH264Poc { + poc_lsb: u32, + poc_msb: i32, + poc: i32, + field_pic_flag: bool, +} + +#[derive(Clone, Copy)] +struct ParsedH264AccessUnitInfo { + first_mb_in_slice: u64, + pic_parameter_set_id: u32, + frame_num: u16, + field_pic_flag: bool, + bottom_field_flag: bool, + nal_ref_idc: u8, + idr_pic_flag: bool, + idr_pic_id: Option, + pic_order_cnt_lsb: Option, + delta_pic_order_cnt_bottom: i32, + poc: Option, +} + +fn parse_h264_sps(nal: &[u8], spec: &str) -> Result { + if nal.len() < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.264 SPS NAL is too short".to_string(), + }); + } + let profile = nal[1]; + let rbsp = nal_to_rbsp(&nal[1..]); + let mut reader = BitReader::new(Cursor::new(rbsp)); + let profile_idc = read_bits_u8(&mut reader, 8, spec)?; + let profile_compatibility_bits = read_bits_u8(&mut reader, 8, spec)?; + let level_idc = read_bits_u8(&mut reader, 8, spec)?; + let seq_parameter_set_id = read_ue(&mut reader, spec)?; + + let mut chroma_format_idc = 1_u8; + let mut bit_depth_luma_minus8 = 0_u8; + let mut bit_depth_chroma_minus8 = 0_u8; + let mut high_profile_fields_enabled = false; + let mut separate_colour_plane_flag = false; + if matches!( + profile_idc, + 100 | 110 | 122 | 244 | 44 | 83 | 86 | 118 | 128 | 138 | 139 | 134 | 135 + ) { + high_profile_fields_enabled = true; + chroma_format_idc = u8::try_from(read_ue(&mut reader, spec)?).map_err(|_| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.264 chroma format does not fit in u8".to_string(), + } + })?; + if chroma_format_idc == 3 { + separate_colour_plane_flag = read_bit(&mut reader, spec)?; + } + bit_depth_luma_minus8 = u8::try_from(read_ue(&mut reader, spec)?).map_err(|_| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.264 luma bit depth does not fit in u8".to_string(), + } + })?; + bit_depth_chroma_minus8 = u8::try_from(read_ue(&mut reader, spec)?).map_err(|_| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.264 chroma bit depth does not fit in u8".to_string(), + } + })?; + let _qpprime_y_zero_transform_bypass_flag = read_bit(&mut reader, spec)?; + let seq_scaling_matrix_present_flag = read_bit(&mut reader, spec)?; + if seq_scaling_matrix_present_flag { + let count = if chroma_format_idc != 3 { 8 } else { 12 }; + for index in 0..count { + if read_bit(&mut reader, spec)? { + skip_scaling_list(&mut reader, if index < 6 { 16 } else { 64 }, spec)?; + } + } + } + } + + let log2_max_frame_num = u8::try_from( + read_ue(&mut reader, spec)? + .checked_add(4) + .ok_or(MuxError::LayoutOverflow("H.264 frame-num width"))?, + ) + .map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.264 frame-num width does not fit in u8".to_string(), + })?; + let pic_order_cnt_type = read_ue(&mut reader, spec)?; + let mut log2_max_pic_order_cnt_lsb = None; + if pic_order_cnt_type == 0 { + log2_max_pic_order_cnt_lsb = Some( + u8::try_from( + read_ue(&mut reader, spec)? + .checked_add(4) + .ok_or(MuxError::LayoutOverflow("H.264 POC-LSB width"))?, + ) + .map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.264 POC-LSB width does not fit in u8".to_string(), + })?, + ); + } else if pic_order_cnt_type == 1 { + let _delta_pic_order_always_zero_flag = read_bit(&mut reader, spec)?; + let _offset_for_non_ref_pic = read_se(&mut reader, spec)?; + let _offset_for_top_to_bottom_field = read_se(&mut reader, spec)?; + let cycle = read_ue(&mut reader, spec)?; + for _ in 0..cycle { + let _ = read_se(&mut reader, spec)?; + } + } + let _max_num_ref_frames = read_ue(&mut reader, spec)?; + let _gaps_in_frame_num_value_allowed_flag = read_bit(&mut reader, spec)?; + let pic_width_in_mbs_minus1 = read_ue(&mut reader, spec)?; + let pic_height_in_map_units_minus1 = read_ue(&mut reader, spec)?; + let frame_mbs_only_flag = read_bit(&mut reader, spec)?; + if !frame_mbs_only_flag { + let _mb_adaptive_frame_field_flag = read_bit(&mut reader, spec)?; + } + let _direct_8x8_inference_flag = read_bit(&mut reader, spec)?; + let frame_cropping_flag = read_bit(&mut reader, spec)?; + let ( + frame_crop_left_offset, + frame_crop_right_offset, + frame_crop_top_offset, + frame_crop_bottom_offset, + ) = if frame_cropping_flag { + ( + read_ue(&mut reader, spec)?, + read_ue(&mut reader, spec)?, + read_ue(&mut reader, spec)?, + read_ue(&mut reader, spec)?, + ) + } else { + (0, 0, 0, 0) + }; + + let vui_parameters_present_flag = read_bit(&mut reader, spec)?; + let ( + timing_num_units_in_tick, + timing_time_scale, + vui_pic_struct_present_flag, + pixel_aspect_ratio, + color_info, + ) = if vui_parameters_present_flag { + parse_vui_timing(&mut reader, spec)? + } else { + (None, None, false, None, None) + }; + + let sub_width_c = match chroma_format_idc { + 0 | 3 => 1_u32, + _ => 2_u32, + }; + let sub_height_c = match chroma_format_idc { + 0 => { + if frame_mbs_only_flag { + 1 + } else { + 2 + } + } + 1 => { + if frame_mbs_only_flag { + 2 + } else { + 4 + } + } + 2 | 3 => { + if frame_mbs_only_flag { + 1 + } else { + 2 + } + } + _ => 1, + }; + let crop_unit_x = if chroma_format_idc == 0 { + 1 + } else { + sub_width_c + }; + let crop_unit_y = if chroma_format_idc == 0 { + if frame_mbs_only_flag { 2 } else { 4 } + } else { + sub_height_c + }; + + let width = ((pic_width_in_mbs_minus1 + 1) * 16) + .saturating_sub((frame_crop_left_offset + frame_crop_right_offset) * crop_unit_x); + let height = + ((pic_height_in_map_units_minus1 + 1) * 16 * if frame_mbs_only_flag { 1 } else { 2 }) + .saturating_sub((frame_crop_top_offset + frame_crop_bottom_offset) * crop_unit_y); + + Ok(H264SpsInfo { + seq_parameter_set_id, + width: u16::try_from(width).map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.264 SPS width does not fit in u16".to_string(), + })?, + height: u16::try_from(height).map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.264 SPS height does not fit in u16".to_string(), + })?, + profile, + profile_compatibility: profile_compatibility_bits, + level: level_idc, + high_profile_fields_enabled, + separate_colour_plane_flag, + frame_mbs_only_flag, + log2_max_frame_num, + pic_order_cnt_type: u8::try_from(pic_order_cnt_type).map_err(|_| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.264 pic_order_cnt_type does not fit in u8".to_string(), + } + })?, + log2_max_pic_order_cnt_lsb, + chroma_format: chroma_format_idc, + bit_depth_luma_minus8, + bit_depth_chroma_minus8, + timing_time_scale, + timing_num_units_in_tick, + vui_pic_struct_present_flag, + pixel_aspect_ratio, + color_info, + }) +} + +fn parse_h264_pps(nal: &[u8], spec: &str) -> Result { + if nal.len() < 2 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.264 PPS NAL is too short".to_string(), + }); + } + let rbsp = nal_to_rbsp(&nal[1..]); + let mut reader = BitReader::new(Cursor::new(rbsp)); + let pic_parameter_set_id = read_ue(&mut reader, spec)?; + let seq_parameter_set_id = read_ue(&mut reader, spec)?; + let _entropy_coding_mode_flag = read_bit(&mut reader, spec)?; + let bottom_field_pic_order_in_frame_present_flag = read_bit(&mut reader, spec)?; + Ok(H264PpsInfo { + pic_parameter_set_id, + seq_parameter_set_id, + bottom_field_pic_order_in_frame_present_flag, + }) +} + +fn derive_raw_h264_sample_timing( + time_scale: u32, + num_units_in_tick: u32, + vui_pic_struct_present_flag: bool, + field_pic_flag: bool, + spec: &str, +) -> Result<(u32, u32), MuxError> { + let delta_tfi_divisor_idx = if !vui_pic_struct_present_flag { + 1_u32 + u32::from(!field_pic_flag) + } else { + 2 + }; + let doubled_time_scale = time_scale.checked_mul(2); + let (timescale, sample_duration) = if let Some(doubled_time_scale) = doubled_time_scale { + let doubled_num_units_in_tick = + num_units_in_tick + .checked_mul(2) + .ok_or(MuxError::LayoutOverflow( + "raw H.264 sample duration from SPS timing", + ))?; + ( + doubled_time_scale, + doubled_num_units_in_tick + .checked_mul(delta_tfi_divisor_idx) + .ok_or(MuxError::LayoutOverflow( + "raw H.264 sample duration from SPS timing", + ))?, + ) + } else { + ( + time_scale, + num_units_in_tick.checked_mul(delta_tfi_divisor_idx).ok_or( + MuxError::LayoutOverflow("raw H.264 sample duration from SPS timing"), + )?, + ) + }; + if timescale == 0 || sample_duration == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.264 SPS timing info produced an invalid zero cadence".to_string(), + }); + } + Ok((timescale, sample_duration)) +} + +fn default_raw_h264_sample_timing() -> (u32, u32) { + (DEFAULT_RAW_H264_TIMESCALE, DEFAULT_RAW_H264_SAMPLE_DURATION) +} + +struct H264DerivedSampleTiming { + composition_offsets: Vec, + source_edit_media_time: Option, +} + +fn derive_h264_sample_timing_from_poc( + first_vcl_nals: &[Vec], + samples: &[StagedSample], + sps_info: &H264SpsInfo, + pps_info: &H264PpsInfo, + sample_duration: u32, + initial_presentation_time: u64, + spec: &str, +) -> Option { + if first_vcl_nals.len() != samples.len() { + return None; + } + let mut pocs = Vec::with_capacity(first_vcl_nals.len()); + let mut prev_poc_lsb = 0_u32; + let mut prev_poc_msb = 0_i32; + + for nal in first_vcl_nals { + let parsed = + parse_h264_slice_poc(nal, sps_info, pps_info, prev_poc_lsb, prev_poc_msb, spec)?; + pocs.push(parsed.poc); + prev_poc_lsb = parsed.poc_lsb; + prev_poc_msb = parsed.poc_msb; + } + + let poc_step = derive_h264_poc_step(&pocs).unwrap_or(1); + let mut composition_offsets = Vec::with_capacity(samples.len()); + let initial_presentation_time = i64::try_from(initial_presentation_time).ok()?; + let sample_duration = i64::from(sample_duration); + let mut gop_start = 0usize; + while gop_start < samples.len() { + let gop_end = samples + .iter() + .enumerate() + .skip(gop_start + 1) + .find_map(|(index, sample)| sample.is_sync_sample.then_some(index)) + .unwrap_or(samples.len()); + let gop_base_poc = pocs[gop_start]; + let gop_display_ranks = + derive_h264_gop_display_ranks(&pocs[gop_start..gop_end], gop_base_poc, poc_step)?; + let gop_decode_start = i64::try_from(gop_start) + .ok()? + .checked_mul(sample_duration)?; + for (local_index, display_rank) in gop_display_ranks.into_iter().enumerate() { + let decode_time = gop_decode_start.checked_add( + i64::try_from(local_index) + .ok()? + .checked_mul(sample_duration)?, + )?; + let presentation_time = gop_decode_start + .checked_add(initial_presentation_time)? + .checked_add(i64::from(display_rank).checked_mul(sample_duration)?)?; + composition_offsets + .push(i32::try_from(presentation_time.checked_sub(decode_time)?).ok()?); + } + gop_start = gop_end; + } + Some(H264DerivedSampleTiming { + composition_offsets, + source_edit_media_time: (initial_presentation_time > 0) + .then_some(u64::try_from(initial_presentation_time).ok()?) + .filter(|value| *value > 0), + }) +} + +fn derive_h264_poc_step(pocs: &[i32]) -> Option { + pocs.windows(2) + .filter_map(|window| { + let diff = (window[1] - window[0]).abs(); + (diff > 0).then_some(diff) + }) + .min() +} + +fn derive_h264_gop_display_ranks( + gop_pocs: &[i32], + gop_base_poc: i32, + poc_step: i32, +) -> Option> { + if gop_pocs.is_empty() || poc_step <= 0 { + return Some(Vec::new()); + } + let relative_pocs = gop_pocs + .iter() + .map(|poc| poc.checked_sub(gop_base_poc)) + .collect::>>()?; + if relative_pocs + .iter() + .all(|relative_poc| *relative_poc >= 0 && *relative_poc % poc_step == 0) + { + return relative_pocs + .into_iter() + .map(|relative_poc| relative_poc.checked_div(poc_step)) + .collect(); + } + + let mut order = relative_pocs + .iter() + .copied() + .enumerate() + .collect::>(); + order.sort_by_key(|(_, poc)| *poc); + let mut display_ranks = vec![0_i32; gop_pocs.len()]; + for (rank, (index, _)) in order.into_iter().enumerate() { + display_ranks[index] = i32::try_from(rank).ok()?; + } + Some(display_ranks) +} + +fn parse_h264_slice_poc( + nal: &[u8], + sps_info: &H264SpsInfo, + pps_info: &H264PpsInfo, + prev_poc_lsb: u32, + prev_poc_msb: i32, + spec: &str, +) -> Option { + if nal.len() < 2 + || pps_info.seq_parameter_set_id != sps_info.seq_parameter_set_id + || sps_info.pic_order_cnt_type != 0 + { + return None; + } + let nal_type = nal[0] & 0x1F; + if !is_h264_vcl_nal_type(nal_type) { + return None; + } + + let rbsp = nal_to_rbsp(&nal[1..]); + let mut reader = BitReader::new(Cursor::new(rbsp)); + let _first_mb_in_slice = read_ue(&mut reader, spec).ok()?; + let _slice_type = read_ue(&mut reader, spec).ok()?; + let pic_parameter_set_id = read_ue(&mut reader, spec).ok()?; + if pic_parameter_set_id != pps_info.pic_parameter_set_id { + return None; + } + let _frame_num = + read_bits_u16(&mut reader, usize::from(sps_info.log2_max_frame_num), spec).ok()?; + if sps_info.separate_colour_plane_flag { + let _colour_plane_id = read_bits_u8(&mut reader, 2, spec).ok()?; + } + let mut field_pic_flag = false; + let mut bottom_field_flag = false; + if !sps_info.frame_mbs_only_flag { + field_pic_flag = read_bit(&mut reader, spec).ok()?; + if field_pic_flag { + bottom_field_flag = read_bit(&mut reader, spec).ok()?; + } + } + if nal_type == 5 { + let _idr_pic_id = read_ue(&mut reader, spec).ok()?; + } + let poc_lsb = read_bits_u32( + &mut reader, + usize::from(sps_info.log2_max_pic_order_cnt_lsb?), + spec, + ) + .ok()?; + let delta_pic_order_cnt_bottom = + if pps_info.bottom_field_pic_order_in_frame_present_flag && !field_pic_flag { + read_se(&mut reader, spec).ok()? + } else { + 0 + }; + + let max_poc_lsb = 1_u32.checked_shl(u32::from(sps_info.log2_max_pic_order_cnt_lsb?))?; + let poc_msb = if nal_type == 5 { + 0 + } else if poc_lsb < prev_poc_lsb && prev_poc_lsb - poc_lsb >= max_poc_lsb / 2 { + prev_poc_msb.checked_add(i32::try_from(max_poc_lsb).ok()?)? + } else if poc_lsb > prev_poc_lsb && poc_lsb - prev_poc_lsb > max_poc_lsb / 2 { + prev_poc_msb.checked_sub(i32::try_from(max_poc_lsb).ok()?)? + } else { + prev_poc_msb + }; + let top_field_order_cnt = poc_msb.checked_add(i32::try_from(poc_lsb).ok()?)?; + let bottom_field_order_cnt = top_field_order_cnt.checked_add(delta_pic_order_cnt_bottom)?; + let poc = if field_pic_flag { + if bottom_field_flag { + bottom_field_order_cnt + } else { + top_field_order_cnt + } + } else { + top_field_order_cnt.min(bottom_field_order_cnt) + }; + + Some(ParsedH264Poc { + poc_lsb, + poc_msb, + poc, + field_pic_flag, + }) +} + +fn parse_h264_access_unit_info( + nal: &[u8], + sps_info: &H264SpsInfo, + pps_info: &H264PpsInfo, + prev_poc_lsb: u32, + prev_poc_msb: i32, + spec: &str, +) -> Option { + if nal.len() < 2 || pps_info.seq_parameter_set_id != sps_info.seq_parameter_set_id { + return None; + } + let nal_type = nal[0] & 0x1F; + if !is_h264_vcl_nal_type(nal_type) { + return None; + } + + let rbsp = nal_to_rbsp(&nal[1..]); + let mut reader = BitReader::new(Cursor::new(rbsp)); + let first_mb_in_slice = read_ue(&mut reader, spec).ok()?; + let _slice_type = read_ue(&mut reader, spec).ok()?; + let pic_parameter_set_id = read_ue(&mut reader, spec).ok()?; + if pic_parameter_set_id != pps_info.pic_parameter_set_id { + return None; + } + let frame_num = + read_bits_u16(&mut reader, usize::from(sps_info.log2_max_frame_num), spec).ok()?; + if sps_info.separate_colour_plane_flag { + let _colour_plane_id = read_bits_u8(&mut reader, 2, spec).ok()?; + } + let nal_ref_idc = (nal[0] >> 5) & 0x3; + let mut field_pic_flag = false; + let mut bottom_field_flag = false; + if !sps_info.frame_mbs_only_flag { + field_pic_flag = read_bit(&mut reader, spec).ok()?; + if field_pic_flag { + bottom_field_flag = read_bit(&mut reader, spec).ok()?; + } + } + let idr_pic_flag = nal_type == 5; + let idr_pic_id = if idr_pic_flag { + Some(read_ue(&mut reader, spec).ok()?) + } else { + None + }; + + let mut pic_order_cnt_lsb = None; + let mut delta_pic_order_cnt_bottom = 0; + let mut poc = None; + if sps_info.pic_order_cnt_type == 0 { + let parsed_poc = + parse_h264_slice_poc(nal, sps_info, pps_info, prev_poc_lsb, prev_poc_msb, spec)?; + pic_order_cnt_lsb = Some(parsed_poc.poc_lsb); + if pps_info.bottom_field_pic_order_in_frame_present_flag && !field_pic_flag { + let rbsp = nal_to_rbsp(&nal[1..]); + let mut reader = BitReader::new(Cursor::new(rbsp)); + let _first_mb_in_slice = read_ue(&mut reader, spec).ok()?; + let _slice_type = read_ue(&mut reader, spec).ok()?; + let _pic_parameter_set_id = read_ue(&mut reader, spec).ok()?; + let _frame_num = + read_bits_u16(&mut reader, usize::from(sps_info.log2_max_frame_num), spec).ok()?; + if sps_info.separate_colour_plane_flag { + let _colour_plane_id = read_bits_u8(&mut reader, 2, spec).ok()?; + } + if !sps_info.frame_mbs_only_flag { + let field_pic_flag_value = read_bit(&mut reader, spec).ok()?; + if field_pic_flag_value { + let _bottom_field_flag = read_bit(&mut reader, spec).ok()?; + } + } + if idr_pic_flag { + let _idr_pic_id = read_ue(&mut reader, spec).ok()?; + } + let _pic_order_cnt_lsb = read_bits_u32( + &mut reader, + usize::from(sps_info.log2_max_pic_order_cnt_lsb?), + spec, + ) + .ok()?; + delta_pic_order_cnt_bottom = read_se(&mut reader, spec).ok()?; + } + poc = Some(parsed_poc); + } + + Some(ParsedH264AccessUnitInfo { + first_mb_in_slice: u64::from(first_mb_in_slice), + pic_parameter_set_id, + frame_num, + field_pic_flag, + bottom_field_flag, + nal_ref_idc, + idr_pic_flag, + idr_pic_id, + pic_order_cnt_lsb, + delta_pic_order_cnt_bottom, + poc, + }) +} + +fn h264_starts_new_access_unit( + current: &ParsedH264AccessUnitInfo, + next: &ParsedH264AccessUnitInfo, +) -> bool { + if next.first_mb_in_slice != 0 { + return false; + } + current.frame_num != next.frame_num + || current.pic_parameter_set_id != next.pic_parameter_set_id + || current.field_pic_flag != next.field_pic_flag + || (current.field_pic_flag + && next.field_pic_flag + && current.bottom_field_flag != next.bottom_field_flag) + || ((current.nal_ref_idc == 0) != (next.nal_ref_idc == 0)) + || current.idr_pic_flag != next.idr_pic_flag + || (current.idr_pic_flag && current.idr_pic_id != next.idr_pic_id) + || (!current.field_pic_flag + && !next.field_pic_flag + && (current.pic_order_cnt_lsb != next.pic_order_cnt_lsb + || current.delta_pic_order_cnt_bottom != next.delta_pic_order_cnt_bottom)) +} + +fn display_track_width(width: u16, pixel_aspect_ratio: Option<&H264PixelAspectRatio>) -> u16 { + let Some(pixel_aspect_ratio) = pixel_aspect_ratio else { + return width; + }; + let numerator = u64::from(width) + .saturating_mul(u64::from(pixel_aspect_ratio.h_spacing)) + .saturating_add(u64::from(pixel_aspect_ratio.v_spacing / 2)); + let display_width = numerator / u64::from(pixel_aspect_ratio.v_spacing); + u16::try_from(display_width).unwrap_or(width) +} + +fn parse_vui_timing(reader: &mut BitReader, spec: &str) -> Result +where + R: Read, +{ + let mut pixel_aspect_ratio = None; + if read_bit(reader, spec)? { + let aspect_ratio_idc = read_bits_u8(reader, 8, spec)?; + if aspect_ratio_idc == 255 { + let sar_width = read_bits_u16(reader, 16, spec)?; + let sar_height = read_bits_u16(reader, 16, spec)?; + if sar_width != 0 && sar_height != 0 && sar_width != sar_height { + pixel_aspect_ratio = Some(H264PixelAspectRatio { + h_spacing: u32::from(sar_width), + v_spacing: u32::from(sar_height), + }); + } + } else { + pixel_aspect_ratio = h264_pixel_aspect_ratio_from_idc(aspect_ratio_idc); + } + } + if read_bit(reader, spec)? { + let _overscan_appropriate_flag = read_bit(reader, spec)?; + } + let mut color_info = None; + if read_bit(reader, spec)? { + let _video_format = read_bits_u8(reader, 3, spec)?; + let video_full_range_flag = read_bit(reader, spec)?; + if read_bit(reader, spec)? { + color_info = Some(H264ColorInfo { + colour_primaries: u16::from(read_bits_u8(reader, 8, spec)?), + transfer_characteristics: u16::from(read_bits_u8(reader, 8, spec)?), + matrix_coefficients: u16::from(read_bits_u8(reader, 8, spec)?), + full_range_flag: video_full_range_flag, + }); + } + } + if read_bit(reader, spec)? { + let _chroma_sample_loc_type_top_field = read_ue(reader, spec)?; + let _chroma_sample_loc_type_bottom_field = read_ue(reader, spec)?; + } + let timing_info_present_flag = read_bit(reader, spec)?; + let mut num_units_in_tick = None; + let mut time_scale = None; + if timing_info_present_flag { + num_units_in_tick = Some(read_bits_u32(reader, 32, spec)?); + time_scale = Some(read_bits_u32(reader, 32, spec)?); + let _fixed_frame_rate_flag = read_bit(reader, spec)?; + } + let nal_hrd_parameters_present_flag = read_bit(reader, spec)?; + if nal_hrd_parameters_present_flag { + skip_hrd_parameters(reader, spec)?; + } + let vcl_hrd_parameters_present_flag = read_bit(reader, spec)?; + if vcl_hrd_parameters_present_flag { + skip_hrd_parameters(reader, spec)?; + } + if nal_hrd_parameters_present_flag || vcl_hrd_parameters_present_flag { + let _low_delay_hrd_flag = read_bit(reader, spec)?; + } + let pic_struct_present_flag = read_bit(reader, spec)?; + if read_bit(reader, spec)? { + skip_bitstream_restrictions(reader, spec)?; + } + Ok(( + num_units_in_tick, + time_scale, + pic_struct_present_flag, + pixel_aspect_ratio, + color_info, + )) +} + +fn skip_hrd_parameters(reader: &mut BitReader, spec: &str) -> Result<(), MuxError> +where + R: Read, +{ + let cpb_cnt_minus1 = read_ue(reader, spec)?; + let _bit_rate_scale = read_bits_u8(reader, 4, spec)?; + let _cpb_size_scale = read_bits_u8(reader, 4, spec)?; + for _ in 0..=cpb_cnt_minus1 { + let _bit_rate_value_minus1 = read_ue(reader, spec)?; + let _cpb_size_value_minus1 = read_ue(reader, spec)?; + let _cbr_flag = read_bit(reader, spec)?; + } + let _initial_cpb_removal_delay_length_minus1 = read_bits_u8(reader, 5, spec)?; + let _cpb_removal_delay_length_minus1 = read_bits_u8(reader, 5, spec)?; + let _dpb_output_delay_length_minus1 = read_bits_u8(reader, 5, spec)?; + let _time_offset_length = read_bits_u8(reader, 5, spec)?; + Ok(()) +} + +fn skip_bitstream_restrictions(reader: &mut BitReader, spec: &str) -> Result<(), MuxError> +where + R: Read, +{ + let _motion_vectors_over_pic_boundaries_flag = read_bit(reader, spec)?; + let _max_bytes_per_pic_denom = read_ue(reader, spec)?; + let _max_bits_per_mb_denom = read_ue(reader, spec)?; + let _log2_max_mv_length_horizontal = read_ue(reader, spec)?; + let _log2_max_mv_length_vertical = read_ue(reader, spec)?; + let _max_num_reorder_frames = read_ue(reader, spec)?; + let _max_dec_frame_buffering = read_ue(reader, spec)?; + Ok(()) +} + +fn h264_pixel_aspect_ratio_from_idc(aspect_ratio_idc: u8) -> Option { + let (h_spacing, v_spacing) = match aspect_ratio_idc { + 1 => (1, 1), + 2 => (12, 11), + 3 => (10, 11), + 4 => (16, 11), + 5 => (40, 33), + 6 => (24, 11), + 7 => (20, 11), + 8 => (32, 11), + 9 => (80, 33), + 10 => (18, 11), + 11 => (15, 11), + 12 => (64, 33), + 13 => (160, 99), + 14 => (4, 3), + 15 => (3, 2), + 16 => (2, 1), + _ => return None, + }; + (h_spacing != v_spacing).then_some(H264PixelAspectRatio { + h_spacing, + v_spacing, + }) +} + +fn skip_scaling_list(reader: &mut BitReader, size: usize, spec: &str) -> Result<(), MuxError> +where + R: Read, +{ + let mut last_scale = 8_i32; + let mut next_scale = 8_i32; + for _ in 0..size { + if next_scale != 0 { + let delta_scale = read_se(reader, spec)?; + next_scale = (last_scale + delta_scale + 256) % 256; + } + last_scale = if next_scale == 0 { + last_scale + } else { + next_scale + }; + } + Ok(()) +} + +fn read_bit(reader: &mut BitReader, spec: &str) -> Result +where + R: Read, +{ + read_bit_labeled(reader, spec, "H.264") +} + +fn read_bits_u8(reader: &mut BitReader, width: usize, spec: &str) -> Result +where + R: Read, +{ + read_bits_u8_labeled(reader, width, spec, "H.264") +} + +fn read_bits_u16(reader: &mut BitReader, width: usize, spec: &str) -> Result +where + R: Read, +{ + read_bits_u16_labeled(reader, width, spec, "H.264") +} + +fn read_bits_u32(reader: &mut BitReader, width: usize, spec: &str) -> Result +where + R: Read, +{ + read_bits_u32_labeled(reader, width, spec, "H.264") +} + +fn read_ue(reader: &mut BitReader, spec: &str) -> Result +where + R: Read, +{ + read_ue_labeled(reader, spec, "H.264") +} + +fn read_se(reader: &mut BitReader, spec: &str) -> Result +where + R: Read, +{ + read_se_labeled(reader, spec, "H.264") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mux::mp4::visual_sample_entry_immediate_children; + + fn decode_hex(bytes: &str) -> Vec { + assert_eq!(bytes.len() % 2, 0); + bytes + .as_bytes() + .chunks_exact(2) + .map(|chunk| { + let text = std::str::from_utf8(chunk).unwrap(); + u8::from_str_radix(text, 16).unwrap() + }) + .collect() + } + + #[test] + fn build_h264_sample_entry_from_avc_config_includes_default_colr_when_requested() { + let avcc = AVCDecoderConfiguration { + configuration_version: 1, + profile: 100, + profile_compatibility: 0, + level: 13, + length_size_minus_one: 3, + num_of_sequence_parameter_sets: 1, + sequence_parameter_sets: vec![AVCParameterSet { + length: 24, + nal_unit: decode_hex("6764000DAC34E505067E7840000019000005DAA3C50A4580"), + }], + num_of_picture_parameter_sets: 1, + picture_parameter_sets: vec![AVCParameterSet { + length: 5, + nal_unit: decode_hex("68EEB2C8B0"), + }], + high_profile_fields_enabled: true, + chroma_format: 1, + bit_depth_luma_minus8: 0, + bit_depth_chroma_minus8: 0, + num_of_sequence_parameter_set_ext: 0, + sequence_parameter_sets_ext: Vec::new(), + }; + + let (sample_entry_box, _, _) = + build_h264_sample_entry_from_avc_config_with_options(&avcc, "test", true).unwrap(); + let child_boxes = visual_sample_entry_immediate_children(&sample_entry_box).unwrap(); + assert!( + child_boxes + .iter() + .any(|child_box| child_box.get(4..8) == Some(&b"colr"[..])) + ); + } + + #[test] + fn default_raw_h264_sample_timing_uses_25_fps_cadence() { + assert_eq!(default_raw_h264_sample_timing(), (25_000, 1_000)); + } +} diff --git a/src/mux/demux/h265.rs b/src/mux/demux/h265.rs new file mode 100644 index 0000000..af9b4fb --- /dev/null +++ b/src/mux/demux/h265.rs @@ -0,0 +1,1338 @@ +use std::fs::File; +use std::io::{Cursor, Read}; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::AsyncReadExt; + +use crate::FourCc; +use crate::bitio::BitReader; +use crate::boxes::AnyTypeBox; +use crate::boxes::iso14496_12::{ + Btrt, Colr, HEVCDecoderConfiguration, HEVCNalu, HEVCNaluArray, Pasp, SampleEntry, + VisualSampleEntry, +}; + +use super::super::MuxError; +use super::super::import::{ + SegmentedMuxSourceSegment, SegmentedMuxSourceSegmentData, SegmentedMuxSourceSpec, StagedSample, +}; +use super::annexb_common::{ + AnnexBNal, AnnexBNalScanner, IndexedAnnexBTrack, nal_to_rbsp, push_unique_nal, + read_bit_labeled, read_bits_u8_labeled, read_bits_u16_labeled, read_bits_u32_labeled, + read_se_labeled, read_ue_labeled, skip_bits_labeled, +}; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::read_segmented_bytes_sync; + +const DVH1: FourCc = FourCc::from_bytes(*b"dvh1"); + +pub(in crate::mux) fn stage_annex_b_h265_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let mut scanner = AnnexBNalScanner::default(); + let mut state = H265StageState::new(); + let mut chunk = [0_u8; 16 * 1024]; + + loop { + let read = file.read(&mut chunk)?; + if read == 0 { + break; + } + scanner.push(&chunk[..read], |nal| stage_h265_nal(&mut state, nal))?; + } + scanner.finish(|nal| stage_h265_nal(&mut state, nal))?; + finalize_h265_staged_track(path, state, spec) +} + +pub(in crate::mux) fn stage_annex_b_h265_segmented_sync( + path: &Path, + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut scanner = AnnexBNalScanner::default(); + let mut state = H265StageState::new(); + let mut offset = 0_u64; + + while offset < total_size { + let read_len = usize::try_from((total_size - offset).min(16 * 1024)) + .map_err(|_| MuxError::LayoutOverflow("segmented H.265 scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut chunk, + spec, + "segmented H.265 scan chunk is truncated", + )?; + for nal in scanner.collect(&chunk) { + stage_h265_nal_segmented(&mut state, nal)?; + } + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("segmented H.265 scan offset"))?; + } + for nal in scanner.finish_collect() { + stage_h265_nal_segmented(&mut state, nal)?; + } + finalize_h265_staged_track(path, state, spec) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn stage_annex_b_h265_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let mut scanner = AnnexBNalScanner::default(); + let mut state = H265StageState::new(); + let mut chunk = [0_u8; 16 * 1024]; + + loop { + let read = file.read(&mut chunk).await?; + if read == 0 { + break; + } + for nal in scanner.collect(&chunk[..read]) { + stage_h265_nal(&mut state, nal)?; + } + } + for nal in scanner.finish_collect() { + stage_h265_nal(&mut state, nal)?; + } + finalize_h265_staged_track(path, state, spec) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn stage_annex_b_h265_segmented_async( + path: &Path, + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut scanner = AnnexBNalScanner::default(); + let mut state = H265StageState::new(); + let mut offset = 0_u64; + + while offset < total_size { + let read_len = usize::try_from((total_size - offset).min(16 * 1024)) + .map_err(|_| MuxError::LayoutOverflow("segmented H.265 scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut chunk, + spec, + "segmented H.265 scan chunk is truncated", + ) + .await?; + for nal in scanner.collect(&chunk) { + stage_h265_nal_segmented(&mut state, nal)?; + } + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("segmented H.265 scan offset"))?; + } + for nal in scanner.finish_collect() { + stage_h265_nal_segmented(&mut state, nal)?; + } + finalize_h265_staged_track(path, state, spec) +} + +struct H265StageState { + vps_list: Vec>, + sps_list: Vec>, + pps_list: Vec>, + samples: Vec, + sample_first_vcl_nals: Vec>, + segments: Vec, + current_sample_offset: Option, + current_sample_first_vcl_nal: Option>, + current_sample_size: u32, + current_sync: bool, + current_has_vcl: bool, + saw_dolby_vision_nal: bool, + logical_size: u64, +} + +impl H265StageState { + fn new() -> Self { + Self { + vps_list: Vec::new(), + sps_list: Vec::new(), + pps_list: Vec::new(), + samples: Vec::new(), + sample_first_vcl_nals: Vec::new(), + segments: Vec::new(), + current_sample_offset: None, + current_sample_first_vcl_nal: None, + current_sample_size: 0, + current_sync: false, + current_has_vcl: false, + saw_dolby_vision_nal: false, + logical_size: 0, + } + } + + fn finish_current_sample(&mut self) { + if let Some(data_offset) = self.current_sample_offset.take() { + self.samples.push(StagedSample { + data_offset, + data_size: self.current_sample_size, + duration: 0, + composition_time_offset: 0, + is_sync_sample: self.current_sync, + }); + self.sample_first_vcl_nals + .push(self.current_sample_first_vcl_nal.take().unwrap_or_default()); + self.current_sample_size = 0; + self.current_sync = false; + self.current_has_vcl = false; + } + } + + fn append_sample_nal( + &mut self, + source_offset: u64, + source_size: u32, + is_sync_sample: bool, + is_vcl: bool, + ) -> Result<(), MuxError> { + if self.current_sample_offset.is_none() { + self.current_sample_offset = Some(self.logical_size); + } + let prefix = source_size.to_be_bytes(); + self.segments.push(SegmentedMuxSourceSegment { + logical_offset: self.logical_size, + data: SegmentedMuxSourceSegmentData::Prefix(prefix), + }); + self.logical_size = self + .logical_size + .checked_add(4) + .ok_or(MuxError::LayoutOverflow("raw H.265 transformed payload"))?; + self.segments.push(SegmentedMuxSourceSegment { + logical_offset: self.logical_size, + data: SegmentedMuxSourceSegmentData::FileRange { + source_offset, + size: source_size, + }, + }); + self.current_sample_size = self + .current_sample_size + .checked_add( + 4_u32 + .checked_add(source_size) + .ok_or(MuxError::LayoutOverflow( + "raw H.265 transformed sample size", + ))?, + ) + .ok_or(MuxError::LayoutOverflow("raw H.265 staged sample size"))?; + self.logical_size = self + .logical_size + .checked_add(u64::from(source_size)) + .ok_or(MuxError::LayoutOverflow("raw H.265 transformed payload"))?; + self.current_sync |= is_sync_sample; + self.current_has_vcl |= is_vcl; + Ok(()) + } + + fn append_sample_bytes( + &mut self, + bytes: Vec, + is_sync_sample: bool, + is_vcl: bool, + ) -> Result<(), MuxError> { + let source_size = u32::try_from(bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("segmented H.265 NAL length"))?; + if self.current_sample_offset.is_none() { + self.current_sample_offset = Some(self.logical_size); + } + let prefix = source_size.to_be_bytes(); + self.segments.push(SegmentedMuxSourceSegment { + logical_offset: self.logical_size, + data: SegmentedMuxSourceSegmentData::Prefix(prefix), + }); + self.logical_size = self + .logical_size + .checked_add(4) + .ok_or(MuxError::LayoutOverflow( + "segmented H.265 transformed payload", + ))?; + self.segments.push(SegmentedMuxSourceSegment { + logical_offset: self.logical_size, + data: SegmentedMuxSourceSegmentData::Bytes(bytes), + }); + self.current_sample_size = self + .current_sample_size + .checked_add( + 4_u32 + .checked_add(source_size) + .ok_or(MuxError::LayoutOverflow( + "segmented H.265 transformed sample size", + ))?, + ) + .ok_or(MuxError::LayoutOverflow( + "segmented H.265 staged sample size", + ))?; + self.logical_size = self + .logical_size + .checked_add(u64::from(source_size)) + .ok_or(MuxError::LayoutOverflow( + "segmented H.265 transformed payload", + ))?; + self.current_sync |= is_sync_sample; + self.current_has_vcl |= is_vcl; + Ok(()) + } +} + +fn stage_h265_nal(state: &mut H265StageState, nal: AnnexBNal) -> Result<(), MuxError> { + if nal.bytes.len() < 2 { + return Err(MuxError::UnsupportedTrackImport { + spec: "h265".to_string(), + message: "H.265 NAL units must be at least two bytes long".to_string(), + }); + } + let nal_type = hevc_nal_type(&nal.bytes); + let first_slice_segment = if is_hevc_vcl_nal_type(nal_type) { + Some(h265_first_slice_segment_in_pic(&nal.bytes, "h265")?) + } else { + None + }; + match nal_type { + 32 => push_unique_nal(&mut state.vps_list, nal.bytes), + 33 => push_unique_nal(&mut state.sps_list, nal.bytes), + 34 => push_unique_nal(&mut state.pps_list, nal.bytes), + 35 => state.finish_current_sample(), + 62 | 63 => { + state.saw_dolby_vision_nal = true; + let is_vcl = is_hevc_vcl_nal_type(nal_type); + if first_slice_segment == Some(true) && state.current_has_vcl { + state.finish_current_sample(); + } + if is_vcl && state.current_sample_first_vcl_nal.is_none() { + state.current_sample_first_vcl_nal = Some(nal.bytes.clone()); + } + let nal_len = u32::try_from(nal.bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("H.265 NAL length"))?; + state.append_sample_nal( + nal.source_offset, + nal_len, + is_hevc_sync_nal_type(nal_type), + is_vcl, + )?; + } + _ => { + let is_vcl = is_hevc_vcl_nal_type(nal_type); + if first_slice_segment == Some(true) && state.current_has_vcl { + state.finish_current_sample(); + } + if is_vcl && state.current_sample_first_vcl_nal.is_none() { + state.current_sample_first_vcl_nal = Some(nal.bytes.clone()); + } + let nal_len = u32::try_from(nal.bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("H.265 NAL length"))?; + state.append_sample_nal( + nal.source_offset, + nal_len, + is_hevc_sync_nal_type(nal_type), + is_vcl, + )?; + } + } + Ok(()) +} + +fn stage_h265_nal_segmented(state: &mut H265StageState, nal: AnnexBNal) -> Result<(), MuxError> { + if nal.bytes.len() < 2 { + return Err(MuxError::UnsupportedTrackImport { + spec: "h265".to_string(), + message: "H.265 NAL units must be at least two bytes long".to_string(), + }); + } + let nal_type = hevc_nal_type(&nal.bytes); + let first_slice_segment = if is_hevc_vcl_nal_type(nal_type) { + Some(h265_first_slice_segment_in_pic(&nal.bytes, "h265")?) + } else { + None + }; + match nal_type { + 32 => push_unique_nal(&mut state.vps_list, nal.bytes), + 33 => push_unique_nal(&mut state.sps_list, nal.bytes), + 34 => push_unique_nal(&mut state.pps_list, nal.bytes), + 35 => state.finish_current_sample(), + 62 | 63 => { + state.saw_dolby_vision_nal = true; + let is_vcl = is_hevc_vcl_nal_type(nal_type); + if first_slice_segment == Some(true) && state.current_has_vcl { + state.finish_current_sample(); + } + if is_vcl && state.current_sample_first_vcl_nal.is_none() { + state.current_sample_first_vcl_nal = Some(nal.bytes.clone()); + } + state.append_sample_bytes(nal.bytes, is_hevc_sync_nal_type(nal_type), is_vcl)?; + } + _ => { + let is_vcl = is_hevc_vcl_nal_type(nal_type); + if first_slice_segment == Some(true) && state.current_has_vcl { + state.finish_current_sample(); + } + if is_vcl && state.current_sample_first_vcl_nal.is_none() { + state.current_sample_first_vcl_nal = Some(nal.bytes.clone()); + } + state.append_sample_bytes(nal.bytes, is_hevc_sync_nal_type(nal_type), is_vcl)?; + } + } + Ok(()) +} + +fn finalize_h265_staged_track( + path: &Path, + mut state: H265StageState, + spec: &str, +) -> Result { + state.finish_current_sample(); + if state.vps_list.is_empty() || state.sps_list.is_empty() || state.pps_list.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.265 input must include VPS, SPS, and PPS NAL units".to_string(), + }); + } + if state.samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.265 input contained parameter sets but no media samples".to_string(), + }); + } + + let sps_info = parse_h265_sps_configuration(&state.sps_list[0], spec)?; + let pps_info = parse_h265_pps_configuration(&state.pps_list[0], spec)?; + let width = sps_info.width; + let height = sps_info.height; + let (timescale, sample_duration) = if let (Some(time_scale), Some(num_units_in_tick)) = ( + sps_info.timing_time_scale, + sps_info.timing_num_units_in_tick, + ) { + ( + time_scale, + num_units_in_tick + .checked_mul(sps_info.timing_ticks_per_picture.unwrap_or(1)) + .ok_or(MuxError::LayoutOverflow( + "raw H.265 sample duration from SPS timing", + ))?, + ) + } else if state.samples.len() == 1 { + (1, 1) + } else { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "multi-sample H.265 inputs currently require timing info in SPS VUI parameters" + .to_string(), + }); + }; + for sample in &mut state.samples { + sample.duration = sample_duration; + } + let sample_pocs = parse_h265_sample_pocs( + &state.sample_first_vcl_nals, + &sps_info, + &pps_info, + sample_duration, + spec, + ); + let mut source_edit_media_time = None; + if let Some(sample_pocs) = sample_pocs { + let mut min_composition_offset = i32::MAX; + for (index, sample) in state.samples.iter_mut().enumerate() { + let decode_time = i64::from(sample_duration) + .checked_mul( + i64::try_from(index) + .map_err(|_| MuxError::LayoutOverflow("raw H.265 decode-time index"))?, + ) + .ok_or(MuxError::LayoutOverflow("raw H.265 decode time"))?; + let presentation_time = i64::from(sample_pocs[index]); + let composition_offset = presentation_time + .checked_sub(decode_time) + .ok_or(MuxError::LayoutOverflow("raw H.265 composition offset"))?; + sample.composition_time_offset = i32::try_from(composition_offset) + .map_err(|_| MuxError::LayoutOverflow("raw H.265 composition offset"))?; + min_composition_offset = min_composition_offset.min(sample.composition_time_offset); + } + if min_composition_offset < 0 { + let shift = min_composition_offset + .checked_neg() + .ok_or(MuxError::LayoutOverflow( + "raw H.265 composition-offset shift", + ))?; + for sample in &mut state.samples { + sample.composition_time_offset = + sample.composition_time_offset.checked_add(shift).ok_or( + MuxError::LayoutOverflow("raw H.265 shifted composition offset"), + )?; + } + } + let mut min_presentation_time = i64::MAX; + for (index, sample) in state.samples.iter().enumerate() { + let decode_time = i64::from(sample_duration) + .checked_mul( + i64::try_from(index) + .map_err(|_| MuxError::LayoutOverflow("raw H.265 decode-time index"))?, + ) + .ok_or(MuxError::LayoutOverflow("raw H.265 decode time"))?; + let presentation_time = decode_time + .checked_add(i64::from(sample.composition_time_offset)) + .ok_or(MuxError::LayoutOverflow( + "raw H.265 presentation time after composition-offset shift", + ))?; + min_presentation_time = min_presentation_time.min(presentation_time); + } + if min_presentation_time > 0 { + source_edit_media_time = Some( + u64::try_from(min_presentation_time) + .map_err(|_| MuxError::LayoutOverflow("raw H.265 edit media time"))?, + ); + } + } + let media_duration = staged_media_duration(&state.samples) + .ok_or(MuxError::LayoutOverflow("raw H.265 media duration"))?; + let sample_entry_type = if state.saw_dolby_vision_nal { + DVH1 + } else { + FourCc::from_bytes(*b"hvc1") + }; + let display_width = display_track_width(width, sps_info.pixel_aspect_ratio.as_ref()); + let sample_entry_box = build_h265_sample_entry_box(H265SampleEntryInputs { + sample_entry_type, + width, + height, + sps_info: &sps_info, + samples: &state.samples, + media_duration, + timescale, + vps_list: &state.vps_list, + sps_list: &state.sps_list, + pps_list: &state.pps_list, + })?; + + Ok(IndexedAnnexBTrack { + segmented_source: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: state.segments, + total_size: state.logical_size, + }, + track_width: display_width, + track_height: height, + timescale, + sample_entry_box, + source_edit_media_time, + samples: state.samples, + }) +} + +struct H265SampleEntryInputs<'a> { + sample_entry_type: FourCc, + width: u16, + height: u16, + sps_info: &'a H265SpsInfo, + samples: &'a [StagedSample], + media_duration: u64, + timescale: u32, + vps_list: &'a [Vec], + sps_list: &'a [Vec], + pps_list: &'a [Vec], +} + +fn build_h265_sample_entry_box(inputs: H265SampleEntryInputs<'_>) -> Result, MuxError> { + let H265SampleEntryInputs { + sample_entry_type, + width, + height, + sps_info, + samples, + media_duration, + timescale, + vps_list, + sps_list, + pps_list, + } = inputs; + let mut sample_entry = VisualSampleEntry::default(); + sample_entry.set_box_type(sample_entry_type); + sample_entry.sample_entry = SampleEntry { + box_type: sample_entry_type, + data_reference_index: 1, + }; + sample_entry.width = width; + sample_entry.height = height; + sample_entry.horizresolution = 72_u32 << 16; + sample_entry.vertresolution = 72_u32 << 16; + sample_entry.frame_count = 1; + sample_entry.depth = 0x0018; + sample_entry.pre_defined3 = -1; + + let nalu_arrays = [(&vps_list, 32_u8), (&sps_list, 33_u8), (&pps_list, 34_u8)] + .into_iter() + .map(|(group, nalu_type)| -> Result { + Ok(HEVCNaluArray { + completeness: true, + reserved: false, + nalu_type, + num_nalus: u16::try_from(group.len()) + .map_err(|_| MuxError::LayoutOverflow("HEVC NAL count"))?, + nalus: group + .iter() + .map(|nal| -> Result { + Ok(HEVCNalu { + length: u16::try_from(nal.len()) + .map_err(|_| MuxError::LayoutOverflow("HEVC NAL length"))?, + nal_unit: nal.clone(), + }) + }) + .collect::, _>>()?, + }) + }) + .collect::, _>>()?; + + let mut child_boxes = vec![super::super::mp4::encode_typed_box( + &HEVCDecoderConfiguration { + configuration_version: 1, + general_profile_space: sps_info.general_profile_space, + general_tier_flag: sps_info.general_tier_flag, + general_profile_idc: sps_info.general_profile_idc, + general_profile_compatibility: sps_info.general_profile_compatibility, + general_constraint_indicator: sps_info.general_constraint_indicator, + general_level_idc: sps_info.general_level_idc, + min_spatial_segmentation_idc: 0, + parallelism_type: 3, + chroma_format_idc: sps_info.chroma_format_idc, + bit_depth_luma_minus8: sps_info.bit_depth_luma_minus8, + bit_depth_chroma_minus8: sps_info.bit_depth_chroma_minus8, + avg_frame_rate: 0, + constant_frame_rate: 0, + num_temporal_layers: sps_info.num_temporal_layers, + temporal_id_nested: sps_info.temporal_id_nested, + length_size_minus_one: 3, + num_of_nalu_arrays: u8::try_from(nalu_arrays.len()) + .map_err(|_| MuxError::LayoutOverflow("HEVC NAL array count"))?, + nalu_arrays, + }, + &[], + )?]; + let pixel_aspect_ratio = sps_info.pixel_aspect_ratio.as_ref().map_or( + Pasp { + h_spacing: 1, + v_spacing: 1, + }, + |pixel_aspect_ratio| Pasp { + h_spacing: pixel_aspect_ratio.h_spacing, + v_spacing: pixel_aspect_ratio.v_spacing, + }, + ); + child_boxes.push(super::super::mp4::encode_typed_box( + &pixel_aspect_ratio, + &[], + )?); + if let Some(color_info) = sps_info.color_info.as_ref() { + child_boxes.push(super::super::mp4::encode_typed_box( + &Colr { + colour_type: FourCc::from_bytes(*b"nclx"), + colour_primaries: color_info.colour_primaries, + transfer_characteristics: color_info.transfer_characteristics, + matrix_coefficients: color_info.matrix_coefficients, + full_range_flag: color_info.full_range_flag, + reserved: 0, + profile: Vec::new(), + unknown: Vec::new(), + }, + &[], + )?); + } + child_boxes.push(super::super::mp4::encode_typed_box( + &build_btrt(samples, media_duration, timescale)?, + &[], + )?); + + super::super::mp4::encode_typed_box(&sample_entry, &child_boxes.concat()) +} + +fn build_btrt( + samples: &[StagedSample], + media_duration: u64, + timescale: u32, +) -> Result { + if samples.is_empty() || media_duration == 0 || timescale == 0 { + return Ok(Btrt::default()); + } + + let buffer_size_db = samples + .iter() + .map(|sample| sample.data_size) + .max() + .unwrap_or(0); + let total_bytes = samples.iter().try_fold(0_u64, |sum, sample| { + sum.checked_add(u64::from(sample.data_size)) + .ok_or(MuxError::LayoutOverflow("raw H.265 total sample bytes")) + })?; + let avg_bitrate = total_bytes + .checked_mul(8) + .and_then(|value| value.checked_mul(u64::from(timescale))) + .ok_or(MuxError::LayoutOverflow("raw H.265 average bitrate"))? + / media_duration; + let avg_bitrate = avg_bitrate & !7; + + Ok(Btrt { + buffer_size_db, + max_bitrate: u32::try_from(avg_bitrate) + .map_err(|_| MuxError::LayoutOverflow("raw H.265 maximum bitrate"))?, + avg_bitrate: u32::try_from(avg_bitrate) + .map_err(|_| MuxError::LayoutOverflow("raw H.265 average bitrate"))?, + }) +} + +fn staged_media_duration(samples: &[StagedSample]) -> Option { + let mut decode_time = 0_i64; + let mut decode_end = 0_i64; + let mut presentation_end = 0_i64; + for sample in samples { + let duration = i64::from(sample.duration); + let sample_decode_end = decode_time.checked_add(duration)?; + let sample_presentation_end = decode_time + .checked_add(i64::from(sample.composition_time_offset))? + .checked_add(duration)?; + decode_end = decode_end.max(sample_decode_end); + presentation_end = presentation_end.max(sample_presentation_end); + decode_time = sample_decode_end; + } + u64::try_from(decode_end.max(presentation_end)).ok() +} + +fn display_track_width(width: u16, pixel_aspect_ratio: Option<&H265PixelAspectRatio>) -> u16 { + let Some(pixel_aspect_ratio) = pixel_aspect_ratio else { + return width; + }; + let numerator = u64::from(width) + .saturating_mul(u64::from(pixel_aspect_ratio.h_spacing)) + .saturating_add(u64::from(pixel_aspect_ratio.v_spacing / 2)); + let display_width = numerator / u64::from(pixel_aspect_ratio.v_spacing); + u16::try_from(display_width).unwrap_or(width) +} + +fn parse_h265_pps_configuration(nal: &[u8], spec: &str) -> Result { + if nal.len() < 3 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.265 PPS NAL is too short".to_string(), + }); + } + let rbsp = nal_to_rbsp(&nal[2..]); + let mut reader = BitReader::new(Cursor::new(rbsp)); + let _pps_pic_parameter_set_id = read_ue_labeled(&mut reader, spec, "H.265")?; + let _pps_seq_parameter_set_id = read_ue_labeled(&mut reader, spec, "H.265")?; + Ok(H265PpsInfo { + dependent_slice_segments_enabled_flag: read_bit_labeled(&mut reader, spec, "H.265")?, + output_flag_present_flag: read_bit_labeled(&mut reader, spec, "H.265")?, + num_extra_slice_header_bits: read_bits_u8_labeled(&mut reader, 3, spec, "H.265")?, + }) +} + +fn parse_h265_sample_pocs( + first_vcl_nals: &[Vec], + sps_info: &H265SpsInfo, + pps_info: &H265PpsInfo, + sample_duration: u32, + spec: &str, +) -> Option> { + let mut pocs = Vec::with_capacity(first_vcl_nals.len()); + let mut prev_poc_lsb = 0_u32; + let mut prev_poc_msb = 0_i32; + + for nal in first_vcl_nals { + let parsed = + parse_h265_slice_poc(nal, sps_info, pps_info, prev_poc_lsb, prev_poc_msb, spec)?; + pocs.push( + i32::try_from(parsed.poc) + .ok()? + .checked_mul(i32::try_from(sample_duration).ok()?)?, + ); + prev_poc_lsb = parsed.poc_lsb; + prev_poc_msb = parsed.poc_msb; + } + + Some(pocs) +} + +struct ParsedH265Poc { + poc_lsb: u32, + poc_msb: i32, + poc: u32, +} + +fn parse_h265_slice_poc( + nal: &[u8], + sps_info: &H265SpsInfo, + pps_info: &H265PpsInfo, + prev_poc_lsb: u32, + prev_poc_msb: i32, + spec: &str, +) -> Option { + if nal.len() < 3 { + return None; + } + let nal_type = hevc_nal_type(nal); + let idr_pic_flag = matches!(nal_type, 19 | 20); + let rbsp = nal_to_rbsp(&nal[2..]); + let mut reader = BitReader::new(Cursor::new(rbsp)); + let first_slice_segment_in_pic = read_bit_labeled(&mut reader, spec, "H.265").ok()?; + if matches!(nal_type, 16..=23) { + let _no_output_of_prior_pics_flag = read_bit_labeled(&mut reader, spec, "H.265").ok()?; + } + let _slice_pic_parameter_set_id = read_ue_labeled(&mut reader, spec, "H.265").ok()?; + let dependent_slice_segment_flag = + if !first_slice_segment_in_pic && pps_info.dependent_slice_segments_enabled_flag { + read_bit_labeled(&mut reader, spec, "H.265").ok()? + } else { + false + }; + if dependent_slice_segment_flag { + return None; + } + if !first_slice_segment_in_pic { + return None; + } + if pps_info.num_extra_slice_header_bits > 0 { + let _ = read_bits_u8_labeled( + &mut reader, + usize::from(pps_info.num_extra_slice_header_bits), + spec, + "H.265", + ) + .ok()?; + } + let _slice_type = read_ue_labeled(&mut reader, spec, "H.265").ok()?; + if pps_info.output_flag_present_flag { + let _pic_output_flag = read_bit_labeled(&mut reader, spec, "H.265").ok()?; + } + if sps_info.separate_colour_plane_flag { + let _colour_plane_id = read_bits_u8_labeled(&mut reader, 2, spec, "H.265").ok()?; + } + if idr_pic_flag { + return Some(ParsedH265Poc { + poc_lsb: 0, + poc_msb: 0, + poc: 0, + }); + } + let poc_lsb = u32::from( + read_bits_u16_labeled( + &mut reader, + usize::from(sps_info.log2_max_pic_order_cnt_lsb), + spec, + "H.265", + ) + .ok()?, + ); + let max_poc_lsb = 1_u32.checked_shl(u32::from(sps_info.log2_max_pic_order_cnt_lsb))?; + let poc_msb = if poc_lsb < prev_poc_lsb && prev_poc_lsb - poc_lsb >= max_poc_lsb / 2 { + prev_poc_msb.checked_add(i32::try_from(max_poc_lsb).ok()?)? + } else if poc_lsb > prev_poc_lsb && poc_lsb - prev_poc_lsb > max_poc_lsb / 2 { + prev_poc_msb.checked_sub(i32::try_from(max_poc_lsb).ok()?)? + } else { + prev_poc_msb + }; + let poc = u32::try_from(i64::from(poc_msb) + i64::from(poc_lsb)).ok()?; + Some(ParsedH265Poc { + poc_lsb, + poc_msb, + poc, + }) +} + +const fn hevc_nal_type(nal: &[u8]) -> u8 { + (nal[0] >> 1) & 0x3F +} + +const fn is_hevc_vcl_nal_type(nal_type: u8) -> bool { + nal_type <= 31 +} + +const fn is_hevc_sync_nal_type(nal_type: u8) -> bool { + matches!(nal_type, 16..=21) +} + +fn h265_first_slice_segment_in_pic(nal: &[u8], spec: &str) -> Result { + if nal.len() < 3 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.265 VCL NAL is too short".to_string(), + }); + } + let rbsp = nal_to_rbsp(&nal[2..]); + let mut reader = BitReader::new(Cursor::new(rbsp)); + read_bit_labeled(&mut reader, spec, "H.265") +} + +struct H265SpsInfo { + width: u16, + height: u16, + general_profile_space: u8, + general_tier_flag: bool, + general_profile_idc: u8, + general_profile_compatibility: [bool; 32], + general_constraint_indicator: [u8; 6], + general_level_idc: u8, + chroma_format_idc: u8, + separate_colour_plane_flag: bool, + bit_depth_luma_minus8: u8, + bit_depth_chroma_minus8: u8, + num_temporal_layers: u8, + temporal_id_nested: u8, + log2_max_pic_order_cnt_lsb: u8, + timing_time_scale: Option, + timing_num_units_in_tick: Option, + timing_ticks_per_picture: Option, + pixel_aspect_ratio: Option, + color_info: Option, +} + +struct H265PpsInfo { + dependent_slice_segments_enabled_flag: bool, + output_flag_present_flag: bool, + num_extra_slice_header_bits: u8, +} + +struct H265PixelAspectRatio { + h_spacing: u32, + v_spacing: u32, +} + +struct H265ColorInfo { + colour_primaries: u16, + transfer_characteristics: u16, + matrix_coefficients: u16, + full_range_flag: bool, +} + +type H265VuiInfo = ( + Option, + Option, + Option, + Option, + Option, +); + +fn parse_h265_sps_configuration(nal: &[u8], spec: &str) -> Result { + if nal.len() < 3 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.265 SPS NAL is too short".to_string(), + }); + } + let rbsp = nal_to_rbsp(&nal[2..]); + let mut reader = BitReader::new(Cursor::new(rbsp)); + let _sps_video_parameter_set_id = read_bits_u8_labeled(&mut reader, 4, spec, "H.265")?; + let max_sub_layers_minus1 = read_bits_u8_labeled(&mut reader, 3, spec, "H.265")?; + let temporal_id_nested = u8::from(read_bit_labeled(&mut reader, spec, "H.265")?); + let general_profile_space = read_bits_u8_labeled(&mut reader, 2, spec, "H.265")?; + let general_tier_flag = read_bit_labeled(&mut reader, spec, "H.265")?; + let general_profile_idc = read_bits_u8_labeled(&mut reader, 5, spec, "H.265")?; + let mut general_profile_compatibility = [false; 32]; + for entry in &mut general_profile_compatibility { + *entry = read_bit_labeled(&mut reader, spec, "H.265")?; + } + let mut general_constraint_indicator = [0_u8; 6]; + for entry in &mut general_constraint_indicator { + *entry = read_bits_u8_labeled(&mut reader, 8, spec, "H.265")?; + } + let general_level_idc = read_bits_u8_labeled(&mut reader, 8, spec, "H.265")?; + + let mut sub_layer_profile_present_flags = + Vec::with_capacity(usize::from(max_sub_layers_minus1)); + let mut sub_layer_level_present_flags = Vec::with_capacity(usize::from(max_sub_layers_minus1)); + for _ in 0..max_sub_layers_minus1 { + sub_layer_profile_present_flags.push(read_bit_labeled(&mut reader, spec, "H.265")?); + sub_layer_level_present_flags.push(read_bit_labeled(&mut reader, spec, "H.265")?); + } + if max_sub_layers_minus1 > 0 { + for _ in max_sub_layers_minus1..8 { + skip_bits_labeled(&mut reader, 2, spec, "H.265")?; + } + } + for (profile_present, level_present) in sub_layer_profile_present_flags + .into_iter() + .zip(sub_layer_level_present_flags) + { + if profile_present { + skip_bits_labeled(&mut reader, 88, spec, "H.265")?; + } + if level_present { + skip_bits_labeled(&mut reader, 8, spec, "H.265")?; + } + } + + let _sps_seq_parameter_set_id = read_ue_labeled(&mut reader, spec, "H.265")?; + let chroma_format_idc = + u8::try_from(read_ue_labeled(&mut reader, spec, "H.265")?).map_err(|_| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.265 chroma format does not fit in u8".to_string(), + } + })?; + let separate_colour_plane_flag = if chroma_format_idc == 3 { + read_bit_labeled(&mut reader, spec, "H.265")? + } else { + false + }; + let pic_width_in_luma_samples = read_ue_labeled(&mut reader, spec, "H.265")?; + let pic_height_in_luma_samples = read_ue_labeled(&mut reader, spec, "H.265")?; + let (conf_win_left_offset, conf_win_right_offset, conf_win_top_offset, conf_win_bottom_offset) = + if read_bit_labeled(&mut reader, spec, "H.265")? { + ( + read_ue_labeled(&mut reader, spec, "H.265")?, + read_ue_labeled(&mut reader, spec, "H.265")?, + read_ue_labeled(&mut reader, spec, "H.265")?, + read_ue_labeled(&mut reader, spec, "H.265")?, + ) + } else { + (0, 0, 0, 0) + }; + let bit_depth_luma_minus8 = u8::try_from(read_ue_labeled(&mut reader, spec, "H.265")?) + .map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.265 luma bit depth does not fit in u8".to_string(), + })?; + let bit_depth_chroma_minus8 = u8::try_from(read_ue_labeled(&mut reader, spec, "H.265")?) + .map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.265 chroma bit depth does not fit in u8".to_string(), + })?; + let log2_max_pic_order_cnt_lsb_minus4 = read_ue_labeled(&mut reader, spec, "H.265")?; + let sps_sub_layer_ordering_info_present_flag = read_bit_labeled(&mut reader, spec, "H.265")?; + let sub_layer_ordering_start = if sps_sub_layer_ordering_info_present_flag { + 0 + } else { + u32::from(max_sub_layers_minus1) + }; + for _ in sub_layer_ordering_start..=u32::from(max_sub_layers_minus1) { + let _ = read_ue_labeled(&mut reader, spec, "H.265")?; + let _ = read_ue_labeled(&mut reader, spec, "H.265")?; + let _ = read_ue_labeled(&mut reader, spec, "H.265")?; + } + let _ = read_ue_labeled(&mut reader, spec, "H.265")?; + let _ = read_ue_labeled(&mut reader, spec, "H.265")?; + let _ = read_ue_labeled(&mut reader, spec, "H.265")?; + let _ = read_ue_labeled(&mut reader, spec, "H.265")?; + let _ = read_ue_labeled(&mut reader, spec, "H.265")?; + let _ = read_ue_labeled(&mut reader, spec, "H.265")?; + let scaling_list_enabled_flag = read_bit_labeled(&mut reader, spec, "H.265")?; + if scaling_list_enabled_flag && read_bit_labeled(&mut reader, spec, "H.265")? { + skip_h265_scaling_list_data(&mut reader, spec)?; + } + let _amp_enabled_flag = read_bit_labeled(&mut reader, spec, "H.265")?; + let _sample_adaptive_offset_enabled_flag = read_bit_labeled(&mut reader, spec, "H.265")?; + let pcm_enabled_flag = read_bit_labeled(&mut reader, spec, "H.265")?; + if pcm_enabled_flag { + skip_bits_labeled(&mut reader, 4, spec, "H.265")?; + skip_bits_labeled(&mut reader, 4, spec, "H.265")?; + let _ = read_ue_labeled(&mut reader, spec, "H.265")?; + let _ = read_ue_labeled(&mut reader, spec, "H.265")?; + let _pcm_loop_filter_disabled_flag = read_bit_labeled(&mut reader, spec, "H.265")?; + } + let num_short_term_ref_pic_sets = read_ue_labeled(&mut reader, spec, "H.265")?; + let mut num_delta_pocs = Vec::with_capacity( + usize::try_from(num_short_term_ref_pic_sets) + .map_err(|_| MuxError::LayoutOverflow("H.265 short-term reference picture sets"))?, + ); + for st_rps_idx in 0..num_short_term_ref_pic_sets { + skip_h265_short_term_ref_pic_set( + &mut reader, + st_rps_idx, + num_short_term_ref_pic_sets, + &mut num_delta_pocs, + spec, + )?; + } + if read_bit_labeled(&mut reader, spec, "H.265")? { + let num_long_term_ref_pics_sps = read_ue_labeled(&mut reader, spec, "H.265")?; + let lt_ref_pic_bits = + usize::try_from(log2_max_pic_order_cnt_lsb_minus4.checked_add(4).ok_or( + MuxError::LayoutOverflow("H.265 long-term reference picture width"), + )?) + .map_err(|_| MuxError::LayoutOverflow("H.265 long-term reference picture width"))?; + for _ in 0..num_long_term_ref_pics_sps { + skip_bits_labeled(&mut reader, lt_ref_pic_bits, spec, "H.265")?; + let _used_by_curr_pic_lt_sps_flag = read_bit_labeled(&mut reader, spec, "H.265")?; + } + } + let _sps_temporal_mvp_enabled_flag = read_bit_labeled(&mut reader, spec, "H.265")?; + let _strong_intra_smoothing_enabled_flag = read_bit_labeled(&mut reader, spec, "H.265")?; + let ( + timing_num_units_in_tick, + timing_time_scale, + timing_ticks_per_picture, + pixel_aspect_ratio, + color_info, + ) = if read_bit_labeled(&mut reader, spec, "H.265")? { + parse_h265_vui_timing(&mut reader, spec)? + } else { + (None, None, None, None, None) + }; + + let sub_width_c = match chroma_format_idc { + 1 | 2 => 2_u32, + _ => 1_u32, + }; + let sub_height_c = match chroma_format_idc { + 1 => 2_u32, + _ => 1_u32, + }; + let width = pic_width_in_luma_samples + .saturating_sub((conf_win_left_offset + conf_win_right_offset).saturating_mul(sub_width_c)); + let height = pic_height_in_luma_samples.saturating_sub( + (conf_win_top_offset + conf_win_bottom_offset).saturating_mul(sub_height_c), + ); + + Ok(H265SpsInfo { + width: u16::try_from(width).map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.265 SPS width does not fit in u16".to_string(), + })?, + height: u16::try_from(height).map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.265 SPS height does not fit in u16".to_string(), + })?, + general_profile_space, + general_tier_flag, + general_profile_idc, + general_profile_compatibility, + general_constraint_indicator, + general_level_idc, + chroma_format_idc, + separate_colour_plane_flag, + bit_depth_luma_minus8, + bit_depth_chroma_minus8, + num_temporal_layers: max_sub_layers_minus1.saturating_add(1), + temporal_id_nested, + log2_max_pic_order_cnt_lsb: u8::try_from(log2_max_pic_order_cnt_lsb_minus4 + 4).map_err( + |_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.265 POC width does not fit in u8".to_string(), + }, + )?, + timing_time_scale, + timing_num_units_in_tick, + timing_ticks_per_picture, + pixel_aspect_ratio, + color_info, + }) +} + +fn skip_h265_scaling_list_data(reader: &mut BitReader, spec: &str) -> Result<(), MuxError> +where + R: Read, +{ + for size_id in 0..4 { + let matrix_count = if size_id == 3 { 2 } else { 6 }; + for _ in 0..matrix_count { + if !read_bit_labeled(reader, spec, "H.265")? { + let _ = read_ue_labeled(reader, spec, "H.265")?; + continue; + } + let coef_num = 64_usize.min(1_usize << (4 + (size_id * 2))); + if size_id > 1 { + let _ = read_se_labeled(reader, spec, "H.265")?; + } + for _ in 0..coef_num { + let _ = read_se_labeled(reader, spec, "H.265")?; + } + } + } + Ok(()) +} + +fn skip_h265_short_term_ref_pic_set( + reader: &mut BitReader, + st_rps_idx: u32, + num_short_term_ref_pic_sets: u32, + num_delta_pocs: &mut Vec, + spec: &str, +) -> Result<(), MuxError> +where + R: Read, +{ + let inter_ref_pic_set_prediction_flag = if st_rps_idx != 0 { + read_bit_labeled(reader, spec, "H.265")? + } else { + false + }; + if inter_ref_pic_set_prediction_flag { + let delta_idx_minus1 = if st_rps_idx == num_short_term_ref_pic_sets { + read_ue_labeled(reader, spec, "H.265")? + } else { + 0 + }; + let ref_rps_idx = st_rps_idx + .checked_sub(delta_idx_minus1 + 1) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.265 short-term reference picture sets underflowed".to_string(), + })?; + let ref_num_delta_pocs = *num_delta_pocs + .get(usize::try_from(ref_rps_idx).map_err(|_| { + MuxError::LayoutOverflow("H.265 short-term reference picture set index") + })?) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "H.265 short-term reference picture sets referenced an unknown set" + .to_string(), + })?; + let _delta_rps_sign = read_bit_labeled(reader, spec, "H.265")?; + let _abs_delta_rps_minus1 = read_ue_labeled(reader, spec, "H.265")?; + let mut resolved_delta_pocs = 0_u32; + for _ in 0..=ref_num_delta_pocs { + let used_by_curr_pic_flag = read_bit_labeled(reader, spec, "H.265")?; + let use_delta_flag = if !used_by_curr_pic_flag { + read_bit_labeled(reader, spec, "H.265")? + } else { + false + }; + if used_by_curr_pic_flag || use_delta_flag { + resolved_delta_pocs = + resolved_delta_pocs + .checked_add(1) + .ok_or(MuxError::LayoutOverflow( + "H.265 short-term reference picture delta count", + ))?; + } + } + num_delta_pocs.push(resolved_delta_pocs); + } else { + let num_negative_pics = read_ue_labeled(reader, spec, "H.265")?; + let num_positive_pics = read_ue_labeled(reader, spec, "H.265")?; + for _ in 0..num_negative_pics { + let _ = read_ue_labeled(reader, spec, "H.265")?; + let _used_by_curr_pic_s0_flag = read_bit_labeled(reader, spec, "H.265")?; + } + for _ in 0..num_positive_pics { + let _ = read_ue_labeled(reader, spec, "H.265")?; + let _used_by_curr_pic_s1_flag = read_bit_labeled(reader, spec, "H.265")?; + } + num_delta_pocs.push(num_negative_pics.checked_add(num_positive_pics).ok_or( + MuxError::LayoutOverflow("H.265 short-term reference picture count"), + )?); + } + Ok(()) +} + +fn parse_h265_vui_timing(reader: &mut BitReader, spec: &str) -> Result +where + R: Read, +{ + let mut pixel_aspect_ratio = None; + if read_bit_labeled(reader, spec, "H.265")? { + let aspect_ratio_idc = read_bits_u8_labeled(reader, 8, spec, "H.265")?; + if aspect_ratio_idc == 255 { + let sar_width = read_bits_u16_labeled(reader, 16, spec, "H.265")?; + let sar_height = read_bits_u16_labeled(reader, 16, spec, "H.265")?; + if sar_width != 0 && sar_height != 0 && sar_width != sar_height { + pixel_aspect_ratio = Some(H265PixelAspectRatio { + h_spacing: u32::from(sar_width), + v_spacing: u32::from(sar_height), + }); + } + } else { + pixel_aspect_ratio = h265_pixel_aspect_ratio_from_idc(aspect_ratio_idc); + } + } + if read_bit_labeled(reader, spec, "H.265")? { + let _overscan_appropriate_flag = read_bit_labeled(reader, spec, "H.265")?; + } + let mut color_info = None; + if read_bit_labeled(reader, spec, "H.265")? { + let _video_format = read_bits_u8_labeled(reader, 3, spec, "H.265")?; + let video_full_range_flag = read_bit_labeled(reader, spec, "H.265")?; + if read_bit_labeled(reader, spec, "H.265")? { + color_info = Some(H265ColorInfo { + colour_primaries: u16::from(read_bits_u8_labeled(reader, 8, spec, "H.265")?), + transfer_characteristics: u16::from(read_bits_u8_labeled( + reader, 8, spec, "H.265", + )?), + matrix_coefficients: u16::from(read_bits_u8_labeled(reader, 8, spec, "H.265")?), + full_range_flag: video_full_range_flag, + }); + } + } + if read_bit_labeled(reader, spec, "H.265")? { + let _ = read_ue_labeled(reader, spec, "H.265")?; + let _ = read_ue_labeled(reader, spec, "H.265")?; + } + let _neutral_chroma_indication_flag = read_bit_labeled(reader, spec, "H.265")?; + let _field_seq_flag = read_bit_labeled(reader, spec, "H.265")?; + let _frame_field_info_present_flag = read_bit_labeled(reader, spec, "H.265")?; + if read_bit_labeled(reader, spec, "H.265")? { + let _ = read_ue_labeled(reader, spec, "H.265")?; + let _ = read_ue_labeled(reader, spec, "H.265")?; + let _ = read_ue_labeled(reader, spec, "H.265")?; + let _ = read_ue_labeled(reader, spec, "H.265")?; + } + if !read_bit_labeled(reader, spec, "H.265")? { + return Ok((None, None, None, pixel_aspect_ratio, color_info)); + } + let num_units_in_tick = read_bits_u32_labeled(reader, 32, spec, "H.265")?; + let time_scale = read_bits_u32_labeled(reader, 32, spec, "H.265")?; + let ticks_per_picture = if read_bit_labeled(reader, spec, "H.265")? { + Some( + read_ue_labeled(reader, spec, "H.265")? + .checked_add(1) + .ok_or(MuxError::LayoutOverflow( + "H.265 ticks-per-picture timing from VUI", + ))?, + ) + } else { + None + }; + Ok(( + (num_units_in_tick != 0).then_some(num_units_in_tick), + (time_scale != 0).then_some(time_scale), + ticks_per_picture, + pixel_aspect_ratio, + color_info, + )) +} + +fn h265_pixel_aspect_ratio_from_idc(aspect_ratio_idc: u8) -> Option { + let (h_spacing, v_spacing) = match aspect_ratio_idc { + 1 => (1, 1), + 2 => (12, 11), + 3 => (10, 11), + 4 => (16, 11), + 5 => (40, 33), + 6 => (24, 11), + 7 => (20, 11), + 8 => (32, 11), + 9 => (80, 33), + 10 => (18, 11), + 11 => (15, 11), + 12 => (64, 33), + 13 => (160, 99), + 14 => (4, 3), + 15 => (3, 2), + 16 => (2, 1), + _ => return None, + }; + (h_spacing != v_spacing).then_some(H265PixelAspectRatio { + h_spacing, + v_spacing, + }) +} diff --git a/src/mux/demux/iamf.rs b/src/mux/demux/iamf.rs new file mode 100644 index 0000000..172e053 --- /dev/null +++ b/src/mux/demux/iamf.rs @@ -0,0 +1,1032 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::iamf::Iacb; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{ + StagedSample, build_generic_audio_sample_entry_box, read_exact_at_sync, +}; + +const IAMF_ENTRY: FourCc = FourCc::from_bytes(*b"iamf"); +const IAMF_SIGNATURE: [u8; 4] = *b"iamf"; +const OBU_IAMF_CODEC_CONFIG: u8 = 0; +const OBU_IAMF_AUDIO_ELEMENT: u8 = 1; +const OBU_IAMF_PARAMETER_BLOCK: u8 = 3; +const OBU_IAMF_TEMPORAL_DELIMITER: u8 = 4; +const OBU_IAMF_AUDIO_FRAME: u8 = 5; +const OBU_IAMF_SEQUENCE_HEADER: u8 = 31; + +const AAC_SAMPLE_RATE_TABLE: [u32; 16] = [ + 96_000, 88_200, 64_000, 48_000, 44_100, 32_000, 24_000, 22_050, 16_000, 12_000, 11_025, 8_000, + 7_350, 0, 0, 0, +]; + +pub(in crate::mux) struct ParsedIamfTrack { + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +pub(in crate::mux) fn looks_like_iamf_prefix(prefix: &[u8]) -> bool { + let Some(&first) = prefix.first() else { + return false; + }; + let obu_type = first >> 3; + if obu_type != OBU_IAMF_SEQUENCE_HEADER { + return false; + } + if first & 0x06 != 0 { + return false; + } + let Ok((obu_size_field, size_len)) = + read_leb128_from_slice(&prefix[1..], "__detect__", "IAMF OBU size", 0) + else { + return false; + }; + let payload_offset = 1usize.saturating_add(size_len); + let total_size = 1usize + .checked_add(size_len) + .and_then(|value| value.checked_add(usize::try_from(obu_size_field).ok()?)); + let Some(total_size) = total_size else { + return false; + }; + if prefix.len() < total_size || prefix.len() < payload_offset + 6 { + return false; + } + let payload = &prefix[payload_offset..]; + if payload[..4] != IAMF_SIGNATURE { + return false; + } + let primary = payload[4]; + let additional = payload[5]; + matches!(primary, 0..=2) || matches!(additional, 0..=2) +} + +#[derive(Clone, Copy)] +struct IamfObuHeader { + obu_type: u8, + total_size: u64, + header_size: u64, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +struct ParsedCodecConfig { + codec_id: FourCc, + num_samples_per_frame: u32, + sample_rate: u32, + sample_size: u16, + channel_count_hint: Option, +} + +pub(in crate::mux) fn scan_iamf_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + parse_iamf_stream_sync(&mut file, file_size, spec) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_iamf_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + parse_iamf_stream_async(&mut file, file_size, spec).await +} + +fn parse_iamf_stream_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result { + let mut offset = 0u64; + let mut descriptor_obus = Vec::new(); + let mut codec_config = None::; + let mut total_substreams = 0u16; + let mut current_sample_start = None::; + let mut audio_frames_in_current_sample = 0u16; + let mut saw_temporal_units = false; + let mut saw_delimiter_mode = None::; + let mut samples = Vec::new(); + + while offset < file_size { + let header = read_iamf_obu_header_sync(file, offset, file_size, spec)?; + let obu_end = offset + .checked_add(header.total_size) + .ok_or(MuxError::LayoutOverflow("IAMF OBU range"))?; + if obu_end > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("IAMF OBU at byte offset {offset} overruns the input length"), + }); + } + match header.obu_type { + OBU_IAMF_SEQUENCE_HEADER => { + ensure_iamf_sequence_header_sync(file, offset, &header, spec)?; + if saw_temporal_units { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF sequence headers must not appear after temporal-unit data" + .to_string(), + }); + } + descriptor_obus.extend_from_slice(&read_obu_bytes_sync( + file, + offset, + header.total_size, + spec, + "IAMF sequence header is truncated", + )?); + } + OBU_IAMF_CODEC_CONFIG => { + if saw_temporal_units { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "IAMF codec configuration OBUs must not appear after temporal-unit data" + .to_string(), + }); + } + let payload = read_iamf_payload_sync(file, offset, &header, spec)?; + let parsed = parse_iamf_codec_config_payload(&payload, spec)?; + if let Some(current) = codec_config { + if current != parsed { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "IAMF codec configuration changed sample rate, sample size, frame length, or codec id mid-stream" + .to_string(), + }); + } + } else { + codec_config = Some(parsed); + } + descriptor_obus.extend_from_slice(&read_obu_bytes_sync( + file, + offset, + header.total_size, + spec, + "IAMF codec configuration OBU is truncated", + )?); + } + OBU_IAMF_AUDIO_ELEMENT => { + if saw_temporal_units { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF audio-element OBUs must not appear after temporal-unit data" + .to_string(), + }); + } + let payload = read_iamf_payload_sync(file, offset, &header, spec)?; + let substreams = parse_iamf_audio_element_payload(&payload, spec)?; + total_substreams = total_substreams + .checked_add(substreams) + .ok_or(MuxError::LayoutOverflow("IAMF total substreams"))?; + descriptor_obus.extend_from_slice(&read_obu_bytes_sync( + file, + offset, + header.total_size, + spec, + "IAMF audio-element OBU is truncated", + )?); + } + OBU_IAMF_PARAMETER_BLOCK | OBU_IAMF_TEMPORAL_DELIMITER | OBU_IAMF_AUDIO_FRAME => { + let config = codec_config.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF temporal-unit data appeared before any codec configuration OBU" + .to_string(), + })?; + if total_substreams == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF temporal-unit data appeared before any audio-element OBU" + .to_string(), + }); + } + saw_temporal_units = true; + if header.obu_type == OBU_IAMF_TEMPORAL_DELIMITER { + if audio_frames_in_current_sample != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "IAMF temporal delimiters must not appear in the middle of a temporal unit" + .to_string(), + }); + } + saw_delimiter_mode.get_or_insert(true); + if current_sample_start.is_some() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "IAMF temporal unit carried more than one delimiter before its audio frames" + .to_string(), + }); + } + current_sample_start = Some(offset); + } else { + if saw_delimiter_mode == Some(true) && current_sample_start.is_none() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "IAMF temporal-unit data stopped using temporal delimiters after they had already started" + .to_string(), + }); + } + saw_delimiter_mode.get_or_insert(false); + if current_sample_start.is_none() { + current_sample_start = Some(offset); + } + if header.obu_type == OBU_IAMF_AUDIO_FRAME { + audio_frames_in_current_sample = audio_frames_in_current_sample + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("IAMF audio-frame count"))?; + } + if audio_frames_in_current_sample == total_substreams { + let sample_start = current_sample_start.take().unwrap(); + let data_size = u32::try_from(obu_end - sample_start) + .map_err(|_| MuxError::LayoutOverflow("IAMF temporal-unit size"))?; + samples.push(StagedSample { + data_offset: sample_start, + data_size, + duration: config.num_samples_per_frame, + composition_time_offset: 0, + is_sync_sample: true, + }); + audio_frames_in_current_sample = 0; + } else if audio_frames_in_current_sample > total_substreams { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "IAMF temporal unit carried more audio frames than the declared substream count" + .to_string(), + }); + } + } + } + _ => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "IAMF OBU at byte offset {offset} used unsupported OBU type {}", + header.obu_type + ), + }); + } + } + offset = obu_end; + } + + finalize_iamf_track( + spec, + descriptor_obus, + codec_config, + total_substreams, + current_sample_start, + audio_frames_in_current_sample, + samples, + ) +} + +#[cfg(feature = "async")] +async fn parse_iamf_stream_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result { + let mut offset = 0u64; + let mut descriptor_obus = Vec::new(); + let mut codec_config = None::; + let mut total_substreams = 0u16; + let mut current_sample_start = None::; + let mut audio_frames_in_current_sample = 0u16; + let mut saw_temporal_units = false; + let mut saw_delimiter_mode = None::; + let mut samples = Vec::new(); + + while offset < file_size { + let header = read_iamf_obu_header_async(file, offset, file_size, spec).await?; + let obu_end = offset + .checked_add(header.total_size) + .ok_or(MuxError::LayoutOverflow("IAMF OBU range"))?; + if obu_end > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("IAMF OBU at byte offset {offset} overruns the input length"), + }); + } + match header.obu_type { + OBU_IAMF_SEQUENCE_HEADER => { + ensure_iamf_sequence_header_async(file, offset, &header, spec).await?; + if saw_temporal_units { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF sequence headers must not appear after temporal-unit data" + .to_string(), + }); + } + descriptor_obus.extend_from_slice( + &read_obu_bytes_async( + file, + offset, + header.total_size, + spec, + "IAMF sequence header is truncated", + ) + .await?, + ); + } + OBU_IAMF_CODEC_CONFIG => { + if saw_temporal_units { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "IAMF codec configuration OBUs must not appear after temporal-unit data" + .to_string(), + }); + } + let payload = read_iamf_payload_async(file, offset, &header, spec).await?; + let parsed = parse_iamf_codec_config_payload(&payload, spec)?; + if let Some(current) = codec_config { + if current != parsed { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "IAMF codec configuration changed sample rate, sample size, frame length, or codec id mid-stream" + .to_string(), + }); + } + } else { + codec_config = Some(parsed); + } + descriptor_obus.extend_from_slice( + &read_obu_bytes_async( + file, + offset, + header.total_size, + spec, + "IAMF codec configuration OBU is truncated", + ) + .await?, + ); + } + OBU_IAMF_AUDIO_ELEMENT => { + if saw_temporal_units { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF audio-element OBUs must not appear after temporal-unit data" + .to_string(), + }); + } + let payload = read_iamf_payload_async(file, offset, &header, spec).await?; + let substreams = parse_iamf_audio_element_payload(&payload, spec)?; + total_substreams = total_substreams + .checked_add(substreams) + .ok_or(MuxError::LayoutOverflow("IAMF total substreams"))?; + descriptor_obus.extend_from_slice( + &read_obu_bytes_async( + file, + offset, + header.total_size, + spec, + "IAMF audio-element OBU is truncated", + ) + .await?, + ); + } + OBU_IAMF_PARAMETER_BLOCK | OBU_IAMF_TEMPORAL_DELIMITER | OBU_IAMF_AUDIO_FRAME => { + let config = codec_config.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF temporal-unit data appeared before any codec configuration OBU" + .to_string(), + })?; + if total_substreams == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF temporal-unit data appeared before any audio-element OBU" + .to_string(), + }); + } + saw_temporal_units = true; + if header.obu_type == OBU_IAMF_TEMPORAL_DELIMITER { + if audio_frames_in_current_sample != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "IAMF temporal delimiters must not appear in the middle of a temporal unit" + .to_string(), + }); + } + saw_delimiter_mode.get_or_insert(true); + if current_sample_start.is_some() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "IAMF temporal unit carried more than one delimiter before its audio frames" + .to_string(), + }); + } + current_sample_start = Some(offset); + } else { + if saw_delimiter_mode == Some(true) && current_sample_start.is_none() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "IAMF temporal-unit data stopped using temporal delimiters after they had already started" + .to_string(), + }); + } + saw_delimiter_mode.get_or_insert(false); + if current_sample_start.is_none() { + current_sample_start = Some(offset); + } + if header.obu_type == OBU_IAMF_AUDIO_FRAME { + audio_frames_in_current_sample = audio_frames_in_current_sample + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("IAMF audio-frame count"))?; + } + if audio_frames_in_current_sample == total_substreams { + let sample_start = current_sample_start.take().unwrap(); + let data_size = u32::try_from(obu_end - sample_start) + .map_err(|_| MuxError::LayoutOverflow("IAMF temporal-unit size"))?; + samples.push(StagedSample { + data_offset: sample_start, + data_size, + duration: config.num_samples_per_frame, + composition_time_offset: 0, + is_sync_sample: true, + }); + audio_frames_in_current_sample = 0; + } else if audio_frames_in_current_sample > total_substreams { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "IAMF temporal unit carried more audio frames than the declared substream count" + .to_string(), + }); + } + } + } + _ => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "IAMF OBU at byte offset {offset} used unsupported OBU type {}", + header.obu_type + ), + }); + } + } + offset = obu_end; + } + + finalize_iamf_track( + spec, + descriptor_obus, + codec_config, + total_substreams, + current_sample_start, + audio_frames_in_current_sample, + samples, + ) +} + +fn finalize_iamf_track( + spec: &str, + descriptor_obus: Vec, + codec_config: Option, + total_substreams: u16, + current_sample_start: Option, + audio_frames_in_current_sample: u16, + samples: Vec, +) -> Result { + if current_sample_start.is_some() || audio_frames_in_current_sample != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF input ended in the middle of a temporal unit".to_string(), + }); + } + if descriptor_obus.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF input did not contain any configuration descriptor OBUs".to_string(), + }); + } + if samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF input did not contain any temporal-unit audio frames".to_string(), + }); + } + let codec_config = codec_config.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF input did not contain any codec configuration OBU".to_string(), + })?; + if total_substreams == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF input did not contain any audio-element OBU".to_string(), + }); + } + let iacb_bytes = super::super::mp4::encode_typed_box( + &Iacb { + configuration_version: 1, + config_obus: descriptor_obus, + }, + &[], + )?; + let sample_entry_box = + build_generic_audio_sample_entry_box(IAMF_ENTRY, 0, 0, 0, &[iacb_bytes])?; + Ok(ParsedIamfTrack { + sample_rate: codec_config.sample_rate, + sample_entry_box, + samples, + }) +} + +fn read_iamf_obu_header_sync( + file: &mut File, + offset: u64, + file_size: u64, + spec: &str, +) -> Result { + let remaining = file_size.saturating_sub(offset); + let header_probe_len = usize::try_from(remaining.min(32)).unwrap(); + let mut probe = vec![0u8; header_probe_len]; + read_exact_at_sync( + file, + offset, + &mut probe, + spec, + "IAMF OBU header is truncated", + )?; + parse_iamf_obu_header(&probe, spec, offset) +} + +#[cfg(feature = "async")] +async fn read_iamf_obu_header_async( + file: &mut TokioFile, + offset: u64, + file_size: u64, + spec: &str, +) -> Result { + let remaining = file_size.saturating_sub(offset); + let header_probe_len = usize::try_from(remaining.min(32)).unwrap(); + let mut probe = vec![0u8; header_probe_len]; + read_exact_at_async( + file, + offset, + &mut probe, + spec, + "IAMF OBU header is truncated", + ) + .await?; + parse_iamf_obu_header(&probe, spec, offset) +} + +fn parse_iamf_obu_header(bytes: &[u8], spec: &str, offset: u64) -> Result { + let Some(&first) = bytes.first() else { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF OBU header is truncated".to_string(), + }); + }; + let obu_type = first >> 3; + let redundant_copy = first & 0x04 != 0; + let trimming_status_flag = first & 0x02 != 0; + let extension_flag = first & 0x01 != 0; + if redundant_copy && is_iamf_temporal_unit_obu(obu_type) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "IAMF temporal-unit OBU at byte offset {offset} used an unsupported redundant-copy flag" + ), + }); + } + if trimming_status_flag && obu_type != OBU_IAMF_AUDIO_FRAME { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "IAMF non-audio-frame OBU at byte offset {offset} used an unsupported trimming flag" + ), + }); + } + let (obu_size_field, size_len) = + read_leb128_from_slice(&bytes[1..], spec, "IAMF OBU size", offset)?; + let mut header_size = 1u64 + .checked_add(u64::try_from(size_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("IAMF OBU header size"))?; + let mut cursor = 1usize + size_len; + if trimming_status_flag { + let (_, trim_end_len) = + read_leb128_from_slice(&bytes[cursor..], spec, "IAMF trim-end", offset)?; + cursor += trim_end_len; + let (_, trim_start_len) = + read_leb128_from_slice(&bytes[cursor..], spec, "IAMF trim-start", offset)?; + cursor += trim_start_len; + header_size = u64::try_from(cursor).unwrap(); + } + if extension_flag { + let (extension_size, extension_len) = + read_leb128_from_slice(&bytes[cursor..], spec, "IAMF extension header size", offset)?; + let extension_size = usize::try_from(extension_size) + .map_err(|_| MuxError::LayoutOverflow("IAMF extension header size"))?; + cursor = cursor + .checked_add(extension_len) + .and_then(|value| value.checked_add(extension_size)) + .ok_or(MuxError::LayoutOverflow("IAMF extension header size"))?; + if bytes.len() < cursor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "IAMF OBU at byte offset {offset} truncated inside its extension header" + ), + }); + } + header_size = u64::try_from(cursor).unwrap(); + } + let total_size = 1u64 + .checked_add(obu_size_field) + .and_then(|value| value.checked_add(u64::try_from(size_len).unwrap())) + .ok_or(MuxError::LayoutOverflow("IAMF OBU size"))?; + if total_size < header_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "IAMF OBU at byte offset {offset} declared a size shorter than its parsed header" + ), + }); + } + Ok(IamfObuHeader { + obu_type, + total_size, + header_size, + }) +} + +fn ensure_iamf_sequence_header_sync( + file: &mut File, + offset: u64, + header: &IamfObuHeader, + spec: &str, +) -> Result<(), MuxError> { + let payload = read_iamf_payload_sync(file, offset, header, spec)?; + ensure_iamf_sequence_header_payload(&payload, spec, offset) +} + +#[cfg(feature = "async")] +async fn ensure_iamf_sequence_header_async( + file: &mut TokioFile, + offset: u64, + header: &IamfObuHeader, + spec: &str, +) -> Result<(), MuxError> { + let payload = read_iamf_payload_async(file, offset, header, spec).await?; + ensure_iamf_sequence_header_payload(&payload, spec, offset) +} + +fn ensure_iamf_sequence_header_payload( + payload: &[u8], + spec: &str, + offset: u64, +) -> Result<(), MuxError> { + if payload.len() < 6 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("IAMF sequence header at byte offset {offset} is truncated"), + }); + } + if payload[..4] != IAMF_SIGNATURE { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "IAMF sequence header at byte offset {offset} did not start with the `iamf` signature" + ), + }); + } + let primary = payload[4]; + let additional = payload[5]; + if !matches!(primary, 0..=2) && !matches!(additional, 0..=2) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "IAMF sequence header at byte offset {offset} used unsupported profiles {primary} and {additional}" + ), + }); + } + Ok(()) +} + +fn parse_iamf_codec_config_payload( + payload: &[u8], + spec: &str, +) -> Result { + let (codec_config_id, id_len) = + read_leb128_from_slice(payload, spec, "IAMF codec_config_id", 0)?; + let _ = codec_config_id; + if payload.len() < id_len + 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF codec configuration OBU is truncated".to_string(), + }); + } + let codec_id = FourCc::from_bytes(payload[id_len..id_len + 4].try_into().unwrap()); + let (num_samples_per_frame, frame_len_len) = read_leb128_from_slice( + &payload[id_len + 4..], + spec, + "IAMF num_samples_per_frame", + 0, + )?; + let cursor = id_len + 4 + frame_len_len; + if payload.len() < cursor + 2 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF codec configuration OBU is truncated before audio roll distance" + .to_string(), + }); + } + let codec_payload = &payload[cursor + 2..]; + let (sample_rate, sample_size, channel_count_hint) = match codec_id { + fourcc if fourcc == FourCc::from_bytes(*b"Opus") => (48_000, 16, None), + fourcc if fourcc == FourCc::from_bytes(*b"fLaC") => { + parse_iamf_flac_config(codec_payload, spec)? + } + fourcc if fourcc == FourCc::from_bytes(*b"ipcm") => { + parse_iamf_lpcm_config(codec_payload, spec)? + } + fourcc if fourcc == FourCc::from_bytes(*b"mp4a") => { + parse_iamf_aac_config(codec_payload, spec)? + } + _ => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "IAMF codec configuration used unsupported codec id `{}`", + codec_id + ), + }); + } + }; + let num_samples_per_frame = u32::try_from(num_samples_per_frame) + .map_err(|_| MuxError::LayoutOverflow("IAMF samples per frame"))?; + if num_samples_per_frame == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF codec configuration declared a zero samples-per-frame value".to_string(), + }); + } + Ok(ParsedCodecConfig { + codec_id, + num_samples_per_frame, + sample_rate, + sample_size, + channel_count_hint, + }) +} + +fn parse_iamf_audio_element_payload(payload: &[u8], spec: &str) -> Result { + let (_, id_len) = read_leb128_from_slice(payload, spec, "IAMF audio_element_id", 0)?; + if payload.len() < id_len + 2 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF audio-element OBU is truncated".to_string(), + }); + } + let cursor = id_len + 1; + let (_, codec_config_len) = read_leb128_from_slice( + &payload[cursor..], + spec, + "IAMF audio-element codec_config_id", + 0, + )?; + let next = cursor + codec_config_len; + let (num_substreams, _) = read_leb128_from_slice( + &payload[next..], + spec, + "IAMF audio-element num_substreams", + 0, + )?; + let substreams = u16::try_from(num_substreams) + .map_err(|_| MuxError::LayoutOverflow("IAMF audio substreams"))?; + if substreams == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF audio-element OBU declared zero substreams".to_string(), + }); + } + Ok(substreams) +} + +fn parse_iamf_flac_config(payload: &[u8], spec: &str) -> Result<(u32, u16, Option), MuxError> { + if payload.len() < 18 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF FLAC codec configuration is truncated".to_string(), + }); + } + let sample_rate = (u32::from(payload[10]) << 12) + | (u32::from(payload[11]) << 4) + | u32::from(payload[12] >> 4); + let channel_count = u16::from(((payload[12] >> 1) & 0x07) + 1); + let sample_size = u16::from((((payload[12] & 0x01) << 4) | (payload[13] >> 4)) + 1); + if sample_rate == 0 || sample_size == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF FLAC codec configuration declared a zero-valued audio parameter" + .to_string(), + }); + } + Ok((sample_rate, sample_size, Some(channel_count))) +} + +fn parse_iamf_lpcm_config(payload: &[u8], spec: &str) -> Result<(u32, u16, Option), MuxError> { + if payload.len() < 6 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF LPCM codec configuration is truncated".to_string(), + }); + } + let sample_size = u16::from(payload[1]); + let sample_rate = u32::from_be_bytes(payload[2..6].try_into().unwrap()); + if sample_size == 0 || sample_rate == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IAMF LPCM codec configuration declared a zero-valued audio parameter" + .to_string(), + }); + } + Ok((sample_rate, sample_size, None)) +} + +fn parse_iamf_aac_config(payload: &[u8], spec: &str) -> Result<(u32, u16, Option), MuxError> { + let mut cursor = BitCursor::new(payload); + let audio_object_type = cursor.read_bits(5, spec, "IAMF AAC audio object type")?; + let audio_object_type = if audio_object_type == 31 { + 32 + cursor.read_bits(6, spec, "IAMF AAC extended audio object type")? + } else { + audio_object_type + }; + let sample_rate_index = cursor.read_bits(4, spec, "IAMF AAC sample rate index")?; + let sample_rate = if sample_rate_index == 0x0F { + cursor.read_bits(24, spec, "IAMF AAC explicit sample rate")? + } else { + AAC_SAMPLE_RATE_TABLE + .get(usize::try_from(sample_rate_index).unwrap()) + .copied() + .unwrap_or(0) + }; + let channel_configuration = cursor.read_bits(4, spec, "IAMF AAC channel configuration")?; + if audio_object_type == 0 || sample_rate == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "IAMF AAC codec configuration declared an unsupported object type or sample rate" + .to_string(), + }); + } + Ok((sample_rate, 16, u16::try_from(channel_configuration).ok())) +} + +fn read_iamf_payload_sync( + file: &mut File, + offset: u64, + header: &IamfObuHeader, + spec: &str, +) -> Result, MuxError> { + let payload_size = usize::try_from(header.total_size - header.header_size) + .map_err(|_| MuxError::LayoutOverflow("IAMF payload size"))?; + let mut payload = vec![0u8; payload_size]; + read_exact_at_sync( + file, + offset + header.header_size, + &mut payload, + spec, + "IAMF OBU payload is truncated", + )?; + Ok(payload) +} + +#[cfg(feature = "async")] +async fn read_iamf_payload_async( + file: &mut TokioFile, + offset: u64, + header: &IamfObuHeader, + spec: &str, +) -> Result, MuxError> { + let payload_size = usize::try_from(header.total_size - header.header_size) + .map_err(|_| MuxError::LayoutOverflow("IAMF payload size"))?; + let mut payload = vec![0u8; payload_size]; + read_exact_at_async( + file, + offset + header.header_size, + &mut payload, + spec, + "IAMF OBU payload is truncated", + ) + .await?; + Ok(payload) +} + +fn read_obu_bytes_sync( + file: &mut File, + offset: u64, + size: u64, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + let size = usize::try_from(size).map_err(|_| MuxError::LayoutOverflow("IAMF OBU size"))?; + let mut bytes = vec![0u8; size]; + read_exact_at_sync(file, offset, &mut bytes, spec, truncated_message)?; + Ok(bytes) +} + +#[cfg(feature = "async")] +async fn read_obu_bytes_async( + file: &mut TokioFile, + offset: u64, + size: u64, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + let size = usize::try_from(size).map_err(|_| MuxError::LayoutOverflow("IAMF OBU size"))?; + let mut bytes = vec![0u8; size]; + read_exact_at_async(file, offset, &mut bytes, spec, truncated_message).await?; + Ok(bytes) +} + +fn is_iamf_temporal_unit_obu(obu_type: u8) -> bool { + matches!( + obu_type, + OBU_IAMF_PARAMETER_BLOCK | OBU_IAMF_TEMPORAL_DELIMITER | OBU_IAMF_AUDIO_FRAME + ) +} + +fn read_leb128_from_slice( + bytes: &[u8], + spec: &str, + field_name: &str, + offset: u64, +) -> Result<(u64, usize), MuxError> { + let mut value = 0u64; + let mut shift = 0u32; + for (index, byte) in bytes.iter().copied().enumerate() { + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Ok((value, index + 1)); + } + shift += 7; + if shift >= 63 { + break; + } + } + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{field_name} at byte offset {offset} used an unterminated or unsupported leb128 value" + ), + }) +} + +struct BitCursor<'a> { + data: &'a [u8], + bit_offset: usize, +} + +impl<'a> BitCursor<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + bit_offset: 0, + } + } + + fn read_bits(&mut self, width: usize, spec: &str, label: &str) -> Result { + let end = self + .bit_offset + .checked_add(width) + .ok_or(MuxError::LayoutOverflow("IAMF bit reader position"))?; + if end > self.data.len() * 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated IAMF payload while reading {label}"), + }); + } + let mut value = 0u32; + for _ in 0..width { + let byte = self.data[self.bit_offset / 8]; + let shift = 7 - (self.bit_offset % 8); + value = (value << 1) | u32::from((byte >> shift) & 0x01); + self.bit_offset += 1; + } + Ok(value) + } +} diff --git a/src/mux/demux/ivf_common.rs b/src/mux/demux/ivf_common.rs new file mode 100644 index 0000000..952793d --- /dev/null +++ b/src/mux/demux/ivf_common.rs @@ -0,0 +1,383 @@ +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +use super::super::import::StagedSample; +use super::super::{MuxError, MuxRawCodec}; + +pub(in crate::mux) struct ParsedIvfTrack { + pub(in crate::mux) width: u16, + pub(in crate::mux) height: u16, + pub(in crate::mux) timescale: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +#[derive(Clone, Copy)] +struct ParsedIvfHeader { + width: u16, + height: u16, + timescale: u32, + timestamp_scale: u32, +} + +#[derive(Clone, Copy)] +pub(super) struct IndexedIvfSample { + pub(super) data_offset: u64, + pub(super) data_size: u32, + timestamp: u64, +} + +pub(super) struct IndexedIvfTrack { + pub(super) width: u16, + pub(super) height: u16, + pub(super) timescale: u32, + pub(super) first_sample_span: IndexedIvfSample, + pub(super) samples: Vec, +} + +pub(super) fn read_indexed_sample_sync( + path: &Path, + sample: IndexedIvfSample, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + let mut file = File::open(path)?; + file.seek(SeekFrom::Start(sample.data_offset))?; + let mut bytes = vec![ + 0_u8; + usize::try_from(sample.data_size) + .map_err(|_| MuxError::LayoutOverflow("IVF sample size"))? + ]; + file.read_exact(&mut bytes) + .map_err(|error| map_truncated_ivf_error(error, spec, truncated_message))?; + Ok(bytes) +} + +#[cfg(feature = "async")] +pub(super) async fn read_indexed_sample_async( + path: &Path, + sample: IndexedIvfSample, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + let mut file = TokioFile::open(path).await?; + file.seek(SeekFrom::Start(sample.data_offset)).await?; + let mut bytes = vec![ + 0_u8; + usize::try_from(sample.data_size) + .map_err(|_| MuxError::LayoutOverflow("IVF sample size"))? + ]; + file.read_exact(&mut bytes) + .await + .map_err(|error| map_truncated_ivf_error(error, spec, truncated_message))?; + Ok(bytes) +} + +pub(super) fn scan_ivf_video_file_sync( + path: &Path, + codec: MuxRawCodec, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + if file_size < 32 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IVF input is truncated before the 32-byte file header".to_string(), + }); + } + + let mut header = [0_u8; 32]; + file.read_exact(&mut header).map_err(|error| { + map_truncated_ivf_error( + error, + spec, + "IVF input is truncated before the 32-byte file header", + ) + })?; + let parsed_header = parse_ivf_video_header(&header, codec, spec)?; + + let mut offset = 32_u64; + let mut indexed_samples = Vec::new(); + while offset < file_size { + if file_size - offset < 12 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "IVF frame header at byte offset {offset} is truncated before 12 bytes" + ), + }); + } + let mut frame_header = [0_u8; 12]; + file.read_exact(&mut frame_header).map_err(|error| { + map_truncated_ivf_error(error, spec, "IVF input ended while reading a frame header") + })?; + let frame_size = u32::from_le_bytes(frame_header[..4].try_into().unwrap()); + let timestamp = u64::from_le_bytes(frame_header[4..12].try_into().unwrap()); + let data_offset = offset + 12; + let frame_end = data_offset + .checked_add(u64::from(frame_size)) + .ok_or(MuxError::LayoutOverflow("IVF frame range"))?; + if frame_end > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("IVF frame at byte offset {offset} overruns the input length"), + }); + } + indexed_samples.push(IndexedIvfSample { + data_offset, + data_size: frame_size, + timestamp, + }); + file.seek(SeekFrom::Current(i64::from(frame_size)))?; + offset = frame_end; + } + if indexed_samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IVF input contained no frame payloads".to_string(), + }); + } + + Ok(IndexedIvfTrack { + width: parsed_header.width, + height: parsed_header.height, + timescale: parsed_header.timescale, + first_sample_span: indexed_samples[0], + samples: build_ivf_staged_samples(&indexed_samples, parsed_header.timestamp_scale, spec)?, + }) +} + +#[cfg(feature = "async")] +pub(super) async fn scan_ivf_video_file_async( + path: &Path, + codec: MuxRawCodec, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + if file_size < 32 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IVF input is truncated before the 32-byte file header".to_string(), + }); + } + + let mut header = [0_u8; 32]; + file.read_exact(&mut header).await.map_err(|error| { + map_truncated_ivf_error( + error, + spec, + "IVF input is truncated before the 32-byte file header", + ) + })?; + let parsed_header = parse_ivf_video_header(&header, codec, spec)?; + + let mut offset = 32_u64; + let mut indexed_samples = Vec::new(); + while offset < file_size { + if file_size - offset < 12 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "IVF frame header at byte offset {offset} is truncated before 12 bytes" + ), + }); + } + let mut frame_header = [0_u8; 12]; + file.read_exact(&mut frame_header).await.map_err(|error| { + map_truncated_ivf_error(error, spec, "IVF input ended while reading a frame header") + })?; + let frame_size = u32::from_le_bytes(frame_header[..4].try_into().unwrap()); + let timestamp = u64::from_le_bytes(frame_header[4..12].try_into().unwrap()); + let data_offset = offset + 12; + let frame_end = data_offset + .checked_add(u64::from(frame_size)) + .ok_or(MuxError::LayoutOverflow("IVF frame range"))?; + if frame_end > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("IVF frame at byte offset {offset} overruns the input length"), + }); + } + indexed_samples.push(IndexedIvfSample { + data_offset, + data_size: frame_size, + timestamp, + }); + file.seek(SeekFrom::Current(i64::from(frame_size))).await?; + offset = frame_end; + } + if indexed_samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IVF input contained no frame payloads".to_string(), + }); + } + + Ok(IndexedIvfTrack { + width: parsed_header.width, + height: parsed_header.height, + timescale: parsed_header.timescale, + first_sample_span: indexed_samples[0], + samples: build_ivf_staged_samples(&indexed_samples, parsed_header.timestamp_scale, spec)?, + }) +} + +fn parse_ivf_video_header( + header: &[u8; 32], + expected_codec: MuxRawCodec, + spec: &str, +) -> Result { + if &header[..4] != b"DKIF" { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IVF input did not start with the `DKIF` signature".to_string(), + }); + } + let version = u16::from_le_bytes(header[4..6].try_into().unwrap()); + if version != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("IVF input used unsupported version {version}; expected 0"), + }); + } + let header_size = u16::from_le_bytes(header[6..8].try_into().unwrap()); + if header_size != 32 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "IVF input declared unsupported header size {header_size}; expected 32" + ), + }); + } + let codec = + ivf_codec_from_fourcc_bytes(header[8..12].try_into().unwrap()).ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "IVF input used unsupported codec tag `{}`", + String::from_utf8_lossy(&header[8..12]) + ), + } + })?; + if codec != expected_codec { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "IVF input codec `{}` does not match requested raw `{}` import", + codec.prefix(), + expected_codec.prefix() + ), + }); + } + let width = u16::from_le_bytes(header[12..14].try_into().unwrap()); + let height = u16::from_le_bytes(header[14..16].try_into().unwrap()); + if width == 0 || height == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IVF input declared zero width or height".to_string(), + }); + } + let timescale = u32::from_le_bytes(header[16..20].try_into().unwrap()); + let timestamp_scale = u32::from_le_bytes(header[20..24].try_into().unwrap()); + if timescale == 0 || timestamp_scale == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "IVF input declared a zero timebase field".to_string(), + }); + } + Ok(ParsedIvfHeader { + width, + height, + timescale, + timestamp_scale, + }) +} + +fn ivf_codec_from_fourcc_bytes(fourcc: [u8; 4]) -> Option { + match &fourcc { + b"AV01" => Some(MuxRawCodec::Av1), + b"VP80" => Some(MuxRawCodec::Vp8), + b"VP90" => Some(MuxRawCodec::Vp9), + b"VP10" => Some(MuxRawCodec::Vp10), + _ => None, + } +} + +fn build_ivf_staged_samples( + indexed_samples: &[IndexedIvfSample], + timestamp_scale: u32, + spec: &str, +) -> Result, MuxError> { + if indexed_samples.len() == 1 { + let sample = indexed_samples[0]; + return Ok(vec![StagedSample { + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: 0, + composition_time_offset: 0, + is_sync_sample: true, + }]); + } + + let default_duration = timestamp_scale; + let mut previous_duration = default_duration; + let mut samples = Vec::with_capacity(indexed_samples.len()); + for (index, sample) in indexed_samples.iter().enumerate() { + let duration = if let Some(next) = indexed_samples.get(index + 1) { + if next.timestamp < sample.timestamp { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "IVF frame timestamps must be monotonic, but frame {index} regressed from {} to {}", + sample.timestamp, next.timestamp + ), + }); + } + let delta = next.timestamp - sample.timestamp; + if delta == 0 { + previous_duration + } else { + let scaled = delta + .checked_mul(u64::from(timestamp_scale)) + .ok_or(MuxError::LayoutOverflow("IVF sample duration"))?; + let duration = u32::try_from(scaled) + .map_err(|_| MuxError::LayoutOverflow("IVF sample duration"))?; + previous_duration = duration; + duration + } + } else { + previous_duration + }; + samples.push(StagedSample { + data_offset: sample.data_offset, + data_size: sample.data_size, + duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + Ok(samples) +} + +fn map_truncated_ivf_error( + error: std::io::Error, + spec: &str, + truncated_message: &'static str, +) -> MuxError { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: truncated_message.to_string(), + } + } else { + MuxError::Io(error) + } +} diff --git a/src/mux/demux/j2k.rs b/src/mux/demux/j2k.rs new file mode 100644 index 0000000..a6e5e54 --- /dev/null +++ b/src/mux/demux/j2k.rs @@ -0,0 +1,520 @@ +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +use crate::FourCc; + +use super::super::MuxError; +use super::super::import::StagedSample; +use super::raw_visual::build_mjp2_sample_entry_box; + +const JP_SIGNATURE: FourCc = FourCc::from_bytes(*b"jP "); +const JP2H: FourCc = FourCc::from_bytes(*b"jp2h"); +const IHDR: FourCc = FourCc::from_bytes(*b"ihdr"); +const JP2C: FourCc = FourCc::from_bytes(*b"jp2c"); + +pub(in crate::mux) struct ParsedJ2kTrack { + pub(in crate::mux) width: u16, + pub(in crate::mux) height: u16, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +pub(in crate::mux) fn scan_j2k_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + parse_j2k_file_sync(spec, &mut file, file_size) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_j2k_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + parse_j2k_file_async(spec, &mut file, file_size).await +} + +fn parse_j2k_file_sync( + spec: &str, + file: &mut File, + file_size: u64, +) -> Result { + let prefix = read_j2k_prefix_sync(file, file_size)?; + if prefix.len() >= 12 + && u32::from_be_bytes(prefix[0..4].try_into().unwrap()) == 12 + && prefix[4..8] == *JP_SIGNATURE.as_bytes() + && u32::from_be_bytes(prefix[8..12].try_into().unwrap()) == 0x0D0A_870A + { + return parse_jp2_file_sync(spec, file, file_size); + } + if prefix.len() >= 16 && u32::from_be_bytes(prefix[0..4].try_into().unwrap()) == 0xFF4F_FF51 { + return parse_j2k_codestream_prefix(spec, &prefix, file_size); + } + Err(invalid_j2k( + spec, + "input is neither a JP2 image file nor a raw JPEG 2000 codestream", + )) +} + +#[cfg(feature = "async")] +async fn parse_j2k_file_async( + spec: &str, + file: &mut TokioFile, + file_size: u64, +) -> Result { + let prefix = read_j2k_prefix_async(file, file_size).await?; + if prefix.len() >= 12 + && u32::from_be_bytes(prefix[0..4].try_into().unwrap()) == 12 + && prefix[4..8] == *JP_SIGNATURE.as_bytes() + && u32::from_be_bytes(prefix[8..12].try_into().unwrap()) == 0x0D0A_870A + { + return parse_jp2_file_async(spec, file, file_size).await; + } + if prefix.len() >= 16 && u32::from_be_bytes(prefix[0..4].try_into().unwrap()) == 0xFF4F_FF51 { + return parse_j2k_codestream_prefix(spec, &prefix, file_size); + } + Err(invalid_j2k( + spec, + "input is neither a JP2 image file nor a raw JPEG 2000 codestream", + )) +} + +fn parse_jp2_file_sync( + spec: &str, + file: &mut File, + file_size: u64, +) -> Result { + let mut offset = 0_u64; + let mut width = None::; + let mut height = None::; + let mut jp2h_payload = None::>; + let mut jp2c_offset = None::; + while offset < file_size { + let (box_type, data_offset, end_offset) = + read_be_box_range_sync(file, file_size, offset, spec)?; + match box_type { + JP2H => { + let payload_len = usize::try_from(end_offset - data_offset) + .map_err(|_| MuxError::LayoutOverflow("JP2H payload size"))?; + let mut payload = vec![0_u8; payload_len]; + file.seek(SeekFrom::Start(data_offset))?; + file.read_exact(&mut payload)?; + let (parsed_width, parsed_height) = parse_jp2h_dimensions(spec, &payload)?; + width = Some(parsed_width); + height = Some(parsed_height); + jp2h_payload = Some(payload); + } + JP2C => { + jp2c_offset = Some(offset); + break; + } + _ => {} + } + offset = end_offset; + } + + let width = width + .ok_or_else(|| invalid_j2k(spec, "JP2 input did not carry a jp2h/ihdr image header"))?; + let height = height.ok_or_else(|| { + invalid_j2k( + spec, + "JP2 input did not expose image dimensions before codestream data", + ) + })?; + let jp2h_payload = jp2h_payload.ok_or_else(|| { + invalid_j2k( + spec, + "JP2 input did not carry a jp2h decoder-configuration box", + ) + })?; + let jp2c_offset = jp2c_offset + .ok_or_else(|| invalid_j2k(spec, "JP2 input did not carry a jp2c codestream box"))?; + let width_u16 = u16::try_from(width) + .map_err(|_| invalid_j2k(spec, "JP2 width does not fit in an MP4 visual sample entry"))?; + let height_u16 = u16::try_from(height).map_err(|_| { + invalid_j2k( + spec, + "JP2 height does not fit in an MP4 visual sample entry", + ) + })?; + let sample_size = u32::try_from(file_size - jp2c_offset).map_err(|_| { + MuxError::LayoutOverflow("JP2 codestream payload exceeds MP4 sample limits") + })?; + let sample_entry_box = + build_mjp2_sample_entry_box(width_u16, height_u16, b"", Some(&jp2h_payload))?; + Ok(ParsedJ2kTrack { + width: width_u16, + height: height_u16, + sample_entry_box, + samples: vec![StagedSample { + data_offset: jp2c_offset, + data_size: sample_size, + duration: 1_000, + composition_time_offset: 0, + is_sync_sample: true, + }], + }) +} + +#[cfg(feature = "async")] +async fn parse_jp2_file_async( + spec: &str, + file: &mut TokioFile, + file_size: u64, +) -> Result { + let mut offset = 0_u64; + let mut width = None::; + let mut height = None::; + let mut jp2h_payload = None::>; + let mut jp2c_offset = None::; + while offset < file_size { + let (box_type, data_offset, end_offset) = + read_be_box_range_async(file, file_size, offset, spec).await?; + match box_type { + JP2H => { + let payload_len = usize::try_from(end_offset - data_offset) + .map_err(|_| MuxError::LayoutOverflow("JP2H payload size"))?; + let mut payload = vec![0_u8; payload_len]; + file.seek(SeekFrom::Start(data_offset)).await?; + file.read_exact(&mut payload).await?; + let (parsed_width, parsed_height) = parse_jp2h_dimensions(spec, &payload)?; + width = Some(parsed_width); + height = Some(parsed_height); + jp2h_payload = Some(payload); + } + JP2C => { + jp2c_offset = Some(offset); + break; + } + _ => {} + } + offset = end_offset; + } + + let width = width + .ok_or_else(|| invalid_j2k(spec, "JP2 input did not carry a jp2h/ihdr image header"))?; + let height = height.ok_or_else(|| { + invalid_j2k( + spec, + "JP2 input did not expose image dimensions before codestream data", + ) + })?; + let jp2h_payload = jp2h_payload.ok_or_else(|| { + invalid_j2k( + spec, + "JP2 input did not carry a jp2h decoder-configuration box", + ) + })?; + let jp2c_offset = jp2c_offset + .ok_or_else(|| invalid_j2k(spec, "JP2 input did not carry a jp2c codestream box"))?; + let width_u16 = u16::try_from(width) + .map_err(|_| invalid_j2k(spec, "JP2 width does not fit in an MP4 visual sample entry"))?; + let height_u16 = u16::try_from(height).map_err(|_| { + invalid_j2k( + spec, + "JP2 height does not fit in an MP4 visual sample entry", + ) + })?; + let sample_size = u32::try_from(file_size - jp2c_offset).map_err(|_| { + MuxError::LayoutOverflow("JP2 codestream payload exceeds MP4 sample limits") + })?; + let sample_entry_box = + build_mjp2_sample_entry_box(width_u16, height_u16, b"", Some(&jp2h_payload))?; + Ok(ParsedJ2kTrack { + width: width_u16, + height: height_u16, + sample_entry_box, + samples: vec![StagedSample { + data_offset: jp2c_offset, + data_size: sample_size, + duration: 1_000, + composition_time_offset: 0, + is_sync_sample: true, + }], + }) +} + +fn parse_jp2h_dimensions(spec: &str, payload: &[u8]) -> Result<(u32, u32), MuxError> { + let mut offset = 0_u64; + let payload_size = + u64::try_from(payload.len()).map_err(|_| MuxError::LayoutOverflow("JP2H byte length"))?; + while offset < payload_size { + let (box_type, data_offset, end_offset) = read_be_box_range_bytes(payload, offset, spec)?; + if box_type == IHDR { + if end_offset - data_offset < 14 { + return Err(invalid_j2k(spec, "JP2 ihdr payload is truncated")); + } + let data_offset_usize = usize::try_from(data_offset) + .map_err(|_| MuxError::LayoutOverflow("JP2 ihdr offset"))?; + let height = u32::from_be_bytes( + payload[data_offset_usize..data_offset_usize + 4] + .try_into() + .unwrap(), + ); + let width = u32::from_be_bytes( + payload[data_offset_usize + 4..data_offset_usize + 8] + .try_into() + .unwrap(), + ); + if width == 0 || height == 0 { + return Err(invalid_j2k( + spec, + "JP2 ihdr declared zero width or zero height", + )); + } + return Ok((width, height)); + } + offset = end_offset; + } + Err(invalid_j2k( + spec, + "JP2 input did not carry an ihdr image header inside jp2h", + )) +} + +fn parse_j2k_codestream_prefix( + spec: &str, + prefix: &[u8], + file_size: u64, +) -> Result { + if prefix.len() < 16 { + return Err(invalid_j2k( + spec, + "JPEG 2000 codestream is truncated before the SIZ image dimensions", + )); + } + let width = u32::from_be_bytes(prefix[8..12].try_into().unwrap()); + let height = u32::from_be_bytes(prefix[12..16].try_into().unwrap()); + if width == 0 || height == 0 { + return Err(invalid_j2k( + spec, + "JPEG 2000 codestream declared zero width or zero height", + )); + } + let width_u16 = u16::try_from(width).map_err(|_| { + invalid_j2k( + spec, + "JPEG 2000 codestream width does not fit in an MP4 visual sample entry", + ) + })?; + let height_u16 = u16::try_from(height).map_err(|_| { + invalid_j2k( + spec, + "JPEG 2000 codestream height does not fit in an MP4 visual sample entry", + ) + })?; + let sample_entry_box = build_mjp2_sample_entry_box(width_u16, height_u16, b"", None)?; + let data_size = u32::try_from(file_size) + .map_err(|_| MuxError::LayoutOverflow("JPEG 2000 codestream exceeds MP4 sample limits"))?; + Ok(ParsedJ2kTrack { + width: width_u16, + height: height_u16, + sample_entry_box, + samples: vec![StagedSample { + data_offset: 0, + data_size, + duration: 1_000, + composition_time_offset: 0, + is_sync_sample: true, + }], + }) +} + +fn read_be_box_range_bytes( + bytes: &[u8], + offset: u64, + spec: &str, +) -> Result<(FourCc, u64, u64), MuxError> { + let offset_usize = + usize::try_from(offset).map_err(|_| MuxError::LayoutOverflow("JPEG 2000 box offset"))?; + if bytes.len().saturating_sub(offset_usize) < 8 { + return Err(invalid_j2k(spec, "JPEG 2000 box header is truncated")); + } + let size32 = u32::from_be_bytes(bytes[offset_usize..offset_usize + 4].try_into().unwrap()); + let box_type = FourCc::from_bytes( + bytes[offset_usize + 4..offset_usize + 8] + .try_into() + .unwrap(), + ); + let (header_size, end_offset) = if size32 == 1 { + if bytes.len().saturating_sub(offset_usize) < 16 { + return Err(invalid_j2k( + spec, + "JPEG 2000 extended-size box header is truncated", + )); + } + let size64 = u64::from_be_bytes( + bytes[offset_usize + 8..offset_usize + 16] + .try_into() + .unwrap(), + ); + if size64 < 16 { + return Err(invalid_j2k( + spec, + &format!("JPEG 2000 box `{box_type}` declared an invalid extended size"), + )); + } + (16_u64, offset + size64) + } else if size32 == 0 { + ( + 8_u64, + u64::try_from(bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("JPEG 2000 byte length"))?, + ) + } else { + if size32 < 8 { + return Err(invalid_j2k( + spec, + &format!("JPEG 2000 box `{box_type}` declared a size smaller than its header"), + )); + } + (8_u64, offset + u64::from(size32)) + }; + if end_offset + > u64::try_from(bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("JPEG 2000 byte length"))? + { + return Err(invalid_j2k( + spec, + &format!("JPEG 2000 box `{box_type}` overruns the input length"), + )); + } + Ok((box_type, offset + header_size, end_offset)) +} + +fn read_be_box_range_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, +) -> Result<(FourCc, u64, u64), MuxError> { + if file_size.saturating_sub(offset) < 8 { + return Err(invalid_j2k(spec, "JPEG 2000 box header is truncated")); + } + let mut header = [0_u8; 16]; + file.seek(SeekFrom::Start(offset))?; + file.read_exact(&mut header[..8])?; + let size32 = u32::from_be_bytes(header[..4].try_into().unwrap()); + let box_type = FourCc::from_bytes(header[4..8].try_into().unwrap()); + let (header_size, end_offset) = if size32 == 1 { + if file_size.saturating_sub(offset) < 16 { + return Err(invalid_j2k( + spec, + "JPEG 2000 extended-size box header is truncated", + )); + } + file.read_exact(&mut header[8..16])?; + let size64 = u64::from_be_bytes(header[8..16].try_into().unwrap()); + if size64 < 16 { + return Err(invalid_j2k( + spec, + &format!("JPEG 2000 box `{box_type}` declared an invalid extended size"), + )); + } + (16_u64, offset + size64) + } else if size32 == 0 { + (8_u64, file_size) + } else { + if size32 < 8 { + return Err(invalid_j2k( + spec, + &format!("JPEG 2000 box `{box_type}` declared a size smaller than its header"), + )); + } + (8_u64, offset + u64::from(size32)) + }; + if end_offset > file_size { + return Err(invalid_j2k( + spec, + &format!("JPEG 2000 box `{box_type}` overruns the input length"), + )); + } + Ok((box_type, offset + header_size, end_offset)) +} + +#[cfg(feature = "async")] +async fn read_be_box_range_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, +) -> Result<(FourCc, u64, u64), MuxError> { + if file_size.saturating_sub(offset) < 8 { + return Err(invalid_j2k(spec, "JPEG 2000 box header is truncated")); + } + let mut header = [0_u8; 16]; + file.seek(SeekFrom::Start(offset)).await?; + file.read_exact(&mut header[..8]).await?; + let size32 = u32::from_be_bytes(header[..4].try_into().unwrap()); + let box_type = FourCc::from_bytes(header[4..8].try_into().unwrap()); + let (header_size, end_offset) = if size32 == 1 { + if file_size.saturating_sub(offset) < 16 { + return Err(invalid_j2k( + spec, + "JPEG 2000 extended-size box header is truncated", + )); + } + file.read_exact(&mut header[8..16]).await?; + let size64 = u64::from_be_bytes(header[8..16].try_into().unwrap()); + if size64 < 16 { + return Err(invalid_j2k( + spec, + &format!("JPEG 2000 box `{box_type}` declared an invalid extended size"), + )); + } + (16_u64, offset + size64) + } else if size32 == 0 { + (8_u64, file_size) + } else { + if size32 < 8 { + return Err(invalid_j2k( + spec, + &format!("JPEG 2000 box `{box_type}` declared a size smaller than its header"), + )); + } + (8_u64, offset + u64::from(size32)) + }; + if end_offset > file_size { + return Err(invalid_j2k( + spec, + &format!("JPEG 2000 box `{box_type}` overruns the input length"), + )); + } + Ok((box_type, offset + header_size, end_offset)) +} + +fn read_j2k_prefix_sync(file: &mut File, file_size: u64) -> Result, MuxError> { + let prefix_len = usize::try_from(file_size.min(16)) + .map_err(|_| MuxError::LayoutOverflow("JPEG 2000 prefix length"))?; + let mut prefix = vec![0_u8; prefix_len]; + file.seek(SeekFrom::Start(0))?; + file.read_exact(&mut prefix)?; + Ok(prefix) +} + +#[cfg(feature = "async")] +async fn read_j2k_prefix_async(file: &mut TokioFile, file_size: u64) -> Result, MuxError> { + let prefix_len = usize::try_from(file_size.min(16)) + .map_err(|_| MuxError::LayoutOverflow("JPEG 2000 prefix length"))?; + let mut prefix = vec![0_u8; prefix_len]; + file.seek(SeekFrom::Start(0)).await?; + file.read_exact(&mut prefix).await?; + Ok(prefix) +} + +fn invalid_j2k(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} diff --git a/src/mux/demux/jpeg.rs b/src/mux/demux/jpeg.rs new file mode 100644 index 0000000..224eebb --- /dev/null +++ b/src/mux/demux/jpeg.rs @@ -0,0 +1,892 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::iso14496_12::{SampleEntry, VisualSampleEntry}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::read_exact_at_sync; + +const JPEG_ENTRY: FourCc = FourCc::from_bytes(*b"jpeg"); +const AVI_JPEG_ENTRY: FourCc = FourCc::from_bytes(*b"MJPG"); +const JPEG_SOI: [u8; 2] = [0xFF, 0xD8]; +const JPEG_MARKER_SOI: u8 = 0xD8; +const JPEG_MARKER_EOI: u8 = 0xD9; +const JPEG_MARKER_SOS: u8 = 0xDA; +const JPEG_MARKER_TEM: u8 = 0x01; + +pub(in crate::mux) struct ParsedJpegTrack { + pub(in crate::mux) width: u16, + pub(in crate::mux) height: u16, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) data_size: u32, +} + +pub(in crate::mux) fn scan_jpeg_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + parse_jpeg_stream_sync(&mut file, file_size, spec) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_jpeg_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + parse_jpeg_stream_async(&mut file, file_size, spec).await +} + +pub(in crate::mux) fn parse_jpeg_bytes( + spec: &str, + bytes: &[u8], +) -> Result { + let file_size = + u64::try_from(bytes.len()).map_err(|_| MuxError::LayoutOverflow("JPEG bytes length"))?; + if bytes.len() < 4 { + return Err(invalid_jpeg( + spec, + "JPEG input is truncated before the first marker header", + )); + } + let mut prefix = [0_u8; 2]; + prefix.copy_from_slice(&bytes[..2]); + validate_jpeg_prefix_bytes(&prefix, spec)?; + + let mut offset = 2_u64; + let mut width = None::; + let mut height = None::; + let mut saw_sof = false; + let mut saw_sos = false; + let mut saw_eoi = false; + while offset < file_size { + let (marker, marker_offset) = read_next_marker_bytes(bytes, offset, spec)?; + match marker { + JPEG_MARKER_SOI => { + return Err(invalid_jpeg( + spec, + "JPEG input carried an unexpected embedded SOI marker", + )); + } + JPEG_MARKER_EOI => { + saw_eoi = true; + if marker_offset + 2 != file_size { + return Err(invalid_jpeg( + spec, + "JPEG input carried trailing bytes after the EOI marker", + )); + } + break; + } + JPEG_MARKER_SOS => { + let (_, data_size, next_offset) = + read_segment_bounds_bytes(bytes, marker_offset, spec)?; + if data_size < 6 { + return Err(invalid_jpeg(spec, "JPEG SOS segment is too short")); + } + saw_sos = true; + let (_, next_marker_offset) = + scan_entropy_coded_data_bytes(bytes, next_offset, spec)?; + offset = next_marker_offset; + } + marker if marker_has_standalone_layout(marker) => { + offset = marker_offset + 2; + } + marker => { + let (data_offset, data_size, next_offset) = + read_segment_bounds_bytes(bytes, marker_offset, spec)?; + if is_sof_marker(marker) { + if saw_sof { + return Err(invalid_jpeg( + spec, + "JPEG input carried more than one frame header marker", + )); + } + let data_offset_usize = usize::try_from(data_offset) + .map_err(|_| MuxError::LayoutOverflow("JPEG SOF data offset"))?; + if data_offset_usize + 6 > bytes.len() { + return Err(invalid_jpeg(spec, "JPEG frame header is truncated")); + } + let mut header = [0_u8; 6]; + header.copy_from_slice(&bytes[data_offset_usize..data_offset_usize + 6]); + let (parsed_width, parsed_height) = + decode_sof_dimensions(header, data_size, spec)?; + width = Some(parsed_width); + height = Some(parsed_height); + saw_sof = true; + } + offset = next_offset; + } + } + } + + finalize_jpeg_track(spec, file_size, width, height, saw_sof, saw_sos, saw_eoi) +} + +fn parse_jpeg_stream_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result { + validate_jpeg_prefix_sync(file, file_size, spec)?; + parse_jpeg_markers_sync(file, file_size, spec) +} + +#[cfg(feature = "async")] +async fn parse_jpeg_stream_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result { + validate_jpeg_prefix_async(file, file_size, spec).await?; + parse_jpeg_markers_async(file, file_size, spec).await +} + +fn validate_jpeg_prefix_sync(file: &mut File, file_size: u64, spec: &str) -> Result<(), MuxError> { + if file_size < 4 { + return Err(invalid_jpeg( + spec, + "JPEG input is truncated before the first marker header", + )); + } + let mut prefix = [0_u8; 2]; + read_exact_at_sync( + file, + 0, + &mut prefix, + spec, + "JPEG input is truncated before the SOI marker", + )?; + validate_jpeg_prefix_bytes(&prefix, spec) +} + +#[cfg(feature = "async")] +async fn validate_jpeg_prefix_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result<(), MuxError> { + if file_size < 4 { + return Err(invalid_jpeg( + spec, + "JPEG input is truncated before the first marker header", + )); + } + let mut prefix = [0_u8; 2]; + read_exact_at_async( + file, + 0, + &mut prefix, + spec, + "JPEG input is truncated before the SOI marker", + ) + .await?; + validate_jpeg_prefix_bytes(&prefix, spec) +} + +fn validate_jpeg_prefix_bytes(prefix: &[u8; 2], spec: &str) -> Result<(), MuxError> { + if *prefix != JPEG_SOI { + return Err(invalid_jpeg( + spec, + "input does not begin with the JPEG SOI marker", + )); + } + Ok(()) +} + +fn parse_jpeg_markers_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result { + let mut offset = 2_u64; + let mut width = None::; + let mut height = None::; + let mut saw_sof = false; + let mut saw_sos = false; + let mut saw_eoi = false; + while offset < file_size { + let (marker, marker_offset) = read_next_marker_sync(file, file_size, offset, spec)?; + match marker { + JPEG_MARKER_SOI => { + return Err(invalid_jpeg( + spec, + "JPEG input carried an unexpected embedded SOI marker", + )); + } + JPEG_MARKER_EOI => { + saw_eoi = true; + if marker_offset + 2 != file_size { + return Err(invalid_jpeg( + spec, + "JPEG input carried trailing bytes after the EOI marker", + )); + } + break; + } + JPEG_MARKER_SOS => { + let (_, data_size, next_offset) = + read_segment_bounds_sync(file, file_size, marker_offset, spec)?; + if data_size < 6 { + return Err(invalid_jpeg(spec, "JPEG SOS segment is too short")); + } + saw_sos = true; + let (_, next_marker_offset) = + scan_entropy_coded_data_sync(file, file_size, next_offset, spec)?; + offset = next_marker_offset; + } + marker if marker_has_standalone_layout(marker) => { + offset = marker_offset + 2; + } + marker => { + let (data_offset, data_size, next_offset) = + read_segment_bounds_sync(file, file_size, marker_offset, spec)?; + if is_sof_marker(marker) { + if saw_sof { + return Err(invalid_jpeg( + spec, + "JPEG input carried more than one frame header marker", + )); + } + let (parsed_width, parsed_height) = + parse_sof_dimensions_sync(file, data_offset, data_size, spec)?; + width = Some(parsed_width); + height = Some(parsed_height); + saw_sof = true; + } + offset = next_offset; + } + } + } + finalize_jpeg_track(spec, file_size, width, height, saw_sof, saw_sos, saw_eoi) +} + +#[cfg(feature = "async")] +async fn parse_jpeg_markers_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result { + let mut offset = 2_u64; + let mut width = None::; + let mut height = None::; + let mut saw_sof = false; + let mut saw_sos = false; + let mut saw_eoi = false; + while offset < file_size { + let (marker, marker_offset) = read_next_marker_async(file, file_size, offset, spec).await?; + match marker { + JPEG_MARKER_SOI => { + return Err(invalid_jpeg( + spec, + "JPEG input carried an unexpected embedded SOI marker", + )); + } + JPEG_MARKER_EOI => { + saw_eoi = true; + if marker_offset + 2 != file_size { + return Err(invalid_jpeg( + spec, + "JPEG input carried trailing bytes after the EOI marker", + )); + } + break; + } + JPEG_MARKER_SOS => { + let (_, data_size, next_offset) = + read_segment_bounds_async(file, file_size, marker_offset, spec).await?; + if data_size < 6 { + return Err(invalid_jpeg(spec, "JPEG SOS segment is too short")); + } + saw_sos = true; + let (_, next_marker_offset) = + scan_entropy_coded_data_async(file, file_size, next_offset, spec).await?; + offset = next_marker_offset; + } + marker if marker_has_standalone_layout(marker) => { + offset = marker_offset + 2; + } + marker => { + let (data_offset, data_size, next_offset) = + read_segment_bounds_async(file, file_size, marker_offset, spec).await?; + if is_sof_marker(marker) { + if saw_sof { + return Err(invalid_jpeg( + spec, + "JPEG input carried more than one frame header marker", + )); + } + let (parsed_width, parsed_height) = + parse_sof_dimensions_async(file, data_offset, data_size, spec).await?; + width = Some(parsed_width); + height = Some(parsed_height); + saw_sof = true; + } + offset = next_offset; + } + } + } + finalize_jpeg_track(spec, file_size, width, height, saw_sof, saw_sos, saw_eoi) +} + +fn read_next_marker_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, +) -> Result<(u8, u64), MuxError> { + let mut cursor = offset; + if cursor + 2 > file_size { + return Err(invalid_jpeg(spec, "JPEG marker header is truncated")); + } + let mut prefix = [0_u8; 1]; + read_exact_at_sync( + file, + cursor, + &mut prefix, + spec, + "JPEG marker header is truncated", + )?; + if prefix[0] != 0xFF { + return Err(invalid_jpeg( + spec, + "JPEG marker stream contained non-marker bytes between segments", + )); + } + cursor += 1; + loop { + if cursor >= file_size { + return Err(invalid_jpeg(spec, "JPEG marker header is truncated")); + } + let mut marker = [0_u8; 1]; + read_exact_at_sync( + file, + cursor, + &mut marker, + spec, + "JPEG marker header is truncated", + )?; + if marker[0] == 0xFF { + cursor += 1; + continue; + } + if marker[0] == 0x00 { + return Err(invalid_jpeg( + spec, + "JPEG marker stream carried a stuffed zero outside entropy-coded data", + )); + } + return Ok((marker[0], cursor - 1)); + } +} + +fn read_next_marker_bytes(bytes: &[u8], offset: u64, spec: &str) -> Result<(u8, u64), MuxError> { + let file_size = + u64::try_from(bytes.len()).map_err(|_| MuxError::LayoutOverflow("JPEG bytes length"))?; + let mut cursor = offset; + if cursor + 2 > file_size { + return Err(invalid_jpeg(spec, "JPEG marker header is truncated")); + } + let offset_usize = + usize::try_from(cursor).map_err(|_| MuxError::LayoutOverflow("JPEG marker offset"))?; + if bytes[offset_usize] != 0xFF { + return Err(invalid_jpeg( + spec, + "JPEG marker stream contained non-marker bytes between segments", + )); + } + cursor += 1; + loop { + if cursor >= file_size { + return Err(invalid_jpeg(spec, "JPEG marker header is truncated")); + } + let cursor_usize = + usize::try_from(cursor).map_err(|_| MuxError::LayoutOverflow("JPEG marker offset"))?; + let marker = bytes[cursor_usize]; + if marker == 0xFF { + cursor += 1; + continue; + } + if marker == 0x00 { + return Err(invalid_jpeg( + spec, + "JPEG marker stream carried a stuffed zero outside entropy-coded data", + )); + } + return Ok((marker, cursor - 1)); + } +} + +#[cfg(feature = "async")] +async fn read_next_marker_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, +) -> Result<(u8, u64), MuxError> { + let mut cursor = offset; + if cursor + 2 > file_size { + return Err(invalid_jpeg(spec, "JPEG marker header is truncated")); + } + let mut prefix = [0_u8; 1]; + read_exact_at_async( + file, + cursor, + &mut prefix, + spec, + "JPEG marker header is truncated", + ) + .await?; + if prefix[0] != 0xFF { + return Err(invalid_jpeg( + spec, + "JPEG marker stream contained non-marker bytes between segments", + )); + } + cursor += 1; + loop { + if cursor >= file_size { + return Err(invalid_jpeg(spec, "JPEG marker header is truncated")); + } + let mut marker = [0_u8; 1]; + read_exact_at_async( + file, + cursor, + &mut marker, + spec, + "JPEG marker header is truncated", + ) + .await?; + if marker[0] == 0xFF { + cursor += 1; + continue; + } + if marker[0] == 0x00 { + return Err(invalid_jpeg( + spec, + "JPEG marker stream carried a stuffed zero outside entropy-coded data", + )); + } + return Ok((marker[0], cursor - 1)); + } +} + +fn read_segment_bounds_sync( + file: &mut File, + file_size: u64, + marker_offset: u64, + spec: &str, +) -> Result<(u64, u64, u64), MuxError> { + let mut length = [0_u8; 2]; + read_exact_at_sync( + file, + marker_offset + 2, + &mut length, + spec, + "JPEG segment length is truncated", + )?; + decode_segment_bounds(file_size, marker_offset, u16::from_be_bytes(length), spec) +} + +#[cfg(feature = "async")] +async fn read_segment_bounds_async( + file: &mut TokioFile, + file_size: u64, + marker_offset: u64, + spec: &str, +) -> Result<(u64, u64, u64), MuxError> { + let mut length = [0_u8; 2]; + read_exact_at_async( + file, + marker_offset + 2, + &mut length, + spec, + "JPEG segment length is truncated", + ) + .await?; + decode_segment_bounds(file_size, marker_offset, u16::from_be_bytes(length), spec) +} + +fn decode_segment_bounds( + file_size: u64, + marker_offset: u64, + length: u16, + spec: &str, +) -> Result<(u64, u64, u64), MuxError> { + if length < 2 { + return Err(invalid_jpeg( + spec, + "JPEG segment length field was smaller than the required 2-byte minimum", + )); + } + let data_offset = marker_offset + 4; + let data_size = u64::from(length - 2); + let next_offset = data_offset + .checked_add(data_size) + .ok_or(MuxError::LayoutOverflow("JPEG segment range"))?; + if next_offset > file_size { + return Err(invalid_jpeg(spec, "JPEG segment overruns the input length")); + } + Ok((data_offset, data_size, next_offset)) +} + +fn read_segment_bounds_bytes( + bytes: &[u8], + marker_offset: u64, + spec: &str, +) -> Result<(u64, u64, u64), MuxError> { + let file_size = + u64::try_from(bytes.len()).map_err(|_| MuxError::LayoutOverflow("JPEG bytes length"))?; + let length_offset = usize::try_from(marker_offset + 2) + .map_err(|_| MuxError::LayoutOverflow("JPEG segment length offset"))?; + if length_offset + 2 > bytes.len() { + return Err(invalid_jpeg(spec, "JPEG segment length is truncated")); + } + let length = u16::from_be_bytes(bytes[length_offset..length_offset + 2].try_into().unwrap()); + decode_segment_bounds(file_size, marker_offset, length, spec) +} + +fn parse_sof_dimensions_sync( + file: &mut File, + data_offset: u64, + data_size: u64, + spec: &str, +) -> Result<(u32, u32), MuxError> { + if data_size < 6 { + return Err(invalid_jpeg(spec, "JPEG frame header is too short")); + } + let mut header = [0_u8; 6]; + read_exact_at_sync( + file, + data_offset, + &mut header, + spec, + "JPEG frame header is truncated", + )?; + decode_sof_dimensions(header, data_size, spec) +} + +#[cfg(feature = "async")] +async fn parse_sof_dimensions_async( + file: &mut TokioFile, + data_offset: u64, + data_size: u64, + spec: &str, +) -> Result<(u32, u32), MuxError> { + if data_size < 6 { + return Err(invalid_jpeg(spec, "JPEG frame header is too short")); + } + let mut header = [0_u8; 6]; + read_exact_at_async( + file, + data_offset, + &mut header, + spec, + "JPEG frame header is truncated", + ) + .await?; + decode_sof_dimensions(header, data_size, spec) +} + +fn decode_sof_dimensions( + header: [u8; 6], + data_size: u64, + spec: &str, +) -> Result<(u32, u32), MuxError> { + let sample_precision = header[0]; + let height = u16::from_be_bytes([header[1], header[2]]); + let width = u16::from_be_bytes([header[3], header[4]]); + let component_count = header[5]; + if sample_precision == 0 { + return Err(invalid_jpeg( + spec, + "JPEG frame header declared a zero sample precision", + )); + } + if width == 0 || height == 0 { + return Err(invalid_jpeg( + spec, + "JPEG frame header declared zero width or zero height", + )); + } + if component_count == 0 { + return Err(invalid_jpeg( + spec, + "JPEG frame header declared zero image components", + )); + } + let required_size = 6_u64 + .checked_add(u64::from(component_count) * 3) + .ok_or(MuxError::LayoutOverflow("JPEG frame header size"))?; + if data_size < required_size { + return Err(invalid_jpeg( + spec, + "JPEG frame header does not contain every declared component entry", + )); + } + Ok((u32::from(width), u32::from(height))) +} + +fn scan_entropy_coded_data_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, +) -> Result<(u8, u64), MuxError> { + let mut cursor = offset; + let mut previous_was_ff = false; + let mut buffer = [0_u8; 4096]; + while cursor < file_size { + let chunk_len = usize::try_from((file_size - cursor).min(buffer.len() as u64)).unwrap(); + read_exact_at_sync( + file, + cursor, + &mut buffer[..chunk_len], + spec, + "JPEG entropy-coded data is truncated", + )?; + for (index, byte) in buffer[..chunk_len].iter().copied().enumerate() { + if previous_was_ff { + match byte { + 0x00 => previous_was_ff = false, + 0xFF => previous_was_ff = true, + 0xD0..=0xD7 => previous_was_ff = false, + marker => { + let marker_offset = cursor + .checked_add(u64::try_from(index).unwrap()) + .and_then(|value| value.checked_sub(1)) + .ok_or(MuxError::LayoutOverflow("JPEG marker offset"))?; + return Ok((marker, marker_offset)); + } + } + } else if byte == 0xFF { + previous_was_ff = true; + } + } + cursor += u64::try_from(chunk_len).unwrap(); + } + Err(invalid_jpeg( + spec, + "JPEG entropy-coded data did not terminate with a marker", + )) +} + +fn scan_entropy_coded_data_bytes( + bytes: &[u8], + offset: u64, + spec: &str, +) -> Result<(u8, u64), MuxError> { + let file_size = + u64::try_from(bytes.len()).map_err(|_| MuxError::LayoutOverflow("JPEG bytes length"))?; + let mut cursor = + usize::try_from(offset).map_err(|_| MuxError::LayoutOverflow("JPEG marker offset"))?; + let mut previous_was_ff = false; + while cursor < bytes.len() { + let byte = bytes[cursor]; + if previous_was_ff { + match byte { + 0x00 => previous_was_ff = false, + 0xFF => previous_was_ff = true, + 0xD0..=0xD7 => previous_was_ff = false, + marker => { + let marker_offset = u64::try_from(cursor) + .map_err(|_| MuxError::LayoutOverflow("JPEG marker offset"))? + .checked_sub(1) + .ok_or(MuxError::LayoutOverflow("JPEG marker offset"))?; + return Ok((marker, marker_offset)); + } + } + } else if byte == 0xFF { + previous_was_ff = true; + } + cursor += 1; + } + let _ = file_size; + Err(invalid_jpeg( + spec, + "JPEG entropy-coded data did not terminate with a marker", + )) +} + +#[cfg(feature = "async")] +async fn scan_entropy_coded_data_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, +) -> Result<(u8, u64), MuxError> { + let mut cursor = offset; + let mut previous_was_ff = false; + let mut buffer = [0_u8; 4096]; + while cursor < file_size { + let chunk_len = usize::try_from((file_size - cursor).min(buffer.len() as u64)).unwrap(); + read_exact_at_async( + file, + cursor, + &mut buffer[..chunk_len], + spec, + "JPEG entropy-coded data is truncated", + ) + .await?; + for (index, byte) in buffer[..chunk_len].iter().copied().enumerate() { + if previous_was_ff { + match byte { + 0x00 => previous_was_ff = false, + 0xFF => previous_was_ff = true, + 0xD0..=0xD7 => previous_was_ff = false, + marker => { + let marker_offset = cursor + .checked_add(u64::try_from(index).unwrap()) + .and_then(|value| value.checked_sub(1)) + .ok_or(MuxError::LayoutOverflow("JPEG marker offset"))?; + return Ok((marker, marker_offset)); + } + } + } else if byte == 0xFF { + previous_was_ff = true; + } + } + cursor += u64::try_from(chunk_len).unwrap(); + } + Err(invalid_jpeg( + spec, + "JPEG entropy-coded data did not terminate with a marker", + )) +} + +fn finalize_jpeg_track( + spec: &str, + file_size: u64, + width: Option, + height: Option, + saw_sof: bool, + saw_sos: bool, + saw_eoi: bool, +) -> Result { + if !saw_sof { + return Err(invalid_jpeg( + spec, + "JPEG input did not carry a supported frame header marker", + )); + } + if !saw_sos { + return Err(invalid_jpeg( + spec, + "JPEG input did not carry a start-of-scan segment", + )); + } + if !saw_eoi { + return Err(invalid_jpeg( + spec, + "JPEG input did not terminate with an EOI marker", + )); + } + let width = width.ok_or_else(|| { + invalid_jpeg( + spec, + "JPEG input did not expose image dimensions before scan data", + ) + })?; + let height = height.ok_or_else(|| { + invalid_jpeg( + spec, + "JPEG input did not expose image dimensions before scan data", + ) + })?; + let width = u16::try_from(width).map_err(|_| { + invalid_jpeg( + spec, + "JPEG width does not fit in an MP4 visual sample entry", + ) + })?; + let height = u16::try_from(height).map_err(|_| { + invalid_jpeg( + spec, + "JPEG height does not fit in an MP4 visual sample entry", + ) + })?; + let data_size = u32::try_from(file_size) + .map_err(|_| MuxError::LayoutOverflow("JPEG file size exceeds MP4 sample limits"))?; + let sample_entry_box = build_jpeg_sample_entry_box(width, height)?; + Ok(ParsedJpegTrack { + width, + height, + sample_entry_box, + data_size, + }) +} + +const fn marker_has_standalone_layout(marker: u8) -> bool { + matches!(marker, JPEG_MARKER_TEM | 0xD0..=0xD7) +} + +fn build_jpeg_sample_entry_box(width: u16, height: u16) -> Result, MuxError> { + let mut compressorname = [0_u8; 32]; + compressorname[0] = 4; + compressorname[1..5].copy_from_slice(b"JPEG"); + super::super::mp4::encode_typed_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: JPEG_ENTRY, + data_reference_index: 1, + }, + width, + height, + horizresolution: 72, + vertresolution: 72, + frame_count: 1, + compressorname, + depth: 0x0018, + pre_defined3: -1, + ..VisualSampleEntry::default() + }, + &[], + ) +} + +pub(in crate::mux) fn build_avi_jpeg_sample_entry_box( + width: u16, + height: u16, +) -> Result, MuxError> { + let mut compressorname = [0_u8; 32]; + compressorname[0] = 19; + compressorname[1..20].copy_from_slice(b"Codec Not Supported"); + super::super::mp4::encode_typed_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: AVI_JPEG_ENTRY, + data_reference_index: 1, + }, + width, + height, + horizresolution: 72, + vertresolution: 72, + frame_count: 1, + compressorname, + depth: 0x0018, + pre_defined3: -1, + ..VisualSampleEntry::default() + }, + &[], + ) +} + +const fn is_sof_marker(marker: u8) -> bool { + matches!(marker, 0xC0..=0xCF) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) +} + +fn invalid_jpeg(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} diff --git a/src/mux/demux/latm.rs b/src/mux/demux/latm.rs new file mode 100644 index 0000000..99a861f --- /dev/null +++ b/src/mux/demux/latm.rs @@ -0,0 +1,939 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::iso14496_14::{ + DECODER_CONFIG_DESCRIPTOR_TAG, DECODER_SPECIFIC_INFO_TAG, DecoderConfigDescriptor, Descriptor, + ES_DESCRIPTOR_TAG, EsDescriptor, Esds, SL_CONFIG_DESCRIPTOR_TAG, +}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{ + SegmentedMuxSourceSegment, SegmentedMuxSourceSegmentData, SegmentedMuxSourceSpec, StagedSample, + build_btrt_from_sample_sizes, build_generic_audio_sample_entry_box, read_exact_at_sync, +}; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::read_segmented_bytes_sync; + +const MP4A: FourCc = FourCc::from_bytes(*b"mp4a"); +const LATM_SYNC_BYTE: u8 = 0x56; +const LATM_SYNC_HIGH_BITS: u8 = 0x07; +const LATM_SAMPLE_DURATION: u32 = 1024; +const MPEG4_AUDIO_OBJECT_TYPE_INDICATION: u8 = 0x40; +const AAC_LC_AUDIO_OBJECT_TYPE: u8 = 2; +const USAC_AUDIO_OBJECT_TYPE: u8 = 42; +const AAC_SAMPLE_RATE_TABLE: [u32; 13] = [ + 96_000, 88_200, 64_000, 48_000, 44_100, 32_000, 24_000, 22_050, 16_000, 12_000, 11_025, 8_000, + 7_350, +]; + +pub(in crate::mux) struct ParsedLatmTrack { + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) segmented_source: SegmentedMuxSourceSpec, + pub(in crate::mux) samples: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ParsedLatmConfig { + audio_object_type: u8, + sample_rate: u32, + channel_count: u16, + audio_specific_config: Vec, +} + +struct ParsedLatmAudioSpecificConfig { + audio_object_type: u8, + sample_rate: u32, + channel_count: u16, +} + +struct ParsedLatmFrame { + config: ParsedLatmConfig, + payload: Vec, +} + +struct LatmBitCursor<'a> { + data: &'a [u8], + bit_offset: usize, +} + +impl<'a> LatmBitCursor<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + bit_offset: 0, + } + } + + fn read_bits(&mut self, width: usize, spec: &str, context: &str) -> Result { + if width > 32 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("LATM parser requested invalid bit width {width} for {context}"), + }); + } + let end = self + .bit_offset + .checked_add(width) + .ok_or(MuxError::LayoutOverflow("LATM bit reader position"))?; + if end > self.data.len() * 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated LATM frame while reading {context}"), + }); + } + + let mut value = 0_u32; + for _ in 0..width { + let byte = self.data[self.bit_offset / 8]; + let shift = 7 - (self.bit_offset % 8); + value = (value << 1) | u32::from((byte >> shift) & 0x01); + self.bit_offset += 1; + } + Ok(value) + } + + fn read_bool(&mut self, spec: &str, context: &str) -> Result { + Ok(self.read_bits(1, spec, context)? != 0) + } + + fn bit_offset(&self) -> usize { + self.bit_offset + } +} + +pub(in crate::mux) fn scan_latm_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + parse_latm_stream_sync(&mut file, file_size, path, spec) +} + +pub(in crate::mux) fn scan_latm_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + path: &Path, + spec: &str, +) -> Result { + parse_latm_segmented_stream_sync(file, segments, total_size, path, spec) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_latm_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + parse_latm_stream_async(&mut file, file_size, path, spec).await +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_latm_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + path: &Path, + spec: &str, +) -> Result { + parse_latm_segmented_stream_async(file, segments, total_size, path, spec).await +} + +fn parse_latm_stream_sync( + file: &mut File, + file_size: u64, + path: &Path, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut config = None::; + let mut transformed_segments = Vec::new(); + let mut samples = Vec::new(); + let mut logical_offset = 0_u64; + + while offset < file_size { + let frame = read_latm_frame_sync(file, file_size, offset, spec)?; + let parsed = parse_latm_frame(&frame, spec, config.as_ref(), offset, file_size)?; + if let Some(existing) = &config { + if existing != &parsed.config { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "LATM input changed sample rate, channel layout, or AudioSpecificConfig bytes mid-stream".to_string(), + }); + } + } else { + config = Some(parsed.config.clone()); + } + + let data_size = u32::try_from(parsed.payload.len()) + .map_err(|_| MuxError::LayoutOverflow("LATM payload size"))?; + transformed_segments.push(SegmentedMuxSourceSegment { + logical_offset, + data: SegmentedMuxSourceSegmentData::Bytes(parsed.payload), + }); + samples.push(StagedSample { + data_offset: logical_offset, + data_size, + duration: LATM_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: true, + }); + logical_offset = logical_offset + .checked_add(u64::from(data_size)) + .ok_or(MuxError::LayoutOverflow("LATM transformed logical size"))?; + offset = offset + .checked_add(u64::try_from(frame.len()).unwrap()) + .ok_or(MuxError::LayoutOverflow("LATM frame offset"))?; + } + + finalize_latm_track( + path, + spec, + config, + transformed_segments, + samples, + logical_offset, + ) +} + +fn parse_latm_segmented_stream_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + path: &Path, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut config = None::; + let mut transformed_segments = Vec::new(); + let mut samples = Vec::new(); + let mut logical_offset = 0_u64; + + while offset < total_size { + let frame = read_latm_frame_segmented_sync(file, segments, total_size, offset, spec)?; + let parsed = parse_latm_frame(&frame, spec, config.as_ref(), offset, total_size)?; + if let Some(existing) = &config { + if existing != &parsed.config { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "LATM input changed sample rate, channel layout, or AudioSpecificConfig bytes mid-stream".to_string(), + }); + } + } else { + config = Some(parsed.config.clone()); + } + + let data_size = u32::try_from(parsed.payload.len()) + .map_err(|_| MuxError::LayoutOverflow("LATM payload size"))?; + transformed_segments.push(SegmentedMuxSourceSegment { + logical_offset, + data: SegmentedMuxSourceSegmentData::Bytes(parsed.payload), + }); + samples.push(StagedSample { + data_offset: logical_offset, + data_size, + duration: LATM_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: true, + }); + logical_offset = logical_offset + .checked_add(u64::from(data_size)) + .ok_or(MuxError::LayoutOverflow("LATM transformed logical size"))?; + offset = offset + .checked_add(u64::try_from(frame.len()).unwrap()) + .ok_or(MuxError::LayoutOverflow("LATM frame offset"))?; + } + + finalize_latm_track( + path, + spec, + config, + transformed_segments, + samples, + logical_offset, + ) +} + +#[cfg(feature = "async")] +async fn parse_latm_stream_async( + file: &mut TokioFile, + file_size: u64, + path: &Path, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut config = None::; + let mut transformed_segments = Vec::new(); + let mut samples = Vec::new(); + let mut logical_offset = 0_u64; + + while offset < file_size { + let frame = read_latm_frame_async(file, file_size, offset, spec).await?; + let parsed = parse_latm_frame(&frame, spec, config.as_ref(), offset, file_size)?; + if let Some(existing) = &config { + if existing != &parsed.config { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "LATM input changed sample rate, channel layout, or AudioSpecificConfig bytes mid-stream".to_string(), + }); + } + } else { + config = Some(parsed.config.clone()); + } + + let data_size = u32::try_from(parsed.payload.len()) + .map_err(|_| MuxError::LayoutOverflow("LATM payload size"))?; + transformed_segments.push(SegmentedMuxSourceSegment { + logical_offset, + data: SegmentedMuxSourceSegmentData::Bytes(parsed.payload), + }); + samples.push(StagedSample { + data_offset: logical_offset, + data_size, + duration: LATM_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: true, + }); + logical_offset = logical_offset + .checked_add(u64::from(data_size)) + .ok_or(MuxError::LayoutOverflow("LATM transformed logical size"))?; + offset = offset + .checked_add(u64::try_from(frame.len()).unwrap()) + .ok_or(MuxError::LayoutOverflow("LATM frame offset"))?; + } + + finalize_latm_track( + path, + spec, + config, + transformed_segments, + samples, + logical_offset, + ) +} + +#[cfg(feature = "async")] +async fn parse_latm_segmented_stream_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + path: &Path, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut config = None::; + let mut transformed_segments = Vec::new(); + let mut samples = Vec::new(); + let mut logical_offset = 0_u64; + + while offset < total_size { + let frame = + read_latm_frame_segmented_async(file, segments, total_size, offset, spec).await?; + let parsed = parse_latm_frame(&frame, spec, config.as_ref(), offset, total_size)?; + if let Some(existing) = &config { + if existing != &parsed.config { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "LATM input changed sample rate, channel layout, or AudioSpecificConfig bytes mid-stream".to_string(), + }); + } + } else { + config = Some(parsed.config.clone()); + } + + let data_size = u32::try_from(parsed.payload.len()) + .map_err(|_| MuxError::LayoutOverflow("LATM payload size"))?; + transformed_segments.push(SegmentedMuxSourceSegment { + logical_offset, + data: SegmentedMuxSourceSegmentData::Bytes(parsed.payload), + }); + samples.push(StagedSample { + data_offset: logical_offset, + data_size, + duration: LATM_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: true, + }); + logical_offset = logical_offset + .checked_add(u64::from(data_size)) + .ok_or(MuxError::LayoutOverflow("LATM transformed logical size"))?; + offset = offset + .checked_add(u64::try_from(frame.len()).unwrap()) + .ok_or(MuxError::LayoutOverflow("LATM frame offset"))?; + } + + finalize_latm_track( + path, + spec, + config, + transformed_segments, + samples, + logical_offset, + ) +} + +fn finalize_latm_track( + path: &Path, + spec: &str, + config: Option, + transformed_segments: Vec, + samples: Vec, + logical_offset: u64, +) -> Result { + let config = config.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "LATM input contained no frames".to_string(), + })?; + if samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "LATM input contained no AAC access units".to_string(), + }); + } + + Ok(ParsedLatmTrack { + sample_rate: config.sample_rate, + sample_entry_box: build_latm_sample_entry_box(&config, &samples)?, + segmented_source: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: transformed_segments, + total_size: logical_offset, + }, + samples, + }) +} + +fn read_latm_frame_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, +) -> Result, MuxError> { + if file_size.saturating_sub(offset) < 3 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated LATM sync header at byte offset {offset}"), + }); + } + let mut header = [0_u8; 3]; + read_exact_at_sync( + file, + offset, + &mut header, + spec, + "truncated LATM sync header", + )?; + validate_latm_sync_header(&header, spec, offset)?; + let mux_size = latm_mux_size(&header); + let frame_size = 3_u64 + .checked_add(mux_size) + .ok_or(MuxError::LayoutOverflow("LATM frame size"))?; + if offset + .checked_add(frame_size) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated LATM frame at byte offset {offset}"), + }); + } + let mut frame = vec![0_u8; usize::try_from(frame_size).unwrap()]; + read_exact_at_sync(file, offset, &mut frame, spec, "truncated LATM frame")?; + Ok(frame) +} + +fn read_latm_frame_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + offset: u64, + spec: &str, +) -> Result, MuxError> { + if total_size.saturating_sub(offset) < 3 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated LATM sync header at logical byte offset {offset}"), + }); + } + let mut header = [0_u8; 3]; + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated LATM sync header", + )?; + validate_latm_sync_header(&header, spec, offset)?; + let mux_size = latm_mux_size(&header); + let frame_size = 3_u64 + .checked_add(mux_size) + .ok_or(MuxError::LayoutOverflow("LATM frame size"))?; + if offset + .checked_add(frame_size) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated LATM frame at logical byte offset {offset}"), + }); + } + let mut frame = vec![0_u8; usize::try_from(frame_size).unwrap()]; + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut frame, + spec, + "truncated LATM frame", + )?; + Ok(frame) +} + +#[cfg(feature = "async")] +async fn read_latm_frame_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, +) -> Result, MuxError> { + if file_size.saturating_sub(offset) < 3 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated LATM sync header at byte offset {offset}"), + }); + } + let mut header = [0_u8; 3]; + read_exact_at_async( + file, + offset, + &mut header, + spec, + "truncated LATM sync header", + ) + .await?; + validate_latm_sync_header(&header, spec, offset)?; + let mux_size = latm_mux_size(&header); + let frame_size = 3_u64 + .checked_add(mux_size) + .ok_or(MuxError::LayoutOverflow("LATM frame size"))?; + if offset + .checked_add(frame_size) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated LATM frame at byte offset {offset}"), + }); + } + let mut frame = vec![0_u8; usize::try_from(frame_size).unwrap()]; + read_exact_at_async(file, offset, &mut frame, spec, "truncated LATM frame").await?; + Ok(frame) +} + +#[cfg(feature = "async")] +async fn read_latm_frame_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + offset: u64, + spec: &str, +) -> Result, MuxError> { + if total_size.saturating_sub(offset) < 3 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated LATM sync header at logical byte offset {offset}"), + }); + } + let mut header = [0_u8; 3]; + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated LATM sync header", + ) + .await?; + validate_latm_sync_header(&header, spec, offset)?; + let mux_size = latm_mux_size(&header); + let frame_size = 3_u64 + .checked_add(mux_size) + .ok_or(MuxError::LayoutOverflow("LATM frame size"))?; + if offset + .checked_add(frame_size) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated LATM frame at logical byte offset {offset}"), + }); + } + let mut frame = vec![0_u8; usize::try_from(frame_size).unwrap()]; + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut frame, + spec, + "truncated LATM frame", + ) + .await?; + Ok(frame) +} + +fn validate_latm_sync_header(header: &[u8; 3], spec: &str, offset: u64) -> Result<(), MuxError> { + if header[0] != LATM_SYNC_BYTE || (header[1] >> 5) != LATM_SYNC_HIGH_BITS { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing LATM sync header at byte offset {offset}"), + }); + } + Ok(()) +} + +fn latm_mux_size(header: &[u8; 3]) -> u64 { + u64::from((u16::from(header[1] & 0x1F) << 8) | u16::from(header[2])) +} + +fn parse_latm_frame( + frame: &[u8], + spec: &str, + expected_config: Option<&ParsedLatmConfig>, + frame_offset: u64, + file_size: u64, +) -> Result { + let mut bits = LatmBitCursor::new(&frame[3..]); + let use_same_stream_mux = bits.read_bool(spec, "LATM useSameStreamMux")?; + + let config = if use_same_stream_mux { + expected_config + .cloned() + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "LATM input used same-stream-mux before any StreamMuxConfig was available" + .to_string(), + })? + } else { + parse_latm_stream_mux_config(&mut bits, spec)? + }; + + let payload_size = read_latm_payload_length(&mut bits, spec)?; + if payload_size == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "LATM frame at byte offset {frame_offset} declared a zero AAC payload" + ), + }); + } + let payload = extract_packed_bit_slice( + &frame[3..], + bits.bit_offset(), + usize::try_from(payload_size) + .map_err(|_| MuxError::LayoutOverflow("LATM payload size"))? + .checked_mul(8) + .ok_or(MuxError::LayoutOverflow("LATM payload bits"))?, + spec, + "LATM AAC payload", + )?; + let frame_size = u64::try_from(frame.len()).unwrap(); + let frame_end = frame_offset + .checked_add(frame_size) + .ok_or(MuxError::LayoutOverflow("LATM frame end"))?; + if frame_end > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated LATM frame at byte offset {frame_offset}"), + }); + } + + Ok(ParsedLatmFrame { config, payload }) +} + +fn parse_latm_stream_mux_config( + bits: &mut LatmBitCursor<'_>, + spec: &str, +) -> Result { + let audio_mux_version = bits.read_bool(spec, "LATM audioMuxVersion")?; + if audio_mux_version { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "LATM direct input currently supports audioMuxVersion 0 only".to_string(), + }); + } + let all_streams_same_time_framing = bits.read_bool(spec, "LATM allStreamsSameTimeFraming")?; + if !all_streams_same_time_framing { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "LATM direct input requires allStreamsSameTimeFraming".to_string(), + }); + } + let num_sub_frames = bits.read_bits(6, spec, "LATM numSubFrames")?; + if num_sub_frames != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "LATM direct input currently supports numSubFrames = 0 only".to_string(), + }); + } + let num_program = bits.read_bits(4, spec, "LATM numProgram")?; + if num_program != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "LATM direct input currently supports one program only".to_string(), + }); + } + let num_layer = bits.read_bits(3, spec, "LATM numLayer")?; + if num_layer != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "LATM direct input currently supports one layer only".to_string(), + }); + } + + let audio_specific_config_start = bits.bit_offset(); + let parsed_audio_config = parse_audio_specific_config(bits, spec)?; + let audio_specific_config_end = bits.bit_offset(); + let audio_specific_config = extract_packed_bit_slice( + bits.data, + audio_specific_config_start, + audio_specific_config_end - audio_specific_config_start, + spec, + "LATM AudioSpecificConfig", + )?; + + let frame_length_type = bits.read_bits(3, spec, "LATM frameLengthType")?; + if frame_length_type != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "LATM direct input currently supports frameLengthType 0 only, found {frame_length_type}" + ), + }); + } + let _latm_buffer_fullness = bits.read_bits(8, spec, "LATM latmBufferFullness")?; + let _other_data_present = bits.read_bool(spec, "LATM otherDataPresent")?; + let _crc_check_present = bits.read_bool(spec, "LATM crcCheckPresent")?; + + Ok(ParsedLatmConfig { + audio_object_type: parsed_audio_config.audio_object_type, + sample_rate: parsed_audio_config.sample_rate, + channel_count: parsed_audio_config.channel_count, + audio_specific_config, + }) +} + +fn parse_audio_specific_config( + bits: &mut LatmBitCursor<'_>, + spec: &str, +) -> Result { + let audio_object_type = read_audio_object_type(bits, spec)?; + let mut sample_rate = read_aac_sample_rate(bits, spec, "LATM AudioSpecificConfig sample rate")?; + let channel_configuration = + u8::try_from(bits.read_bits(4, spec, "LATM AudioSpecificConfig channel configuration")?) + .unwrap(); + let mut core_audio_object_type = audio_object_type; + if matches!(audio_object_type, 5 | 29) { + sample_rate = read_aac_sample_rate(bits, spec, "LATM SBR extension sample rate")?; + core_audio_object_type = read_audio_object_type(bits, spec)?; + } + if !matches!( + core_audio_object_type, + AAC_LC_AUDIO_OBJECT_TYPE | USAC_AUDIO_OBJECT_TYPE + ) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "LATM direct input currently supports AAC LC or USAC style AudioSpecificConfig only, found audio object type {core_audio_object_type}" + ), + }); + } + + let channel_count = aac_channel_count(channel_configuration).ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "LATM direct input currently rejects AAC channel configuration {channel_configuration}" + ), + } + })?; + let _frame_length_flag = bits.read_bool(spec, "LATM frameLengthFlag")?; + let depends_on_core_coder = bits.read_bool(spec, "LATM dependsOnCoreCoder")?; + if depends_on_core_coder { + let _core_coder_delay = bits.read_bits(14, spec, "LATM coreCoderDelay")?; + } + let _extension_flag = bits.read_bool(spec, "LATM extensionFlag")?; + Ok(ParsedLatmAudioSpecificConfig { + audio_object_type: core_audio_object_type, + sample_rate, + channel_count, + }) +} + +fn read_audio_object_type(bits: &mut LatmBitCursor<'_>, spec: &str) -> Result { + let audio_object_type = u8::try_from(bits.read_bits(5, spec, "LATM audioObjectType")?).unwrap(); + if audio_object_type == 31 { + let extended = + u8::try_from(bits.read_bits(6, spec, "LATM extended audioObjectType")?).unwrap(); + return Ok(32 + extended); + } + Ok(audio_object_type) +} + +fn read_aac_sample_rate( + bits: &mut LatmBitCursor<'_>, + spec: &str, + context: &str, +) -> Result { + let sample_rate_index = u8::try_from(bits.read_bits(4, spec, context)?).unwrap(); + if sample_rate_index == 0x0F { + return bits.read_bits(24, spec, context); + } + AAC_SAMPLE_RATE_TABLE + .get(usize::from(sample_rate_index)) + .copied() + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "LATM direct input used unsupported AAC sample-rate index {sample_rate_index}" + ), + }) +} + +fn aac_channel_count(channel_configuration: u8) -> Option { + match channel_configuration { + 1 => Some(1), + 2 => Some(2), + 3 => Some(3), + 4 => Some(4), + 5 => Some(5), + 6 => Some(6), + 7 => Some(8), + _ => None, + } +} + +fn read_latm_payload_length(bits: &mut LatmBitCursor<'_>, spec: &str) -> Result { + let mut size = 0_u32; + loop { + let value = bits.read_bits(8, spec, "LATM payload length")?; + size = size + .checked_add(value) + .ok_or(MuxError::LayoutOverflow("LATM payload length"))?; + if value != 255 { + break; + } + } + Ok(size) +} + +fn extract_packed_bit_slice( + data: &[u8], + bit_offset: usize, + bit_len: usize, + spec: &str, + context: &str, +) -> Result, MuxError> { + let end = bit_offset + .checked_add(bit_len) + .ok_or(MuxError::LayoutOverflow("LATM packed bit slice"))?; + if end > data.len() * 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated LATM frame while extracting {context}"), + }); + } + let byte_len = bit_len.div_ceil(8); + let mut output = vec![0_u8; byte_len]; + for index in 0..bit_len { + let source_bit = bit_offset + index; + let source_byte = data[source_bit / 8]; + let source_shift = 7 - (source_bit % 8); + let bit = (source_byte >> source_shift) & 0x01; + if bit != 0 { + let output_bit = index; + let output_byte_index = output_bit / 8; + let output_shift = 7 - (output_bit % 8); + output[output_byte_index] |= 1 << output_shift; + } + } + Ok(output) +} + +fn build_latm_sample_entry_box( + config: &ParsedLatmConfig, + samples: &[StagedSample], +) -> Result, MuxError> { + let mut esds = build_latm_esds(config.sample_rate, &config.audio_specific_config, samples)?; + esds.normalize_descriptor_sizes_for_mux() + .map_err(|_| MuxError::LayoutOverflow("LATM esds normalization"))?; + let esds = super::super::mp4::encode_typed_box(&esds, &[])?; + build_generic_audio_sample_entry_box( + MP4A, + config.sample_rate, + config.channel_count, + 16, + &[esds], + ) +} + +fn build_latm_esds( + sample_rate: u32, + audio_specific_config: &[u8], + samples: &[StagedSample], +) -> Result { + let decoder_bitrates = build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + sample_rate, + )?; + let mut esds = Esds::default(); + esds.descriptors = vec![ + Descriptor { + tag: ES_DESCRIPTOR_TAG, + es_descriptor: Some(EsDescriptor::default()), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some(DecoderConfigDescriptor { + object_type_indication: MPEG4_AUDIO_OBJECT_TYPE_INDICATION, + stream_type: 5, + buffer_size_db: decoder_bitrates.buffer_size_db, + max_bitrate: decoder_bitrates.max_bitrate, + avg_bitrate: decoder_bitrates.avg_bitrate, + reserved: true, + ..DecoderConfigDescriptor::default() + }), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_SPECIFIC_INFO_TAG, + size: audio_specific_config.len() as u32, + data: audio_specific_config.to_vec(), + ..Descriptor::default() + }, + Descriptor { + tag: SL_CONFIG_DESCRIPTOR_TAG, + size: 1, + data: vec![0x02], + ..Descriptor::default() + }, + ]; + Ok(esds) +} diff --git a/src/mux/demux/mhas.rs b/src/mux/demux/mhas.rs new file mode 100644 index 0000000..0a12d19 --- /dev/null +++ b/src/mux/demux/mhas.rs @@ -0,0 +1,1228 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::iso14496_12::Btrt; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{ + SegmentedMuxSourceSegment, StagedSample, build_btrt_from_sample_sizes, + build_generic_audio_sample_entry_box, read_exact_at_sync, +}; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::read_segmented_bytes_sync; + +const MHM1: FourCc = FourCc::from_bytes(*b"mhm1"); +const MHAS_SAMPLE_RATE_TABLE: [u32; 28] = [ + 96_000, 88_200, 64_000, 48_000, 44_100, 32_000, 24_000, 22_050, 16_000, 12_000, 11_025, 8_000, + 7_350, 0, 0, 57_600, 51_200, 40_000, 38_400, 34_150, 28_800, 25_600, 20_000, 19_200, 17_075, + 14_400, 12_800, 9_600, +]; + +pub(in crate::mux) struct ParsedMhasTrack { + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) audio_profile_level_indication: u8, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ParsedMhasConfig { + profile_level_indication: u8, + sample_rate: u32, + frame_length: u32, + channel_count: u16, + config_payload_size: usize, +} + +#[derive(Clone, Copy)] +struct MhasPacketHeader { + packet_type: u32, + payload_size: u64, + header_size: u64, +} + +struct MhasBitCursor<'a> { + data: &'a [u8], + bit_offset: usize, +} + +impl<'a> MhasBitCursor<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + bit_offset: 0, + } + } + + fn read_bits(&mut self, width: usize, spec: &str, context: &str) -> Result { + if width > 64 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("MHAS parser requested invalid bit width {width} for {context}"), + }); + } + let end = self + .bit_offset + .checked_add(width) + .ok_or(MuxError::LayoutOverflow("MHAS bit reader position"))?; + if end > self.data.len() * 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated MHAS data while reading {context}"), + }); + } + + let mut value = 0_u64; + for _ in 0..width { + let byte = self.data[self.bit_offset / 8]; + let shift = 7 - (self.bit_offset % 8); + value = (value << 1) | u64::from((byte >> shift) & 0x01); + self.bit_offset += 1; + } + Ok(value) + } + + fn read_bool(&mut self, spec: &str, context: &str) -> Result { + Ok(self.read_bits(1, spec, context)? != 0) + } + + fn bytes_consumed(&self) -> usize { + self.bit_offset.div_ceil(8) + } + + fn read_escaped_value( + &mut self, + first_width: usize, + escape_width: usize, + final_width: usize, + spec: &str, + context: &str, + ) -> Result { + let value = self.read_bits(first_width, spec, context)?; + let max_first = (1_u64 << first_width) - 1; + if value != max_first { + return Ok(value); + } + let escape = self.read_bits(escape_width, spec, context)?; + let max_escape = (1_u64 << escape_width) - 1; + if escape != max_escape { + return value + .checked_add(escape) + .ok_or(MuxError::LayoutOverflow("MHAS escaped value")); + } + let final_value = self.read_bits(final_width, spec, context)?; + value + .checked_add(escape) + .and_then(|prefix| prefix.checked_add(final_value)) + .ok_or(MuxError::LayoutOverflow("MHAS escaped value")) + } +} + +pub(in crate::mux) fn scan_mhas_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + if file_size < 3 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MHAS input is truncated before the required leading sync packet".to_string(), + }); + } + + let mut offset = 0_u64; + let mut sample_start = 0_u64; + let mut config = None::; + let mut saw_frame = false; + let mut samples = Vec::new(); + while offset < file_size { + let header = read_mhas_packet_header_sync(&mut file, file_size, offset, spec)?; + let payload_offset = offset + .checked_add(header.header_size) + .ok_or(MuxError::LayoutOverflow("MHAS payload offset"))?; + let packet_end = payload_offset + .checked_add(header.payload_size) + .ok_or(MuxError::LayoutOverflow("MHAS packet range"))?; + if packet_end > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("MHAS packet at byte offset {offset} overruns the input length"), + }); + } + if header.packet_type > 18 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "MHAS packet at byte offset {offset} used unsupported packet type {}", + header.packet_type + ), + }); + } + if header.payload_size == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("MHAS packet at byte offset {offset} declared a zero payload"), + }); + } + if offset == 0 { + let sync_byte = read_mhas_sync_marker_sync(&mut file, payload_offset, spec)?; + if header.packet_type != 6 || header.payload_size != 1 || sync_byte != 0xA5 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "MHAS direct input currently requires a leading sync packet with marker 0xA5" + .to_string(), + }); + } + } + + match header.packet_type { + 1 => { + let payload = read_mhas_packet_payload_sync( + &mut file, + payload_offset, + header.payload_size, + spec, + "MHAS config packet payload is truncated", + )?; + let parsed = parse_mhas_config_packet(&payload, spec)?; + if let Some(existing) = &config { + if existing != &parsed { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "MHAS input changed profile, sample rate, frame length, channel layout, or configuration bytes mid-stream" + .to_string(), + }); + } + } else { + config = Some(parsed); + } + } + 2 => { + let current_config = + config + .as_ref() + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MHAS frame packet appeared before any configuration packet" + .to_string(), + })?; + let is_sync_sample = read_mhas_frame_sap_sync(&mut file, payload_offset, spec)?; + let data_size = u32::try_from(packet_end - sample_start) + .map_err(|_| MuxError::LayoutOverflow("MHAS access unit size"))?; + samples.push(StagedSample { + data_offset: sample_start, + data_size, + duration: current_config.frame_length, + composition_time_offset: 0, + is_sync_sample, + }); + sample_start = packet_end; + saw_frame = true; + } + 17 => { + let payload = read_mhas_packet_payload_sync( + &mut file, + payload_offset, + header.payload_size, + spec, + "MHAS truncation packet payload is truncated", + )?; + parse_mhas_truncation_packet(&payload, spec)?; + } + _ => {} + } + offset = packet_end; + } + + finalize_mhas_track(spec, config, samples, sample_start, saw_frame, offset) +} + +pub(in crate::mux) fn scan_mhas_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + parse_mhas_segmented_stream_sync(file, segments, total_size, spec) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_mhas_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + if file_size < 3 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MHAS input is truncated before the required leading sync packet".to_string(), + }); + } + + let mut offset = 0_u64; + let mut sample_start = 0_u64; + let mut config = None::; + let mut saw_frame = false; + let mut samples = Vec::new(); + while offset < file_size { + let header = read_mhas_packet_header_async(&mut file, file_size, offset, spec).await?; + let payload_offset = offset + .checked_add(header.header_size) + .ok_or(MuxError::LayoutOverflow("MHAS payload offset"))?; + let packet_end = payload_offset + .checked_add(header.payload_size) + .ok_or(MuxError::LayoutOverflow("MHAS packet range"))?; + if packet_end > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("MHAS packet at byte offset {offset} overruns the input length"), + }); + } + if header.packet_type > 18 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "MHAS packet at byte offset {offset} used unsupported packet type {}", + header.packet_type + ), + }); + } + if header.payload_size == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("MHAS packet at byte offset {offset} declared a zero payload"), + }); + } + if offset == 0 { + let sync_byte = read_mhas_sync_marker_async(&mut file, payload_offset, spec).await?; + if header.packet_type != 6 || header.payload_size != 1 || sync_byte != 0xA5 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "MHAS direct input currently requires a leading sync packet with marker 0xA5" + .to_string(), + }); + } + } + + match header.packet_type { + 1 => { + let payload = read_mhas_packet_payload_async( + &mut file, + payload_offset, + header.payload_size, + spec, + "MHAS config packet payload is truncated", + ) + .await?; + let parsed = parse_mhas_config_packet(&payload, spec)?; + if let Some(existing) = &config { + if existing != &parsed { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "MHAS input changed profile, sample rate, frame length, channel layout, or configuration bytes mid-stream" + .to_string(), + }); + } + } else { + config = Some(parsed); + } + } + 2 => { + let current_config = + config + .as_ref() + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MHAS frame packet appeared before any configuration packet" + .to_string(), + })?; + let is_sync_sample = + read_mhas_frame_sap_async(&mut file, payload_offset, spec).await?; + let data_size = u32::try_from(packet_end - sample_start) + .map_err(|_| MuxError::LayoutOverflow("MHAS access unit size"))?; + samples.push(StagedSample { + data_offset: sample_start, + data_size, + duration: current_config.frame_length, + composition_time_offset: 0, + is_sync_sample, + }); + sample_start = packet_end; + saw_frame = true; + } + 17 => { + let payload = read_mhas_packet_payload_async( + &mut file, + payload_offset, + header.payload_size, + spec, + "MHAS truncation packet payload is truncated", + ) + .await?; + parse_mhas_truncation_packet(&payload, spec)?; + } + _ => {} + } + offset = packet_end; + } + + finalize_mhas_track(spec, config, samples, sample_start, saw_frame, offset) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_mhas_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + parse_mhas_segmented_stream_async(file, segments, total_size, spec).await +} + +fn parse_mhas_segmented_stream_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut sample_start = 0_u64; + let mut config = None::; + let mut saw_frame = false; + let mut samples = Vec::new(); + while offset < total_size { + let header = + read_mhas_packet_header_segmented_sync(file, segments, total_size, offset, spec)?; + let payload_offset = offset + .checked_add(header.header_size) + .ok_or(MuxError::LayoutOverflow("MHAS payload offset"))?; + let packet_end = payload_offset + .checked_add(header.payload_size) + .ok_or(MuxError::LayoutOverflow("MHAS packet range"))?; + if packet_end > total_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "MHAS packet at logical byte offset {offset} overruns the carried input length" + ), + }); + } + if header.packet_type > 18 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "MHAS packet at logical byte offset {offset} used unsupported packet type {}", + header.packet_type + ), + }); + } + if header.payload_size == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "MHAS packet at logical byte offset {offset} declared a zero payload" + ), + }); + } + if offset == 0 { + let sync_byte = + read_mhas_sync_marker_segmented_sync(file, segments, payload_offset, spec)?; + if header.packet_type != 6 || header.payload_size != 1 || sync_byte != 0xA5 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "MHAS direct input currently requires a leading sync packet with marker 0xA5" + .to_string(), + }); + } + } + + match header.packet_type { + 1 => { + let payload = read_mhas_packet_payload_segmented_sync( + file, + segments, + payload_offset, + header.payload_size, + spec, + "MHAS config packet payload is truncated", + )?; + let parsed = parse_mhas_config_packet(&payload, spec)?; + if let Some(existing) = &config { + if existing != &parsed { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "MHAS input changed profile, sample rate, frame length, channel layout, or configuration bytes mid-stream" + .to_string(), + }); + } + } else { + config = Some(parsed); + } + } + 2 => { + let current_config = + config + .as_ref() + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MHAS frame packet appeared before any configuration packet" + .to_string(), + })?; + let is_sync_sample = + read_mhas_frame_sap_segmented_sync(file, segments, payload_offset, spec)?; + let data_size = u32::try_from(packet_end - sample_start) + .map_err(|_| MuxError::LayoutOverflow("MHAS access unit size"))?; + samples.push(StagedSample { + data_offset: sample_start, + data_size, + duration: current_config.frame_length, + composition_time_offset: 0, + is_sync_sample, + }); + sample_start = packet_end; + saw_frame = true; + } + 17 => { + let payload = read_mhas_packet_payload_segmented_sync( + file, + segments, + payload_offset, + header.payload_size, + spec, + "MHAS truncation packet payload is truncated", + )?; + parse_mhas_truncation_packet(&payload, spec)?; + } + _ => {} + } + offset = packet_end; + } + + finalize_mhas_track(spec, config, samples, sample_start, saw_frame, offset) +} + +#[cfg(feature = "async")] +async fn parse_mhas_segmented_stream_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut sample_start = 0_u64; + let mut config = None::; + let mut saw_frame = false; + let mut samples = Vec::new(); + while offset < total_size { + let header = + read_mhas_packet_header_segmented_async(file, segments, total_size, offset, spec) + .await?; + let payload_offset = offset + .checked_add(header.header_size) + .ok_or(MuxError::LayoutOverflow("MHAS payload offset"))?; + let packet_end = payload_offset + .checked_add(header.payload_size) + .ok_or(MuxError::LayoutOverflow("MHAS packet range"))?; + if packet_end > total_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "MHAS packet at logical byte offset {offset} overruns the carried input length" + ), + }); + } + if header.packet_type > 18 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "MHAS packet at logical byte offset {offset} used unsupported packet type {}", + header.packet_type + ), + }); + } + if header.payload_size == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "MHAS packet at logical byte offset {offset} declared a zero payload" + ), + }); + } + if offset == 0 { + let sync_byte = + read_mhas_sync_marker_segmented_async(file, segments, payload_offset, spec).await?; + if header.packet_type != 6 || header.payload_size != 1 || sync_byte != 0xA5 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "MHAS direct input currently requires a leading sync packet with marker 0xA5" + .to_string(), + }); + } + } + + match header.packet_type { + 1 => { + let payload = read_mhas_packet_payload_segmented_async( + file, + segments, + payload_offset, + header.payload_size, + spec, + "MHAS config packet payload is truncated", + ) + .await?; + let parsed = parse_mhas_config_packet(&payload, spec)?; + if let Some(existing) = &config { + if existing != &parsed { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "MHAS input changed profile, sample rate, frame length, channel layout, or configuration bytes mid-stream" + .to_string(), + }); + } + } else { + config = Some(parsed); + } + } + 2 => { + let current_config = + config + .as_ref() + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MHAS frame packet appeared before any configuration packet" + .to_string(), + })?; + let is_sync_sample = + read_mhas_frame_sap_segmented_async(file, segments, payload_offset, spec) + .await?; + let data_size = u32::try_from(packet_end - sample_start) + .map_err(|_| MuxError::LayoutOverflow("MHAS access unit size"))?; + samples.push(StagedSample { + data_offset: sample_start, + data_size, + duration: current_config.frame_length, + composition_time_offset: 0, + is_sync_sample, + }); + sample_start = packet_end; + saw_frame = true; + } + 17 => { + let payload = read_mhas_packet_payload_segmented_async( + file, + segments, + payload_offset, + header.payload_size, + spec, + "MHAS truncation packet payload is truncated", + ) + .await?; + parse_mhas_truncation_packet(&payload, spec)?; + } + _ => {} + } + offset = packet_end; + } + + finalize_mhas_track(spec, config, samples, sample_start, saw_frame, offset) +} + +fn finalize_mhas_track( + spec: &str, + config: Option, + mut samples: Vec, + sample_start: u64, + saw_frame: bool, + final_offset: u64, +) -> Result { + let config = config.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MHAS input did not contain a required configuration packet".to_string(), + })?; + if !saw_frame || samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MHAS input did not contain any frame packets".to_string(), + }); + } + if sample_start != final_offset { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MHAS input ended with non-frame packets after the last frame packet" + .to_string(), + }); + } + if config.config_payload_size > 40 { + collapse_consecutive_mhas_sync_runs(&mut samples); + } + let btrt = build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + config.sample_rate, + )?; + Ok(ParsedMhasTrack { + sample_rate: config.sample_rate, + audio_profile_level_indication: config.profile_level_indication, + sample_entry_box: build_mhas_sample_entry_box(&config, btrt)?, + samples, + }) +} + +fn collapse_consecutive_mhas_sync_runs(samples: &mut [StagedSample]) { + let mut previous_sync_index = None::; + for index in 0..samples.len() { + if !samples[index].is_sync_sample { + previous_sync_index = None; + continue; + } + if let Some(previous_sync_index) = previous_sync_index { + samples[previous_sync_index].is_sync_sample = false; + } + previous_sync_index = Some(index); + } +} + +fn build_mhas_sample_entry_box(config: &ParsedMhasConfig, btrt: Btrt) -> Result, MuxError> { + build_mhas_sample_entry_box_with_btrt(config.sample_rate, btrt) +} + +#[cfg(test)] +#[allow(clippy::items_after_test_module)] +mod tests { + use super::collapse_consecutive_mhas_sync_runs; + use crate::mux::import::StagedSample; + + #[test] + fn collapse_consecutive_mhas_sync_runs_keeps_only_last_sample_in_each_run() { + let mut samples = vec![ + StagedSample { + data_offset: 0, + data_size: 1, + duration: 1024, + composition_time_offset: 0, + is_sync_sample: true, + }, + StagedSample { + data_offset: 1, + data_size: 1, + duration: 1024, + composition_time_offset: 0, + is_sync_sample: false, + }, + StagedSample { + data_offset: 2, + data_size: 1, + duration: 1024, + composition_time_offset: 0, + is_sync_sample: true, + }, + StagedSample { + data_offset: 3, + data_size: 1, + duration: 1024, + composition_time_offset: 0, + is_sync_sample: true, + }, + StagedSample { + data_offset: 4, + data_size: 1, + duration: 1024, + composition_time_offset: 0, + is_sync_sample: true, + }, + StagedSample { + data_offset: 5, + data_size: 1, + duration: 1024, + composition_time_offset: 0, + is_sync_sample: false, + }, + ]; + + collapse_consecutive_mhas_sync_runs(&mut samples); + + assert!(samples[0].is_sync_sample); + assert!(!samples[2].is_sync_sample); + assert!(!samples[3].is_sync_sample); + assert!(samples[4].is_sync_sample); + } +} + +pub(in crate::mux) fn build_mhas_sample_entry_box_with_btrt( + sample_rate: u32, + btrt: Btrt, +) -> Result, MuxError> { + let btrt_bytes = super::super::mp4::encode_typed_box(&btrt, &[])?; + build_generic_audio_sample_entry_box(MHM1, sample_rate, 0, 16, &[btrt_bytes]) +} + +fn read_mhas_packet_header_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, +) -> Result { + let available = usize::try_from((file_size - offset).min(15)) + .map_err(|_| MuxError::LayoutOverflow("MHAS header probe size"))?; + let mut header = vec![0_u8; available]; + read_exact_at_sync( + file, + offset, + &mut header, + spec, + "MHAS packet header is truncated", + )?; + parse_mhas_packet_header(&header, spec) +} + +fn read_mhas_packet_header_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + offset: u64, + spec: &str, +) -> Result { + let available = usize::try_from((total_size - offset).min(15)) + .map_err(|_| MuxError::LayoutOverflow("MHAS header probe size"))?; + let mut header = vec![0_u8; available]; + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut header, + spec, + "MHAS packet header is truncated", + )?; + parse_mhas_packet_header(&header, spec) +} + +#[cfg(feature = "async")] +async fn read_mhas_packet_header_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, +) -> Result { + let available = usize::try_from((file_size - offset).min(15)) + .map_err(|_| MuxError::LayoutOverflow("MHAS header probe size"))?; + let mut header = vec![0_u8; available]; + read_exact_at_async( + file, + offset, + &mut header, + spec, + "MHAS packet header is truncated", + ) + .await?; + parse_mhas_packet_header(&header, spec) +} + +#[cfg(feature = "async")] +async fn read_mhas_packet_header_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + offset: u64, + spec: &str, +) -> Result { + let available = usize::try_from((total_size - offset).min(15)) + .map_err(|_| MuxError::LayoutOverflow("MHAS header probe size"))?; + let mut header = vec![0_u8; available]; + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut header, + spec, + "MHAS packet header is truncated", + ) + .await?; + parse_mhas_packet_header(&header, spec) +} + +fn parse_mhas_packet_header(header: &[u8], spec: &str) -> Result { + let mut reader = MhasBitCursor::new(header); + let packet_type = + u32::try_from(reader.read_escaped_value(3, 8, 8, spec, "MHAS packet type")?) + .map_err(|_| MuxError::LayoutOverflow("MHAS packet type"))?; + let _label = reader.read_escaped_value(2, 8, 32, spec, "MHAS packet label")?; + let payload_size = reader.read_escaped_value(11, 24, 24, spec, "MHAS packet size")?; + Ok(MhasPacketHeader { + packet_type, + payload_size, + header_size: u64::try_from(reader.bytes_consumed()) + .map_err(|_| MuxError::LayoutOverflow("MHAS header size"))?, + }) +} + +fn read_mhas_packet_payload_sync( + file: &mut File, + offset: u64, + size: u64, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + let len = + usize::try_from(size).map_err(|_| MuxError::LayoutOverflow("MHAS packet payload size"))?; + let mut payload = vec![0_u8; len]; + read_exact_at_sync(file, offset, &mut payload, spec, truncated_message)?; + Ok(payload) +} + +fn read_mhas_packet_payload_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + offset: u64, + size: u64, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + let len = + usize::try_from(size).map_err(|_| MuxError::LayoutOverflow("MHAS packet payload size"))?; + let mut payload = vec![0_u8; len]; + read_segmented_bytes_sync( + file, + segments, + u64::try_from(len) + .map_err(|_| MuxError::LayoutOverflow("MHAS packet payload size"))? + .checked_add(offset) + .ok_or(MuxError::LayoutOverflow("MHAS packet payload range"))?, + offset, + &mut payload, + spec, + truncated_message, + )?; + Ok(payload) +} + +#[cfg(feature = "async")] +async fn read_mhas_packet_payload_async( + file: &mut TokioFile, + offset: u64, + size: u64, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + let len = + usize::try_from(size).map_err(|_| MuxError::LayoutOverflow("MHAS packet payload size"))?; + let mut payload = vec![0_u8; len]; + read_exact_at_async(file, offset, &mut payload, spec, truncated_message).await?; + Ok(payload) +} + +#[cfg(feature = "async")] +async fn read_mhas_packet_payload_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + offset: u64, + size: u64, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + let len = + usize::try_from(size).map_err(|_| MuxError::LayoutOverflow("MHAS packet payload size"))?; + let mut payload = vec![0_u8; len]; + read_segmented_bytes_async( + file, + segments, + u64::try_from(len) + .map_err(|_| MuxError::LayoutOverflow("MHAS packet payload size"))? + .checked_add(offset) + .ok_or(MuxError::LayoutOverflow("MHAS packet payload range"))?, + offset, + &mut payload, + spec, + truncated_message, + ) + .await?; + Ok(payload) +} + +fn read_mhas_sync_marker_sync(file: &mut File, offset: u64, spec: &str) -> Result { + let mut marker = [0_u8; 1]; + read_exact_at_sync( + file, + offset, + &mut marker, + spec, + "MHAS sync payload is truncated", + )?; + Ok(marker[0]) +} + +fn read_mhas_sync_marker_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + offset: u64, + spec: &str, +) -> Result { + let mut marker = [0_u8; 1]; + read_segmented_bytes_sync( + file, + segments, + offset + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("MHAS sync payload range"))?, + offset, + &mut marker, + spec, + "MHAS sync payload is truncated", + )?; + Ok(marker[0]) +} + +#[cfg(feature = "async")] +async fn read_mhas_sync_marker_async( + file: &mut TokioFile, + offset: u64, + spec: &str, +) -> Result { + let mut marker = [0_u8; 1]; + read_exact_at_async( + file, + offset, + &mut marker, + spec, + "MHAS sync payload is truncated", + ) + .await?; + Ok(marker[0]) +} + +#[cfg(feature = "async")] +async fn read_mhas_sync_marker_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + offset: u64, + spec: &str, +) -> Result { + let mut marker = [0_u8; 1]; + read_segmented_bytes_async( + file, + segments, + offset + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("MHAS sync payload range"))?, + offset, + &mut marker, + spec, + "MHAS sync payload is truncated", + ) + .await?; + Ok(marker[0]) +} + +fn read_mhas_frame_sap_sync(file: &mut File, offset: u64, spec: &str) -> Result { + let mut byte = [0_u8; 1]; + read_exact_at_sync( + file, + offset, + &mut byte, + spec, + "MHAS frame payload is truncated before the SAP flag", + )?; + Ok(byte[0] & 0x80 != 0) +} + +fn read_mhas_frame_sap_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + offset: u64, + spec: &str, +) -> Result { + let mut byte = [0_u8; 1]; + read_segmented_bytes_sync( + file, + segments, + offset + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("MHAS frame SAP range"))?, + offset, + &mut byte, + spec, + "MHAS frame payload is truncated before the SAP flag", + )?; + Ok(byte[0] & 0x80 != 0) +} + +#[cfg(feature = "async")] +async fn read_mhas_frame_sap_async( + file: &mut TokioFile, + offset: u64, + spec: &str, +) -> Result { + let mut byte = [0_u8; 1]; + read_exact_at_async( + file, + offset, + &mut byte, + spec, + "MHAS frame payload is truncated before the SAP flag", + ) + .await?; + Ok(byte[0] & 0x80 != 0) +} + +#[cfg(feature = "async")] +async fn read_mhas_frame_sap_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + offset: u64, + spec: &str, +) -> Result { + let mut byte = [0_u8; 1]; + read_segmented_bytes_async( + file, + segments, + offset + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("MHAS frame SAP range"))?, + offset, + &mut byte, + spec, + "MHAS frame payload is truncated before the SAP flag", + ) + .await?; + Ok(byte[0] & 0x80 != 0) +} + +fn parse_mhas_config_packet(payload: &[u8], spec: &str) -> Result { + let mut reader = MhasBitCursor::new(payload); + let profile_level_indication = + u8::try_from(reader.read_bits(8, spec, "MHAS profile-level indication")?).unwrap(); + let sample_rate_index = + usize::try_from(reader.read_bits(5, spec, "MHAS sample-rate index")?).unwrap(); + let sample_rate = if sample_rate_index == 0x1F { + u32::try_from(reader.read_bits(24, spec, "MHAS explicit sample rate")?) + .map_err(|_| MuxError::LayoutOverflow("MHAS explicit sample rate"))? + } else { + let value = *MHAS_SAMPLE_RATE_TABLE + .get(sample_rate_index) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "MHAS configuration used unsupported sample-rate index {sample_rate_index}" + ), + })?; + if value == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "MHAS configuration used reserved sample-rate index {sample_rate_index}" + ), + }); + } + value + }; + let frame_length = match reader.read_bits(3, spec, "MHAS frame-length index")? { + 0 | 2 => 768, + _ => 1024, + }; + let _core_sbr_flag = reader.read_bool(spec, "MHAS core-SBR flag")?; + let _resilient_flag = reader.read_bool(spec, "MHAS resilient flag")?; + let speaker_layout_type = + u8::try_from(reader.read_bits(2, spec, "MHAS speaker-layout type")?).unwrap(); + let (_reference_channel_layout, channel_count) = if speaker_layout_type == 0 { + let cicp_layout = + u8::try_from(reader.read_bits(6, spec, "MHAS CICP speaker layout")?).unwrap(); + ( + cicp_layout, + mhas_channel_count_from_cicp(cicp_layout, spec)?, + ) + } else { + let count = + reader.read_escaped_value(5, 8, 16, spec, "MHAS explicit speaker count minus one")?; + let count = count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("MHAS explicit speaker count"))?; + let channel_count = u16::try_from(count) + .map_err(|_| MuxError::LayoutOverflow("MHAS explicit speaker count"))?; + (0xFF, channel_count) + }; + if sample_rate == 0 || channel_count == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MHAS configuration did not yield a usable sample rate or channel count" + .to_string(), + }); + } + Ok(ParsedMhasConfig { + profile_level_indication, + sample_rate, + frame_length, + channel_count, + config_payload_size: payload.len(), + }) +} + +fn parse_mhas_truncation_packet(payload: &[u8], spec: &str) -> Result<(), MuxError> { + let mut reader = MhasBitCursor::new(payload); + let is_active = reader.read_bool(spec, "MHAS truncation active flag")?; + let _reserved = reader.read_bool(spec, "MHAS truncation reserved flag")?; + let _trunc_from_begin = reader.read_bool(spec, "MHAS truncation direction flag")?; + let truncated_samples = + u16::try_from(reader.read_bits(13, spec, "MHAS truncated sample count")?).unwrap(); + if is_active && truncated_samples != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MHAS truncation packets with active sample trimming are not supported yet" + .to_string(), + }); + } + Ok(()) +} + +fn mhas_channel_count_from_cicp(cicp_layout: u8, spec: &str) -> Result { + let count = match cicp_layout { + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + 6 => 6, + 7 => 8, + 8 => 2, + 9 => 3, + 10 => 4, + 11 => 7, + 12 => 8, + 13 => 24, + 14 => 8, + 15 => 12, + 16 => 10, + 17 => 12, + 18 => 14, + 19 => 10, + 20 => 14, + _ => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "MHAS configuration used unsupported CICP speaker layout {cicp_layout}" + ), + }); + } + }; + Ok(count) +} diff --git a/src/mux/demux/mod.rs b/src/mux/demux/mod.rs new file mode 100644 index 0000000..3a6e5f5 --- /dev/null +++ b/src/mux/demux/mod.rs @@ -0,0 +1,207 @@ +//! Private mux-ingest detectors and codec-focused direct parsers. +//! +//! The public mux surface stays path-first under [`crate::mux`], while this internal tree owns +//! the codec and container-specific detection and parsing work needed to turn those paths into +//! truthful staged tracks. + +mod aac; +mod ac3; +mod ac4; +mod alac; +mod amr; +mod annexb_common; +mod av1; +mod avi; +mod avs3; +mod bmp; +mod caf_common; +mod container_common; +mod dash; +pub(super) mod detect; +mod dts; +mod eac3; +mod flac; +mod h263; +mod h264; +mod h265; +mod iamf; +mod ivf_common; +mod j2k; +mod jpeg; +mod latm; +mod mhas; +mod mp3; +mod mp4v; +mod mpeg2v; +mod nhml; +mod ogg_common; +mod opus; +mod pcm; +mod png; +mod prores; +mod ps; +mod qcp; +mod raw_visual; +mod rawvid; +mod saf; +mod speex; +mod theora; +mod truehd; +mod ts; +mod vobsub; +mod vorbis; +mod vp10; +mod vp8; +mod vp9; +mod vvc; + +#[cfg(feature = "async")] +pub(super) use aac::scan_adts_file_async; +pub(super) use aac::scan_adts_file_sync; +#[cfg(feature = "async")] +pub(super) use ac3::scan_ac3_file_async; +pub(super) use ac3::scan_ac3_file_sync; +#[cfg(feature = "async")] +pub(super) use ac4::scan_ac4_file_async; +pub(super) use ac4::scan_ac4_file_sync; +#[cfg(feature = "async")] +pub(super) use alac::scan_caf_alac_file_async; +pub(super) use alac::scan_caf_alac_file_sync; +#[cfg(feature = "async")] +pub(super) use amr::{scan_amr_file_async, scan_amr_wb_file_async}; +pub(super) use amr::{scan_amr_file_sync, scan_amr_wb_file_sync}; +pub(super) use annexb_common::{nal_to_rbsp, read_ue_labeled}; +#[cfg(feature = "async")] +pub(super) use av1::scan_av1_file_async; +pub(super) use av1::{ParsedAv1Track, ParsedAv1TrackSource, scan_av1_file_sync}; +#[cfg(feature = "async")] +pub(super) use avi::scan_avi_source_async; +pub(super) use avi::scan_avi_source_sync; +#[cfg(feature = "async")] +pub(super) use bmp::scan_bmp_file_async; +pub(super) use bmp::scan_bmp_file_sync; +#[cfg(feature = "async")] +pub(super) use caf_common::detect_caf_track_kind_async; +pub(super) use caf_common::detect_caf_track_kind_sync; +#[cfg(feature = "async")] +pub(super) use dash::parse_dash_source_async; +pub(super) use dash::{ParsedDashSource, parse_dash_source_sync}; +pub(super) use detect::{ + DetectedContainerPathKind, DetectedPathTrackKind, + detect_container_path_kind_from_path_and_prefix, detect_id3_wrapped_audio_from_prefix, + detect_path_track_kind_from_prefix, id3v2_size_from_prefix, +}; +#[cfg(feature = "async")] +pub(super) use dts::scan_dts_file_async; +#[cfg(feature = "async")] +pub(super) use dts::wrapped_dts_family_has_native_core_sync_async; +pub(super) use dts::{scan_dts_file_sync, wrapped_dts_family_has_native_core_sync_sync}; +#[cfg(feature = "async")] +pub(super) use eac3::scan_eac3_file_async; +pub(super) use eac3::scan_eac3_file_sync; +#[cfg(feature = "async")] +pub(super) use flac::scan_flac_file_async; +#[cfg(feature = "async")] +pub(super) use flac::scan_ogg_flac_file_async; +pub(super) use flac::{scan_flac_file_sync, scan_ogg_flac_file_sync}; +#[cfg(feature = "async")] +pub(super) use h263::scan_h263_file_async; +pub(super) use h263::scan_h263_file_sync; +pub(super) use h264::build_h264_sample_entry_from_avc_config_with_box_type_and_options; +#[cfg(feature = "async")] +pub(super) use h264::stage_annex_b_h264_async; +pub(super) use h264::stage_annex_b_h264_sync; +#[cfg(feature = "async")] +pub(super) use h265::stage_annex_b_h265_async; +pub(super) use h265::stage_annex_b_h265_sync; +#[cfg(feature = "async")] +pub(super) use iamf::scan_iamf_file_async; +pub(super) use iamf::scan_iamf_file_sync; +#[cfg(feature = "async")] +pub(super) use j2k::scan_j2k_file_async; +pub(super) use j2k::scan_j2k_file_sync; +#[cfg(feature = "async")] +pub(super) use jpeg::scan_jpeg_file_async; +pub(super) use jpeg::scan_jpeg_file_sync; +#[cfg(feature = "async")] +pub(super) use latm::scan_latm_file_async; +pub(super) use latm::scan_latm_file_sync; +#[cfg(feature = "async")] +pub(super) use mhas::scan_mhas_file_async; +pub(super) use mhas::scan_mhas_file_sync; +#[cfg(feature = "async")] +pub(super) use mp3::scan_mp3_file_async; +pub(super) use mp3::scan_mp3_file_sync; +pub(super) use mp4v::mp4v_profile_level_indication; +#[cfg(feature = "async")] +pub(super) use mp4v::scan_mp4v_file_async; +pub(super) use mp4v::scan_mp4v_file_sync; +#[cfg(feature = "async")] +pub(super) use mpeg2v::scan_mpeg2v_file_async; +pub(super) use mpeg2v::scan_mpeg2v_file_sync; +#[cfg(feature = "async")] +pub(super) use nhml::parse_nhml_source_async; +pub(super) use nhml::{ + DetectedNhmlSidecarKind, ParsedNhmlSource, ParsedNhmlSourceSpec, detect_nhml_sidecar_kind, + parse_nhml_source_sync, +}; +#[cfg(feature = "async")] +pub(super) use ogg_common::detect_ogg_track_kind_async; +pub(super) use ogg_common::detect_ogg_track_kind_sync; +#[cfg(feature = "async")] +pub(super) use opus::scan_ogg_opus_file_async; +pub(super) use opus::scan_ogg_opus_file_sync; +#[cfg(feature = "async")] +pub(super) use pcm::scan_pcm_file_async; +pub(super) use pcm::{PcmContainerKind, scan_pcm_file_sync}; +#[cfg(feature = "async")] +pub(super) use png::scan_png_file_async; +pub(super) use png::scan_png_file_sync; +#[cfg(feature = "async")] +pub(super) use prores::scan_prores_file_async; +pub(super) use prores::scan_prores_file_sync; +#[cfg(feature = "async")] +pub(super) use ps::scan_program_stream_async; +pub(super) use ps::scan_program_stream_sync; +#[cfg(feature = "async")] +pub(super) use qcp::scan_qcp_file_async; +pub(super) use qcp::scan_qcp_file_sync; +#[cfg(feature = "async")] +pub(super) use rawvid::scan_raw_video_file_async; +#[cfg(feature = "async")] +pub(super) use rawvid::scan_y4m_file_async; +pub(super) use rawvid::{scan_raw_video_file_sync, scan_y4m_file_sync}; +#[cfg(feature = "async")] +pub(super) use saf::scan_saf_source_async; +pub(super) use saf::scan_saf_source_sync; +#[cfg(feature = "async")] +pub(super) use speex::scan_ogg_speex_file_async; +pub(super) use speex::scan_ogg_speex_file_sync; +#[cfg(feature = "async")] +pub(super) use theora::scan_ogg_theora_file_async; +pub(super) use theora::scan_ogg_theora_file_sync; +#[cfg(feature = "async")] +pub(super) use truehd::scan_truehd_file_async; +pub(super) use truehd::scan_truehd_file_sync; +pub(super) use ts::TransportStreamScanResult; +#[cfg(feature = "async")] +pub(super) use ts::scan_transport_stream_async; +pub(super) use ts::scan_transport_stream_sync; +#[cfg(feature = "async")] +pub(super) use vobsub::scan_vobsub_source_async; +pub(super) use vobsub::scan_vobsub_source_sync; +#[cfg(feature = "async")] +pub(super) use vorbis::scan_ogg_vorbis_file_async; +pub(super) use vorbis::scan_ogg_vorbis_file_sync; +#[cfg(feature = "async")] +pub(super) use vp8::scan_vp8_file_async; +pub(super) use vp8::scan_vp8_file_sync; +#[cfg(feature = "async")] +pub(super) use vp9::scan_vp9_file_async; +pub(super) use vp9::scan_vp9_file_sync; +#[cfg(feature = "async")] +pub(super) use vp10::scan_vp10_file_async; +pub(super) use vp10::scan_vp10_file_sync; +#[cfg(feature = "async")] +pub(super) use vvc::stage_annex_b_vvc_async; +pub(super) use vvc::stage_annex_b_vvc_sync; diff --git a/src/mux/demux/mp3.rs b/src/mux/demux/mp3.rs new file mode 100644 index 0000000..2713265 --- /dev/null +++ b/src/mux/demux/mp3.rs @@ -0,0 +1,833 @@ +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +use crate::FourCc; +use crate::boxes::AnyTypeBox; +use crate::boxes::iso14496_12::{AudioSampleEntry, Btrt, SampleEntry}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{SegmentedMuxSourceSegment, StagedSample, read_exact_at_sync}; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::read_segmented_bytes_sync; + +pub(in crate::mux) struct ParsedMp3Track { + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +pub(in crate::mux) struct ParsedMp3FrameHeader { + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) channel_count: u16, + pub(in crate::mux) sample_duration: u32, + pub(in crate::mux) frame_length: u32, +} + +pub(in crate::mux) fn scan_mp3_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::<(u32, u16, u32)>; + while offset < file_size { + if let Some(next_offset) = skip_id3v2_tag_sync(&mut file, file_size, offset, spec)? { + offset = next_offset; + continue; + } + if skip_trailing_id3v1_tag_offset(file_size, offset, &mut file)? { + break; + } + if file_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated MP3 frame header".to_string(), + }); + } + let mut header = [0_u8; 4]; + read_exact_at_sync( + &mut file, + offset, + &mut header, + spec, + "truncated MP3 frame header", + )?; + if header[0] != 0xFF || header[1] & 0xE0 != 0xE0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing MP3 sync word at byte offset {offset}"), + }); + } + let parsed = parse_mp3_frame_header(&header, offset, spec)?; + let frame_length = usize::try_from(parsed.frame_length) + .map_err(|_| MuxError::LayoutOverflow("MP3 frame length"))?; + if offset + .checked_add(u64::try_from(frame_length).unwrap_or(u64::MAX)) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated MP3 frame at byte offset {offset}"), + }); + } + let descriptor = ( + parsed.sample_rate, + parsed.channel_count, + parsed.sample_duration, + ); + if let Some(expected) = expected { + if expected != descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MP3 frames changed sample rate or channel layout mid-stream" + .to_string(), + }); + } + } else { + expected = Some(descriptor); + } + samples.push(StagedSample { + data_offset: offset, + data_size: u32::try_from(frame_length) + .map_err(|_| MuxError::LayoutOverflow("MP3 frame size"))?, + duration: parsed.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add( + u64::try_from(frame_length) + .map_err(|_| MuxError::LayoutOverflow("MP3 frame length"))?, + ) + .ok_or(MuxError::LayoutOverflow("MP3 frame offset"))?; + } + + let (sample_rate, channel_count, _sample_duration) = + expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MP3 input contained no MPEG audio frames".to_string(), + })?; + Ok(ParsedMp3Track { + sample_rate, + sample_entry_box: build_mp3_sample_entry_box( + sample_rate, + channel_count, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?, + samples, + }) +} + +pub(in crate::mux) fn scan_mp3_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::<(u32, u16, u32)>; + while offset < total_size { + if total_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated MP3 frame header".to_string(), + }); + } + let mut header = [0_u8; 4]; + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated MP3 frame header", + )?; + if header[0] != 0xFF || header[1] & 0xE0 != 0xE0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing MP3 sync word at logical byte offset {offset}"), + }); + } + let parsed = parse_mp3_frame_header(&header, offset, spec)?; + let frame_length = usize::try_from(parsed.frame_length) + .map_err(|_| MuxError::LayoutOverflow("MP3 frame length"))?; + if offset + .checked_add(u64::try_from(frame_length).unwrap_or(u64::MAX)) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated MP3 frame at logical byte offset {offset}"), + }); + } + let descriptor = ( + parsed.sample_rate, + parsed.channel_count, + parsed.sample_duration, + ); + if let Some(expected) = expected { + if expected != descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MP3 frames changed sample rate or channel layout mid-stream" + .to_string(), + }); + } + } else { + expected = Some(descriptor); + } + samples.push(StagedSample { + data_offset: offset, + data_size: u32::try_from(frame_length) + .map_err(|_| MuxError::LayoutOverflow("MP3 frame size"))?, + duration: parsed.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add( + u64::try_from(frame_length) + .map_err(|_| MuxError::LayoutOverflow("MP3 frame length"))?, + ) + .ok_or(MuxError::LayoutOverflow("MP3 frame offset"))?; + } + + let (sample_rate, channel_count, _sample_duration) = + expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MP3 input contained no MPEG audio frames".to_string(), + })?; + Ok(ParsedMp3Track { + sample_rate, + sample_entry_box: build_mp3_sample_entry_box( + sample_rate, + channel_count, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?, + samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_mp3_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::<(u32, u16, u32)>; + while offset < file_size { + if let Some(next_offset) = skip_id3v2_tag_async(&mut file, file_size, offset, spec).await? { + offset = next_offset; + continue; + } + if skip_trailing_id3v1_tag_offset_async(file_size, offset, &mut file).await? { + break; + } + if file_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated MP3 frame header".to_string(), + }); + } + let mut header = [0_u8; 4]; + read_exact_at_async( + &mut file, + offset, + &mut header, + spec, + "truncated MP3 frame header", + ) + .await?; + if header[0] != 0xFF || header[1] & 0xE0 != 0xE0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing MP3 sync word at byte offset {offset}"), + }); + } + let parsed = parse_mp3_frame_header(&header, offset, spec)?; + let frame_length = usize::try_from(parsed.frame_length) + .map_err(|_| MuxError::LayoutOverflow("MP3 frame length"))?; + if offset + .checked_add(u64::try_from(frame_length).unwrap_or(u64::MAX)) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated MP3 frame at byte offset {offset}"), + }); + } + let descriptor = ( + parsed.sample_rate, + parsed.channel_count, + parsed.sample_duration, + ); + if let Some(expected) = expected { + if expected != descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MP3 frames changed sample rate or channel layout mid-stream" + .to_string(), + }); + } + } else { + expected = Some(descriptor); + } + samples.push(StagedSample { + data_offset: offset, + data_size: u32::try_from(frame_length) + .map_err(|_| MuxError::LayoutOverflow("MP3 frame size"))?, + duration: parsed.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add( + u64::try_from(frame_length) + .map_err(|_| MuxError::LayoutOverflow("MP3 frame length"))?, + ) + .ok_or(MuxError::LayoutOverflow("MP3 frame offset"))?; + } + + let (sample_rate, channel_count, _sample_duration) = + expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MP3 input contained no MPEG audio frames".to_string(), + })?; + Ok(ParsedMp3Track { + sample_rate, + sample_entry_box: build_mp3_sample_entry_box( + sample_rate, + channel_count, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?, + samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_mp3_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut expected = None::<(u32, u16, u32)>; + while offset < total_size { + if total_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated MP3 frame header".to_string(), + }); + } + let mut header = [0_u8; 4]; + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated MP3 frame header", + ) + .await?; + if header[0] != 0xFF || header[1] & 0xE0 != 0xE0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing MP3 sync word at logical byte offset {offset}"), + }); + } + let parsed = parse_mp3_frame_header(&header, offset, spec)?; + let frame_length = usize::try_from(parsed.frame_length) + .map_err(|_| MuxError::LayoutOverflow("MP3 frame length"))?; + if offset + .checked_add(u64::try_from(frame_length).unwrap_or(u64::MAX)) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated MP3 frame at logical byte offset {offset}"), + }); + } + let descriptor = ( + parsed.sample_rate, + parsed.channel_count, + parsed.sample_duration, + ); + if let Some(expected) = expected { + if expected != descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MP3 frames changed sample rate or channel layout mid-stream" + .to_string(), + }); + } + } else { + expected = Some(descriptor); + } + samples.push(StagedSample { + data_offset: offset, + data_size: u32::try_from(frame_length) + .map_err(|_| MuxError::LayoutOverflow("MP3 frame size"))?, + duration: parsed.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add( + u64::try_from(frame_length) + .map_err(|_| MuxError::LayoutOverflow("MP3 frame length"))?, + ) + .ok_or(MuxError::LayoutOverflow("MP3 frame offset"))?; + } + + let (sample_rate, channel_count, _sample_duration) = + expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MP3 input contained no MPEG audio frames".to_string(), + })?; + Ok(ParsedMp3Track { + sample_rate, + sample_entry_box: build_mp3_sample_entry_box( + sample_rate, + channel_count, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?, + samples, + }) +} + +pub(in crate::mux) fn build_mp3_sample_entry_box( + sample_rate: u32, + channel_count: u16, + samples: I, +) -> Result, MuxError> +where + I: IntoIterator, +{ + let mut sample_entry = AudioSampleEntry::default(); + sample_entry.set_box_type(FourCc::from_bytes(*b".mp3")); + sample_entry.sample_entry = SampleEntry { + box_type: FourCc::from_bytes(*b".mp3"), + data_reference_index: 1, + }; + sample_entry.channel_count = channel_count; + sample_entry.sample_size = 16; + sample_entry.sample_rate = sample_rate << 16; + + let btrt = build_mp3_btrt(samples, sample_rate)?; + let children = super::super::mp4::encode_typed_box(&btrt, &[])?; + super::super::mp4::encode_typed_box(&sample_entry, &children) +} + +fn build_mp3_btrt(samples: I, sample_rate: u32) -> Result +where + I: IntoIterator, +{ + if sample_rate == 0 { + return Ok(Btrt::default()); + } + + let mut saw_sample = false; + let mut buffer_size_db = 0_u32; + let mut total_payload_bytes = 0_u64; + let mut total_duration = 0_u64; + let mut max_window_payload_bytes = 0_u64; + let mut current_window_payload_bytes = 0_u64; + let mut window_start_decode_time = 0_u64; + let mut sample_decode_time = 0_u64; + for (data_size, duration) in samples { + saw_sample = true; + buffer_size_db = buffer_size_db.max(data_size); + total_payload_bytes = total_payload_bytes + .checked_add(u64::from(data_size)) + .ok_or(MuxError::LayoutOverflow("MP3 total payload bytes"))?; + total_duration = total_duration + .checked_add(u64::from(duration)) + .ok_or(MuxError::LayoutOverflow("MP3 total duration"))?; + current_window_payload_bytes = current_window_payload_bytes + .checked_add(u64::from(data_size)) + .ok_or(MuxError::LayoutOverflow("MP3 bitrate window payload"))?; + if sample_decode_time > window_start_decode_time.saturating_add(u64::from(sample_rate)) { + max_window_payload_bytes = max_window_payload_bytes.max(current_window_payload_bytes); + window_start_decode_time = sample_decode_time; + current_window_payload_bytes = 0; + } + sample_decode_time = sample_decode_time + .checked_add(u64::from(duration)) + .ok_or(MuxError::LayoutOverflow("MP3 decode time"))?; + } + if !saw_sample { + return Ok(Btrt::default()); + } + if total_duration == 0 { + return Ok(Btrt::default()); + } + + let avg_bitrate = total_payload_bytes + .checked_mul(8) + .and_then(|bits| bits.checked_mul(u64::from(sample_rate))) + .ok_or(MuxError::LayoutOverflow("MP3 average bitrate"))? + / total_duration; + let avg_bitrate = avg_bitrate & !7; + + let max_bitrate = if max_window_payload_bytes == 0 { + avg_bitrate + } else { + max_window_payload_bytes + .checked_mul(8) + .ok_or(MuxError::LayoutOverflow("MP3 maximum bitrate"))? + }; + + Ok(Btrt { + buffer_size_db, + max_bitrate: u32::try_from(max_bitrate) + .map_err(|_| MuxError::LayoutOverflow("MP3 maximum bitrate"))?, + avg_bitrate: u32::try_from(avg_bitrate) + .map_err(|_| MuxError::LayoutOverflow("MP3 average bitrate"))?, + }) +} + +pub(in crate::mux) fn parse_mp3_frame_header( + header: &[u8; 4], + offset: u64, + spec: &str, +) -> Result { + if header[0] != 0xFF || header[1] & 0xE0 != 0xE0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing MP3 sync word at byte offset {offset}"), + }); + } + let version_id = (header[1] >> 3) & 0x03; + if version_id == 0x01 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("reserved MP3 MPEG version at byte offset {offset}"), + }); + } + let layer = (header[1] >> 1) & 0x03; + if layer == 0x00 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("reserved MPEG audio layer at byte offset {offset}"), + }); + } + if layer == 0x03 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "the current raw MPEG audio mux importer supports Layer II and Layer III frames only" + .to_string(), + }); + } + let bitrate_index = (header[2] >> 4) & 0x0F; + if bitrate_index == 0 || bitrate_index == 0x0F { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported MP3 bitrate index {bitrate_index}"), + }); + } + let sample_rate_index = (header[2] >> 2) & 0x03; + let sample_rate = mp3_sample_rate(version_id, sample_rate_index).ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported MP3 sample-rate index {sample_rate_index}"), + } + })?; + let bitrate_bps = + mpeg_audio_bitrate_bps(version_id, layer, bitrate_index).ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "unsupported MPEG audio bitrate index {bitrate_index} for layer {}", + mpeg_audio_layer_name(layer) + ), + } + })?; + let padding = u32::from((header[2] >> 1) & 0x01); + let channel_count = if (header[3] >> 6) == 0x03 { 1 } else { 2 }; + let sample_duration = mpeg_audio_sample_duration(version_id, layer); + let frame_length = + mpeg_audio_frame_length(version_id, layer, bitrate_bps, sample_rate, padding).ok_or_else( + || MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "unsupported MPEG audio frame-length calculation for layer {}", + mpeg_audio_layer_name(layer) + ), + }, + )?; + if frame_length < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "MP3 frame length underflowed the header size".to_string(), + }); + } + Ok(ParsedMp3FrameHeader { + sample_rate, + channel_count, + sample_duration, + frame_length, + }) +} + +fn skip_id3v2_tag(header: &[u8], spec: &str) -> Result, MuxError> { + if header.len() < 10 { + return Ok(None); + } + if &header[..3] != b"ID3" { + return Ok(None); + } + if header[6..10].iter().any(|byte| byte & 0x80 != 0) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "ID3v2 tag uses a non-synchsafe size field".to_string(), + }); + } + let tag_size = (usize::from(header[6]) << 21) + | (usize::from(header[7]) << 14) + | (usize::from(header[8]) << 7) + | usize::from(header[9]); + let footer_size = if header[5] & 0x10 != 0 { 10 } else { 0 }; + let total_size = 10_usize + .checked_add(tag_size) + .and_then(|size| size.checked_add(footer_size)) + .ok_or(MuxError::LayoutOverflow("ID3 tag size"))?; + Ok(Some(total_size)) +} + +fn skip_id3v2_tag_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, +) -> Result, MuxError> { + if file_size - offset < 10 { + return Ok(None); + } + let mut header = [0_u8; 10]; + read_exact_at_sync( + file, + offset, + &mut header, + spec, + "truncated ID3v2 tag ahead of MPEG audio frames", + )?; + skip_id3v2_tag(&header, spec)? + .map(|size| { + offset + .checked_add( + u64::try_from(size).map_err(|_| MuxError::LayoutOverflow("ID3 tag size"))?, + ) + .ok_or(MuxError::LayoutOverflow("ID3 tag offset")) + }) + .transpose() +} + +#[cfg(feature = "async")] +async fn skip_id3v2_tag_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, +) -> Result, MuxError> { + if file_size - offset < 10 { + return Ok(None); + } + let mut header = [0_u8; 10]; + read_exact_at_async( + file, + offset, + &mut header, + spec, + "truncated ID3v2 tag ahead of MPEG audio frames", + ) + .await?; + skip_id3v2_tag(&header, spec)? + .map(|size| { + offset + .checked_add( + u64::try_from(size).map_err(|_| MuxError::LayoutOverflow("ID3 tag size"))?, + ) + .ok_or(MuxError::LayoutOverflow("ID3 tag offset")) + }) + .transpose() +} + +fn skip_trailing_id3v1_tag(header: &[u8]) -> bool { + header.len() == 128 && &header[..3] == b"TAG" +} + +fn skip_trailing_id3v1_tag_offset( + file_size: u64, + offset: u64, + file: &mut File, +) -> Result { + if offset + 128 != file_size { + return Ok(false); + } + let mut tag = [0_u8; 128]; + file.seek(SeekFrom::Start(offset))?; + file.read_exact(&mut tag)?; + Ok(skip_trailing_id3v1_tag(&tag)) +} + +#[cfg(feature = "async")] +async fn skip_trailing_id3v1_tag_offset_async( + file_size: u64, + offset: u64, + file: &mut TokioFile, +) -> Result { + if offset + 128 != file_size { + return Ok(false); + } + let mut tag = [0_u8; 128]; + file.seek(SeekFrom::Start(offset)).await?; + file.read_exact(&mut tag).await?; + Ok(skip_trailing_id3v1_tag(&tag)) +} + +const fn mp3_sample_rate(version_id: u8, sample_rate_index: u8) -> Option { + let base = match sample_rate_index { + 0 => 44_100, + 1 => 48_000, + 2 => 32_000, + _ => return None, + }; + match version_id { + 0x03 => Some(base), + 0x02 => Some(base / 2), + 0x00 => Some(base / 4), + _ => None, + } +} + +const fn mpeg_audio_bitrate_bps(version_id: u8, layer: u8, bitrate_index: u8) -> Option { + let kbps = match layer { + 0x02 => match bitrate_index { + 1 => 32, + 2 => 48, + 3 => 56, + 4 => 64, + 5 => 80, + 6 => 96, + 7 => 112, + 8 => 128, + 9 => 160, + 10 => 192, + 11 => 224, + 12 => 256, + 13 => 320, + 14 => 384, + _ => return None, + }, + 0x01 => match version_id { + 0x03 => match bitrate_index { + 1 => 32, + 2 => 40, + 3 => 48, + 4 => 56, + 5 => 64, + 6 => 80, + 7 => 96, + 8 => 112, + 9 => 128, + 10 => 160, + 11 => 192, + 12 => 224, + 13 => 256, + 14 => 320, + _ => return None, + }, + 0x02 | 0x00 => match bitrate_index { + 1 => 8, + 2 => 16, + 3 => 24, + 4 => 32, + 5 => 40, + 6 => 48, + 7 => 56, + 8 => 64, + 9 => 80, + 10 => 96, + 11 => 112, + 12 => 128, + 13 => 144, + 14 => 160, + _ => return None, + }, + _ => return None, + }, + _ => return None, + }; + Some(kbps * 1_000) +} + +const fn mpeg_audio_sample_duration(version_id: u8, layer: u8) -> u32 { + match layer { + 0x02 => 1152, + 0x01 => { + if version_id == 0x03 { + 1152 + } else { + 576 + } + } + _ => 0, + } +} + +const fn mpeg_audio_frame_length( + version_id: u8, + layer: u8, + bitrate_bps: u32, + sample_rate: u32, + padding: u32, +) -> Option { + match layer { + 0x02 => Some(((144_u32 * bitrate_bps) / sample_rate).saturating_add(padding)), + 0x01 => { + if version_id == 0x03 { + Some(((144_u32 * bitrate_bps) / sample_rate).saturating_add(padding)) + } else { + Some(((72_u32 * bitrate_bps) / sample_rate).saturating_add(padding)) + } + } + _ => None, + } +} + +const fn mpeg_audio_layer_name(layer: u8) -> &'static str { + match layer { + 0x03 => "I", + 0x02 => "II", + 0x01 => "III", + _ => "reserved", + } +} diff --git a/src/mux/demux/mp4v.rs b/src/mux/demux/mp4v.rs new file mode 100644 index 0000000..f46ac43 --- /dev/null +++ b/src/mux/demux/mp4v.rs @@ -0,0 +1,1043 @@ +use std::fs::File; +use std::io::Cursor; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::bitio::BitReader; +use crate::boxes::iso14496_12::Pasp; +use crate::boxes::iso14496_14::{ + DECODER_CONFIG_DESCRIPTOR_TAG, DECODER_SPECIFIC_INFO_TAG, DecoderConfigDescriptor, Descriptor, + ES_DESCRIPTOR_TAG, EsDescriptor, Esds, SL_CONFIG_DESCRIPTOR_TAG, +}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{ + SegmentedMuxSourceSegment, StagedSample, build_btrt_from_sample_sizes_with_total_duration, + build_visual_sample_entry_box_with_compressor_name, read_exact_at_sync, +}; +use super::annexb_common::{read_bit_labeled, read_bits_u8_labeled, read_bits_u16_labeled}; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::read_segmented_bytes_sync; + +const SAMPLE_ENTRY_MP4V: FourCc = FourCc::from_bytes(*b"mp4v"); +const DIRECT_TIMESCALE: u32 = 25_000; +const DEFAULT_SAMPLE_DURATION: u32 = 1_000; +const SCAN_CHUNK_SIZE: usize = 16 * 1024; +const VOS_START_CODE: u8 = 0xB0; +const USER_DATA_START_CODE: u8 = 0xB2; +const GROUP_OF_VOP_START_CODE: u8 = 0xB3; +const VO_START_CODE: u8 = 0xB5; +const VOP_START_CODE: u8 = 0xB6; + +pub(in crate::mux) struct ParsedMp4vTrack { + pub(in crate::mux) width: u16, + pub(in crate::mux) height: u16, + pub(in crate::mux) timescale: u32, + pub(in crate::mux) decoder_specific_info: Vec, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +pub(in crate::mux) fn scan_mp4v_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + parse_mp4v_stream_sync(file_size, spec, |offset, buf, message| { + read_exact_at_sync(&mut file, offset, buf, spec, message) + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_mp4v_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + parse_mp4v_stream_file_async(&mut file, file_size, spec).await +} + +pub(in crate::mux) fn scan_mp4v_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + parse_mp4v_stream_sync(total_size, spec, |offset, buf, message| { + read_segmented_bytes_sync(file, segments, total_size, offset, buf, spec, message) + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_mp4v_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + parse_mp4v_segmented_stream_async(file, segments, total_size, spec).await +} + +pub(in crate::mux) fn build_direct_mp4v_sample_entry_box( + width: u16, + height: u16, + decoder_specific_info: &[u8], + timescale: u32, + samples: I, +) -> Result, MuxError> +where + I: IntoIterator, +{ + build_direct_mp4v_sample_entry_box_with_total_duration( + width, + height, + decoder_specific_info, + timescale, + samples, + None, + ) +} + +pub(in crate::mux) fn build_direct_mp4v_sample_entry_box_with_total_duration( + width: u16, + height: u16, + decoder_specific_info: &[u8], + timescale: u32, + samples: I, + total_duration_override: Option, +) -> Result, MuxError> +where + I: IntoIterator, +{ + let decoder_bitrates = build_btrt_from_sample_sizes_with_total_duration( + samples, + timescale, + total_duration_override, + )?; + let mut esds = Esds::default(); + esds.descriptors = vec![ + Descriptor { + tag: ES_DESCRIPTOR_TAG, + es_descriptor: Some(EsDescriptor::default()), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some(DecoderConfigDescriptor { + object_type_indication: 0x20, + stream_type: 4, + reserved: true, + buffer_size_db: decoder_bitrates.buffer_size_db, + max_bitrate: decoder_bitrates.max_bitrate, + avg_bitrate: decoder_bitrates.avg_bitrate, + ..DecoderConfigDescriptor::default() + }), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_SPECIFIC_INFO_TAG, + size: u32::try_from(decoder_specific_info.len()) + .map_err(|_| MuxError::LayoutOverflow("MPEG-4 Part 2 decoder config size"))?, + data: decoder_specific_info.to_vec(), + ..Descriptor::default() + }, + Descriptor { + tag: SL_CONFIG_DESCRIPTOR_TAG, + size: 1, + data: vec![0x02], + ..Descriptor::default() + }, + ]; + esds.normalize_descriptor_sizes_for_mux() + .map_err(|_| MuxError::LayoutOverflow("MPEG-4 Part 2 esds"))?; + let pasp = super::super::mp4::encode_typed_box( + &Pasp { + h_spacing: 1, + v_spacing: 1, + }, + &[], + )?; + build_visual_sample_entry_box_with_compressor_name( + SAMPLE_ENTRY_MP4V, + width, + height, + &[], + &[super::super::mp4::encode_typed_box(&esds, &[])?, pasp], + ) +} + +fn parse_mp4v_stream_sync( + logical_size: u64, + spec: &str, + mut read_exact: F, +) -> Result +where + F: FnMut(u64, &mut [u8], &'static str) -> Result<(), MuxError>, +{ + if logical_size < 5 { + return Err(invalid_mp4v( + spec, + "MPEG-4 Part 2 input is truncated before the first start code", + )); + } + + let scan = scan_mp4v_boundaries_sync(logical_size, spec, &mut read_exact)?; + finalize_mp4v_track_sync(logical_size, spec, scan, read_exact) +} + +#[cfg(feature = "async")] +async fn parse_mp4v_stream_file_async( + file: &mut TokioFile, + logical_size: u64, + spec: &str, +) -> Result { + if logical_size < 5 { + return Err(invalid_mp4v( + spec, + "MPEG-4 Part 2 input is truncated before the first start code", + )); + } + + let scan = scan_mp4v_boundaries_file_async(file, logical_size, spec).await?; + finalize_mp4v_track_file_async(file, logical_size, spec, scan).await +} + +#[cfg(feature = "async")] +async fn parse_mp4v_segmented_stream_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + logical_size: u64, + spec: &str, +) -> Result { + if logical_size < 5 { + return Err(invalid_mp4v( + spec, + "MPEG-4 Part 2 input is truncated before the first start code", + )); + } + + let scan = scan_mp4v_boundaries_segmented_async(file, segments, logical_size, spec).await?; + finalize_mp4v_track_segmented_async(file, segments, logical_size, spec, scan).await +} + +struct Mp4vScanState { + config_start: Option, + first_vop_start: Option, + first_sample_prefix_start: Option, + current_sample_start: Option, + current_sync_sample: bool, + samples: Vec, +} + +fn scan_mp4v_boundaries_sync( + logical_size: u64, + spec: &str, + read_exact: &mut F, +) -> Result +where + F: FnMut(u64, &mut [u8], &'static str) -> Result<(), MuxError>, +{ + let mut samples = Vec::new(); + let mut carry = Vec::new(); + let mut offset = 0_u64; + let mut config_start = None::; + let mut first_vop_start = None::; + let mut first_sample_prefix_start = None::; + let mut current_sample_start = None::; + let mut current_sync_sample = false; + + while offset < logical_size { + let read_len = + usize::try_from((logical_size - offset).min(u64::try_from(SCAN_CHUNK_SIZE).unwrap())) + .map_err(|_| MuxError::LayoutOverflow("MPEG-4 Part 2 scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_exact(offset, &mut chunk, "MPEG-4 Part 2 scan chunk is truncated")?; + + let combined_offset = offset + .checked_sub(u64::try_from(carry.len()).unwrap()) + .ok_or(MuxError::LayoutOverflow( + "MPEG-4 Part 2 combined scan offset", + ))?; + let mut combined = carry; + combined.extend_from_slice(&chunk); + + if combined.len() >= 4 { + for index in 0..=combined.len() - 4 { + if !combined[index..].starts_with(&[0x00, 0x00, 0x01]) { + continue; + } + let start_code = combined[index + 3]; + let start_offset = combined_offset + .checked_add(u64::try_from(index).unwrap()) + .ok_or(MuxError::LayoutOverflow("MPEG-4 Part 2 start code offset"))?; + if is_mp4v_config_start_code(start_code) || start_code == USER_DATA_START_CODE { + config_start.get_or_insert(start_offset); + continue; + } + if current_sample_start.is_none() + && first_vop_start.is_none() + && config_start.is_some() + && start_code == GROUP_OF_VOP_START_CODE + { + first_sample_prefix_start.get_or_insert(start_offset); + continue; + } + if start_code != VOP_START_CODE { + continue; + } + config_start.get_or_insert(start_offset); + let is_sync_sample = + mp4v_vop_is_sync_sample_sync(read_exact, logical_size, start_offset, spec)?; + let Some(sample_start) = current_sample_start else { + first_vop_start = Some(start_offset); + current_sample_start = Some(first_sample_prefix_start.unwrap_or(start_offset)); + current_sync_sample = is_sync_sample; + continue; + }; + if start_offset <= sample_start { + continue; + } + samples.push(StagedSample { + data_offset: sample_start, + data_size: u32::try_from(start_offset - sample_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-4 Part 2 frame size"))?, + duration: DEFAULT_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: current_sync_sample, + }); + current_sample_start = Some(start_offset); + current_sync_sample = is_sync_sample; + } + } + + carry = if combined.len() > 3 { + combined[combined.len() - 3..].to_vec() + } else { + combined + }; + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("MPEG-4 Part 2 scan offset"))?; + } + + Ok(Mp4vScanState { + config_start, + first_vop_start, + first_sample_prefix_start, + current_sample_start, + current_sync_sample, + samples, + }) +} + +#[cfg(feature = "async")] +async fn scan_mp4v_boundaries_file_async( + file: &mut TokioFile, + logical_size: u64, + spec: &str, +) -> Result { + let mut samples = Vec::new(); + let mut carry = Vec::new(); + let mut offset = 0_u64; + let mut config_start = None::; + let mut first_vop_start = None::; + let mut first_sample_prefix_start = None::; + let mut current_sample_start = None::; + let mut current_sync_sample = false; + + while offset < logical_size { + let read_len = + usize::try_from((logical_size - offset).min(u64::try_from(SCAN_CHUNK_SIZE).unwrap())) + .map_err(|_| MuxError::LayoutOverflow("MPEG-4 Part 2 scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_exact_at_async( + file, + offset, + &mut chunk, + spec, + "MPEG-4 Part 2 scan chunk is truncated", + ) + .await?; + + let combined_offset = offset + .checked_sub(u64::try_from(carry.len()).unwrap()) + .ok_or(MuxError::LayoutOverflow( + "MPEG-4 Part 2 combined scan offset", + ))?; + let mut combined = carry; + combined.extend_from_slice(&chunk); + + if combined.len() >= 4 { + for index in 0..=combined.len() - 4 { + if !combined[index..].starts_with(&[0x00, 0x00, 0x01]) { + continue; + } + let start_code = combined[index + 3]; + let start_offset = combined_offset + .checked_add(u64::try_from(index).unwrap()) + .ok_or(MuxError::LayoutOverflow("MPEG-4 Part 2 start code offset"))?; + if is_mp4v_config_start_code(start_code) || start_code == USER_DATA_START_CODE { + config_start.get_or_insert(start_offset); + continue; + } + if current_sample_start.is_none() + && first_vop_start.is_none() + && config_start.is_some() + && start_code == GROUP_OF_VOP_START_CODE + { + first_sample_prefix_start.get_or_insert(start_offset); + continue; + } + if start_code != VOP_START_CODE { + continue; + } + config_start.get_or_insert(start_offset); + let is_sync_sample = + mp4v_vop_is_sync_sample_file_async(file, logical_size, start_offset, spec) + .await?; + let Some(sample_start) = current_sample_start else { + first_vop_start = Some(start_offset); + current_sample_start = Some(first_sample_prefix_start.unwrap_or(start_offset)); + current_sync_sample = is_sync_sample; + continue; + }; + if start_offset <= sample_start { + continue; + } + samples.push(StagedSample { + data_offset: sample_start, + data_size: u32::try_from(start_offset - sample_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-4 Part 2 frame size"))?, + duration: DEFAULT_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: current_sync_sample, + }); + current_sample_start = Some(start_offset); + current_sync_sample = is_sync_sample; + } + } + + carry = if combined.len() > 3 { + combined[combined.len() - 3..].to_vec() + } else { + combined + }; + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("MPEG-4 Part 2 scan offset"))?; + } + + Ok(Mp4vScanState { + config_start, + first_vop_start, + first_sample_prefix_start, + current_sample_start, + current_sync_sample, + samples, + }) +} + +#[cfg(feature = "async")] +async fn scan_mp4v_boundaries_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + logical_size: u64, + spec: &str, +) -> Result { + let mut samples = Vec::new(); + let mut carry = Vec::new(); + let mut offset = 0_u64; + let mut config_start = None::; + let mut first_vop_start = None::; + let mut first_sample_prefix_start = None::; + let mut current_sample_start = None::; + let mut current_sync_sample = false; + + while offset < logical_size { + let read_len = + usize::try_from((logical_size - offset).min(u64::try_from(SCAN_CHUNK_SIZE).unwrap())) + .map_err(|_| MuxError::LayoutOverflow("MPEG-4 Part 2 scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_segmented_bytes_async( + file, + segments, + logical_size, + offset, + &mut chunk, + spec, + "MPEG-4 Part 2 scan chunk is truncated", + ) + .await?; + + let combined_offset = offset + .checked_sub(u64::try_from(carry.len()).unwrap()) + .ok_or(MuxError::LayoutOverflow( + "MPEG-4 Part 2 combined scan offset", + ))?; + let mut combined = carry; + combined.extend_from_slice(&chunk); + + if combined.len() >= 4 { + for index in 0..=combined.len() - 4 { + if !combined[index..].starts_with(&[0x00, 0x00, 0x01]) { + continue; + } + let start_code = combined[index + 3]; + let start_offset = combined_offset + .checked_add(u64::try_from(index).unwrap()) + .ok_or(MuxError::LayoutOverflow("MPEG-4 Part 2 start code offset"))?; + if is_mp4v_config_start_code(start_code) || start_code == USER_DATA_START_CODE { + config_start.get_or_insert(start_offset); + continue; + } + if current_sample_start.is_none() + && first_vop_start.is_none() + && config_start.is_some() + && start_code == GROUP_OF_VOP_START_CODE + { + first_sample_prefix_start.get_or_insert(start_offset); + continue; + } + if start_code != VOP_START_CODE { + continue; + } + config_start.get_or_insert(start_offset); + let is_sync_sample = mp4v_vop_is_sync_sample_segmented_async( + file, + segments, + logical_size, + start_offset, + spec, + ) + .await?; + let Some(sample_start) = current_sample_start else { + first_vop_start = Some(start_offset); + current_sample_start = Some(first_sample_prefix_start.unwrap_or(start_offset)); + current_sync_sample = is_sync_sample; + continue; + }; + if start_offset <= sample_start { + continue; + } + samples.push(StagedSample { + data_offset: sample_start, + data_size: u32::try_from(start_offset - sample_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-4 Part 2 frame size"))?, + duration: DEFAULT_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: current_sync_sample, + }); + current_sample_start = Some(start_offset); + current_sync_sample = is_sync_sample; + } + } + + carry = if combined.len() > 3 { + combined[combined.len() - 3..].to_vec() + } else { + combined + }; + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("MPEG-4 Part 2 scan offset"))?; + } + + Ok(Mp4vScanState { + config_start, + first_vop_start, + first_sample_prefix_start, + current_sample_start, + current_sync_sample, + samples, + }) +} + +fn finalize_mp4v_track_sync( + logical_size: u64, + spec: &str, + scan: Mp4vScanState, + mut read_exact: F, +) -> Result +where + F: FnMut(u64, &mut [u8], &'static str) -> Result<(), MuxError>, +{ + let config_start = scan.config_start.ok_or_else(|| { + invalid_mp4v( + spec, + "MPEG-4 Part 2 input did not expose any decoder-config start codes before the first VOP", + ) + })?; + let first_vop_start = scan.first_vop_start.ok_or_else(|| { + invalid_mp4v( + spec, + "MPEG-4 Part 2 input did not contain any VOP start codes", + ) + })?; + let current_sample_start = scan.current_sample_start.ok_or_else(|| { + invalid_mp4v( + spec, + "MPEG-4 Part 2 input did not contain any complete VOP samples", + ) + })?; + if first_vop_start <= config_start { + return Err(invalid_mp4v( + spec, + "MPEG-4 Part 2 decoder config did not precede the first VOP sample", + )); + } + let config_end = scan.first_sample_prefix_start.unwrap_or(first_vop_start); + let config_size = usize::try_from(config_end - config_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-4 Part 2 decoder config size"))?; + let mut decoder_specific_info = vec![0_u8; config_size]; + read_exact( + config_start, + &mut decoder_specific_info, + "MPEG-4 Part 2 decoder config is truncated", + )?; + let (width, height) = parse_mp4v_decoder_specific_info(&decoder_specific_info, spec)?; + + let mut samples = scan.samples; + samples.push(StagedSample { + data_offset: current_sample_start, + data_size: u32::try_from(logical_size - current_sample_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-4 Part 2 trailing frame size"))?, + duration: DEFAULT_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: scan.current_sync_sample, + }); + + Ok(ParsedMp4vTrack { + width, + height, + timescale: DIRECT_TIMESCALE, + decoder_specific_info: decoder_specific_info.clone(), + sample_entry_box: build_direct_mp4v_sample_entry_box( + width, + height, + &decoder_specific_info, + DIRECT_TIMESCALE, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?, + samples, + }) +} + +#[cfg(feature = "async")] +async fn finalize_mp4v_track_file_async( + file: &mut TokioFile, + logical_size: u64, + spec: &str, + scan: Mp4vScanState, +) -> Result { + let config_start = scan.config_start.ok_or_else(|| { + invalid_mp4v( + spec, + "MPEG-4 Part 2 input did not expose any decoder-config start codes before the first VOP", + ) + })?; + let first_vop_start = scan.first_vop_start.ok_or_else(|| { + invalid_mp4v( + spec, + "MPEG-4 Part 2 input did not contain any VOP start codes", + ) + })?; + let current_sample_start = scan.current_sample_start.ok_or_else(|| { + invalid_mp4v( + spec, + "MPEG-4 Part 2 input did not contain any complete VOP samples", + ) + })?; + if first_vop_start <= config_start { + return Err(invalid_mp4v( + spec, + "MPEG-4 Part 2 decoder config did not precede the first VOP sample", + )); + } + let config_end = scan.first_sample_prefix_start.unwrap_or(first_vop_start); + let config_size = usize::try_from(config_end - config_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-4 Part 2 decoder config size"))?; + let mut decoder_specific_info = vec![0_u8; config_size]; + read_exact_at_async( + file, + config_start, + &mut decoder_specific_info, + spec, + "MPEG-4 Part 2 decoder config is truncated", + ) + .await?; + let (width, height) = parse_mp4v_decoder_specific_info(&decoder_specific_info, spec)?; + + let mut samples = scan.samples; + samples.push(StagedSample { + data_offset: current_sample_start, + data_size: u32::try_from(logical_size - current_sample_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-4 Part 2 trailing frame size"))?, + duration: DEFAULT_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: scan.current_sync_sample, + }); + + Ok(ParsedMp4vTrack { + width, + height, + timescale: DIRECT_TIMESCALE, + decoder_specific_info: decoder_specific_info.clone(), + sample_entry_box: build_direct_mp4v_sample_entry_box( + width, + height, + &decoder_specific_info, + DIRECT_TIMESCALE, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?, + samples, + }) +} + +#[cfg(feature = "async")] +async fn finalize_mp4v_track_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + logical_size: u64, + spec: &str, + scan: Mp4vScanState, +) -> Result { + let config_start = scan.config_start.ok_or_else(|| { + invalid_mp4v( + spec, + "MPEG-4 Part 2 input did not expose any decoder-config start codes before the first VOP", + ) + })?; + let first_vop_start = scan.first_vop_start.ok_or_else(|| { + invalid_mp4v( + spec, + "MPEG-4 Part 2 input did not contain any VOP start codes", + ) + })?; + let current_sample_start = scan.current_sample_start.ok_or_else(|| { + invalid_mp4v( + spec, + "MPEG-4 Part 2 input did not contain any complete VOP samples", + ) + })?; + if first_vop_start <= config_start { + return Err(invalid_mp4v( + spec, + "MPEG-4 Part 2 decoder config did not precede the first VOP sample", + )); + } + let config_end = scan.first_sample_prefix_start.unwrap_or(first_vop_start); + let config_size = usize::try_from(config_end - config_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-4 Part 2 decoder config size"))?; + let mut decoder_specific_info = vec![0_u8; config_size]; + read_segmented_bytes_async( + file, + segments, + logical_size, + config_start, + &mut decoder_specific_info, + spec, + "MPEG-4 Part 2 decoder config is truncated", + ) + .await?; + let (width, height) = parse_mp4v_decoder_specific_info(&decoder_specific_info, spec)?; + + let mut samples = scan.samples; + samples.push(StagedSample { + data_offset: current_sample_start, + data_size: u32::try_from(logical_size - current_sample_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-4 Part 2 trailing frame size"))?, + duration: DEFAULT_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: scan.current_sync_sample, + }); + + Ok(ParsedMp4vTrack { + width, + height, + timescale: DIRECT_TIMESCALE, + decoder_specific_info: decoder_specific_info.clone(), + sample_entry_box: build_direct_mp4v_sample_entry_box( + width, + height, + &decoder_specific_info, + DIRECT_TIMESCALE, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?, + samples, + }) +} + +fn mp4v_vop_is_sync_sample_sync( + read_exact: &mut F, + logical_size: u64, + sample_start: u64, + spec: &str, +) -> Result +where + F: FnMut(u64, &mut [u8], &'static str) -> Result<(), MuxError>, +{ + if sample_start + .checked_add(5) + .is_none_or(|end| end > logical_size) + { + return Err(invalid_mp4v(spec, "MPEG-4 Part 2 VOP header is truncated")); + } + let mut header = [0_u8; 1]; + read_exact( + sample_start + 4, + &mut header, + "MPEG-4 Part 2 VOP coding-type header is truncated", + )?; + Ok((header[0] >> 6) == 0) +} + +#[cfg(feature = "async")] +async fn mp4v_vop_is_sync_sample_file_async( + file: &mut TokioFile, + logical_size: u64, + sample_start: u64, + spec: &str, +) -> Result { + if sample_start + .checked_add(5) + .is_none_or(|end| end > logical_size) + { + return Err(invalid_mp4v(spec, "MPEG-4 Part 2 VOP header is truncated")); + } + let mut header = [0_u8; 1]; + read_exact_at_async( + file, + sample_start + 4, + &mut header, + spec, + "MPEG-4 Part 2 VOP coding-type header is truncated", + ) + .await?; + Ok((header[0] >> 6) == 0) +} + +#[cfg(feature = "async")] +async fn mp4v_vop_is_sync_sample_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + logical_size: u64, + sample_start: u64, + spec: &str, +) -> Result { + if sample_start + .checked_add(5) + .is_none_or(|end| end > logical_size) + { + return Err(invalid_mp4v(spec, "MPEG-4 Part 2 VOP header is truncated")); + } + let mut header = [0_u8; 1]; + read_segmented_bytes_async( + file, + segments, + logical_size, + sample_start + 4, + &mut header, + spec, + "MPEG-4 Part 2 VOP coding-type header is truncated", + ) + .await?; + Ok((header[0] >> 6) == 0) +} + +pub(in crate::mux) fn parse_mp4v_decoder_specific_info( + decoder_specific_info: &[u8], + spec: &str, +) -> Result<(u16, u16), MuxError> { + let Some((vol_start, vol_header_offset)) = find_mp4v_vol_start(decoder_specific_info) else { + return Err(invalid_mp4v( + spec, + "MPEG-4 Part 2 decoder config did not contain one video object layer start code", + )); + }; + let vol_end = find_next_mp4v_start_code(decoder_specific_info, vol_header_offset) + .unwrap_or(decoder_specific_info.len()); + if vol_end <= vol_header_offset { + return Err(invalid_mp4v( + spec, + "MPEG-4 Part 2 video object layer header is empty", + )); + } + let _ = vol_start; + parse_mp4v_vol_header(&decoder_specific_info[vol_header_offset..vol_end], spec) +} + +pub(in crate::mux) fn mp4v_profile_level_indication(decoder_specific_info: &[u8]) -> Option { + for index in 0..decoder_specific_info.len().saturating_sub(4) { + if decoder_specific_info[index..index + 3] != [0x00, 0x00, 0x01] { + continue; + } + if decoder_specific_info[index + 3] != VOS_START_CODE { + continue; + } + return decoder_specific_info.get(index + 4).copied(); + } + None +} + +fn find_mp4v_vol_start(bytes: &[u8]) -> Option<(usize, usize)> { + let mut index = 0usize; + while index + 4 <= bytes.len() { + if bytes[index..].starts_with(&[0x00, 0x00, 0x01]) { + let start_code = bytes[index + 3]; + if is_mp4v_vol_start_code(start_code) { + return Some((index, index + 4)); + } + } + index += 1; + } + None +} + +fn find_next_mp4v_start_code(bytes: &[u8], from: usize) -> Option { + let mut index = from; + while index + 4 <= bytes.len() { + if bytes[index..].starts_with(&[0x00, 0x00, 0x01]) { + return Some(index); + } + index += 1; + } + None +} + +fn parse_mp4v_vol_header(bytes: &[u8], spec: &str) -> Result<(u16, u16), MuxError> { + let mut reader = BitReader::new(Cursor::new(bytes)); + let _random_accessible_vol = read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")?; + let _video_object_type_indication = + read_bits_u8_labeled(&mut reader, 8, spec, "MPEG-4 Part 2")?; + if read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")? { + let _verid = read_bits_u8_labeled(&mut reader, 4, spec, "MPEG-4 Part 2")?; + let _priority = read_bits_u8_labeled(&mut reader, 3, spec, "MPEG-4 Part 2")?; + } + let aspect_ratio_info = read_bits_u8_labeled(&mut reader, 4, spec, "MPEG-4 Part 2")?; + if aspect_ratio_info == 0x0F { + let _par_width = read_bits_u8_labeled(&mut reader, 8, spec, "MPEG-4 Part 2")?; + let _par_height = read_bits_u8_labeled(&mut reader, 8, spec, "MPEG-4 Part 2")?; + } + if read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")? { + let _chroma_format = read_bits_u8_labeled(&mut reader, 2, spec, "MPEG-4 Part 2")?; + let _low_delay = read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")?; + if read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")? { + let _first_half_bit_rate = + read_bits_u16_labeled(&mut reader, 15, spec, "MPEG-4 Part 2")?; + let _ = read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")?; + let _latter_half_bit_rate = + read_bits_u16_labeled(&mut reader, 15, spec, "MPEG-4 Part 2")?; + let _ = read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")?; + let _first_half_vbv_buffer_size = + read_bits_u16_labeled(&mut reader, 15, spec, "MPEG-4 Part 2")?; + let _ = read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")?; + let _latter_half_vbv_buffer_size = + read_bits_u8_labeled(&mut reader, 3, spec, "MPEG-4 Part 2")?; + let _first_half_vbv_occupancy = + read_bits_u16_labeled(&mut reader, 11, spec, "MPEG-4 Part 2")?; + let _ = read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")?; + let _latter_half_vbv_occupancy = + read_bits_u16_labeled(&mut reader, 15, spec, "MPEG-4 Part 2")?; + let _ = read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")?; + } + } + let video_object_layer_shape = read_bits_u8_labeled(&mut reader, 2, spec, "MPEG-4 Part 2")?; + if video_object_layer_shape != 0 { + return Err(invalid_mp4v( + spec, + "only rectangular MPEG-4 Part 2 video object layers are supported on the native direct-ingest path", + )); + } + if !read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")? { + return Err(invalid_mp4v( + spec, + "MPEG-4 Part 2 video object layer marker bit was not set before the time-increment resolution", + )); + } + let vop_time_increment_resolution = + read_bits_u16_labeled(&mut reader, 16, spec, "MPEG-4 Part 2")?; + if vop_time_increment_resolution == 0 { + return Err(invalid_mp4v( + spec, + "MPEG-4 Part 2 video object layer declared a zero time-increment resolution", + )); + } + if !read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")? { + return Err(invalid_mp4v( + spec, + "MPEG-4 Part 2 video object layer marker bit was not set after the time-increment resolution", + )); + } + if read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")? { + let fixed_vop_time_increment_bits = + fixed_vop_time_increment_bit_count(vop_time_increment_resolution); + let _fixed_vop_time_increment = read_bits_u16_labeled( + &mut reader, + fixed_vop_time_increment_bits, + spec, + "MPEG-4 Part 2", + )?; + } + if !read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")? { + return Err(invalid_mp4v( + spec, + "MPEG-4 Part 2 video object layer marker bit was not set before coded width", + )); + } + let width = read_bits_u16_labeled(&mut reader, 13, spec, "MPEG-4 Part 2")?; + if !read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")? { + return Err(invalid_mp4v( + spec, + "MPEG-4 Part 2 video object layer marker bit was not set before coded height", + )); + } + let height = read_bits_u16_labeled(&mut reader, 13, spec, "MPEG-4 Part 2")?; + if !read_bit_labeled(&mut reader, spec, "MPEG-4 Part 2")? { + return Err(invalid_mp4v( + spec, + "MPEG-4 Part 2 video object layer marker bit was not set after coded height", + )); + } + if width == 0 || height == 0 { + return Err(invalid_mp4v( + spec, + "MPEG-4 Part 2 video object layer carried a zero coded dimension", + )); + } + Ok((width, height)) +} + +fn fixed_vop_time_increment_bit_count(vop_time_increment_resolution: u16) -> usize { + let max_value = u32::from(vop_time_increment_resolution.saturating_sub(1)); + let bits = 32 - max_value.leading_zeros(); + usize::try_from(bits.max(1)).unwrap() +} + +fn is_mp4v_config_start_code(start_code: u8) -> bool { + matches!(start_code, VOS_START_CODE | VO_START_CODE) || is_mp4v_vol_start_code(start_code) +} + +fn is_mp4v_vol_start_code(start_code: u8) -> bool { + (0x20..=0x2F).contains(&start_code) +} + +fn invalid_mp4v(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} diff --git a/src/mux/demux/mpeg2v.rs b/src/mux/demux/mpeg2v.rs new file mode 100644 index 0000000..b7709f8 --- /dev/null +++ b/src/mux/demux/mpeg2v.rs @@ -0,0 +1,1293 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::iso14496_12::Btrt; +use crate::boxes::iso14496_12::Pasp; +use crate::boxes::iso14496_14::{ + DECODER_CONFIG_DESCRIPTOR_TAG, DECODER_SPECIFIC_INFO_TAG, DecoderConfigDescriptor, Descriptor, + ES_DESCRIPTOR_TAG, EsDescriptor, Esds, SL_CONFIG_DESCRIPTOR_TAG, +}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{ + SegmentedMuxSourceSegment, StagedSample, build_btrt_from_sample_sizes, + build_visual_sample_entry_box_with_compressor_name, read_exact_at_sync, +}; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::read_segmented_bytes_sync; + +const SAMPLE_ENTRY_MP4V: FourCc = FourCc::from_bytes(*b"mp4v"); +const DIRECT_COMPRESSOR_NAME: &[u8] = b""; +const SCAN_CHUNK_SIZE: usize = 16 * 1024; +const MIN_HEADER_SIZE: usize = 8; +const MPEG2_VISUAL_OBJECT_TYPE_SIMPLE: u8 = 0x60; +const MPEG2_VISUAL_OBJECT_TYPE_MAIN: u8 = 0x61; +const MPEG2_VISUAL_OBJECT_TYPE_SNR: u8 = 0x62; +const MPEG2_VISUAL_OBJECT_TYPE_SPATIAL: u8 = 0x63; +const MPEG2_VISUAL_OBJECT_TYPE_HIGH: u8 = 0x64; +const MPEG2_VISUAL_OBJECT_TYPE_422: u8 = 0x65; +const MPEG1_VISUAL_OBJECT_TYPE: u8 = 0x6A; +const SEQUENCE_START_CODE: u8 = 0xB3; +const SEQUENCE_END_START_CODE: u8 = 0xB7; +const EXTENSION_START_CODE: u8 = 0xB5; +const PICTURE_START_CODE: u8 = 0x00; +const FRAME_RATE_23_976: (u32, u32) = (24_000, 1_001); +const FRAME_RATE_24: (u32, u32) = (24_000, 1_000); +const FRAME_RATE_25: (u32, u32) = (25_000, 1_000); +const FRAME_RATE_29_97: (u32, u32) = (30_000, 1_001); +const FRAME_RATE_30: (u32, u32) = (30_000, 1_000); +const FRAME_RATE_50: (u32, u32) = (50_000, 1_000); +const FRAME_RATE_59_94: (u32, u32) = (60_000, 1_001); +const FRAME_RATE_60: (u32, u32) = (60_000, 1_000); + +pub(in crate::mux) struct ParsedMpeg2VideoTrack { + pub(in crate::mux) width: u16, + pub(in crate::mux) height: u16, + pub(in crate::mux) timescale: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) decoder_specific_info: Vec, + pub(in crate::mux) object_type_indication: u8, + pub(in crate::mux) pixel_aspect_ratio: Option<(u32, u32)>, + pub(in crate::mux) eof_terminated_trailing_sample: bool, + pub(in crate::mux) samples: Vec, +} + +struct Mpeg2ScanState { + sequence_start: Option, + first_picture_start: Option, + current_sample_start: Option, + current_sync_sample: bool, + samples: Vec, +} + +struct ParsedMpeg2DecoderConfig { + width: u16, + height: u16, + timescale: u32, + sample_duration: u32, + object_type_indication: u8, + pixel_aspect_ratio: Option<(u32, u32)>, +} + +pub(in crate::mux) struct ProgramStreamMpeg2vSampleEntryConfig<'a> { + pub(in crate::mux) width: u16, + pub(in crate::mux) height: u16, + pub(in crate::mux) decoder_specific_info: &'a [u8], + pub(in crate::mux) object_type_indication: u8, + pub(in crate::mux) timescale: u32, + pub(in crate::mux) leading_media_time: u64, + pub(in crate::mux) pixel_aspect_ratio: Option<(u32, u32)>, +} + +pub(in crate::mux) fn scan_mpeg2v_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + parse_mpeg2v_stream_sync(file_size, spec, |offset, buf, message| { + read_exact_at_sync(&mut file, offset, buf, spec, message) + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_mpeg2v_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + parse_mpeg2v_stream_file_async(&mut file, file_size, spec).await +} + +pub(in crate::mux) fn scan_mpeg2v_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + parse_mpeg2v_stream_sync(total_size, spec, |offset, buf, message| { + read_segmented_bytes_sync(file, segments, total_size, offset, buf, spec, message) + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_mpeg2v_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + parse_mpeg2v_segmented_stream_async(file, segments, total_size, spec).await +} + +fn parse_mpeg2v_stream_sync( + logical_size: u64, + spec: &str, + mut read_exact: F, +) -> Result +where + F: FnMut(u64, &mut [u8], &'static str) -> Result<(), MuxError>, +{ + if logical_size < u64::try_from(MIN_HEADER_SIZE).unwrap() { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video input is truncated before the first start code", + )); + } + let scan = scan_mpeg2v_boundaries_sync(logical_size, spec, &mut read_exact)?; + finalize_mpeg2v_track_sync(logical_size, spec, scan, read_exact) +} + +#[cfg(feature = "async")] +async fn parse_mpeg2v_stream_file_async( + file: &mut TokioFile, + logical_size: u64, + spec: &str, +) -> Result { + if logical_size < u64::try_from(MIN_HEADER_SIZE).unwrap() { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video input is truncated before the first start code", + )); + } + let scan = scan_mpeg2v_boundaries_file_async(file, logical_size, spec).await?; + finalize_mpeg2v_track_file_async(file, logical_size, spec, scan).await +} + +#[cfg(feature = "async")] +async fn parse_mpeg2v_segmented_stream_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + logical_size: u64, + spec: &str, +) -> Result { + if logical_size < u64::try_from(MIN_HEADER_SIZE).unwrap() { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video input is truncated before the first start code", + )); + } + let scan = scan_mpeg2v_boundaries_segmented_async(file, segments, logical_size, spec).await?; + finalize_mpeg2v_track_segmented_async(file, segments, logical_size, spec, scan).await +} + +fn scan_mpeg2v_boundaries_sync( + logical_size: u64, + spec: &str, + read_exact: &mut F, +) -> Result +where + F: FnMut(u64, &mut [u8], &'static str) -> Result<(), MuxError>, +{ + let mut samples = Vec::new(); + let mut carry = Vec::new(); + let mut offset = 0_u64; + let mut sequence_start = None::; + let mut first_picture_start = None::; + let mut current_sample_start = None::; + let mut pending_sample_start = None::; + let mut current_sync_sample = false; + + while offset < logical_size { + let read_len = + usize::try_from((logical_size - offset).min(u64::try_from(SCAN_CHUNK_SIZE).unwrap())) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_exact(offset, &mut chunk, "MPEG-2 video scan chunk is truncated")?; + + let combined_offset = offset + .checked_sub(u64::try_from(carry.len()).unwrap()) + .ok_or(MuxError::LayoutOverflow( + "MPEG-2 video combined scan offset", + ))?; + let mut combined = carry; + combined.extend_from_slice(&chunk); + + if combined.len() >= 4 { + for index in 0..=combined.len() - 4 { + if !combined[index..].starts_with(&[0x00, 0x00, 0x01]) { + continue; + } + let start_code = combined[index + 3]; + let start_offset = combined_offset + .checked_add(u64::try_from(index).unwrap()) + .ok_or(MuxError::LayoutOverflow("MPEG-2 video start-code offset"))?; + if start_code == SEQUENCE_START_CODE { + sequence_start.get_or_insert(start_offset); + pending_sample_start = Some(start_offset); + continue; + } + if start_code == SEQUENCE_END_START_CODE { + if let Some(sample_start) = current_sample_start.take() + && start_offset > sample_start + { + samples.push(StagedSample { + data_offset: sample_start, + data_size: u32::try_from(start_offset - sample_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video frame size"))?, + duration: 0, + composition_time_offset: 0, + is_sync_sample: current_sync_sample, + }); + } + pending_sample_start = None; + current_sync_sample = false; + continue; + } + if start_code != PICTURE_START_CODE { + continue; + } + let is_sync_sample = mpeg2v_picture_is_sync_sample_sync( + read_exact, + logical_size, + start_offset, + spec, + )?; + let Some(sample_start) = current_sample_start else { + first_picture_start = Some(start_offset); + current_sample_start = Some( + pending_sample_start + .take() + .or(sequence_start) + .unwrap_or(start_offset), + ); + current_sync_sample = is_sync_sample; + continue; + }; + let next_sample_start = pending_sample_start.take().unwrap_or(start_offset); + if next_sample_start <= sample_start { + continue; + } + samples.push(StagedSample { + data_offset: sample_start, + data_size: u32::try_from(next_sample_start - sample_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video frame size"))?, + duration: 0, + composition_time_offset: 0, + is_sync_sample: current_sync_sample, + }); + current_sample_start = Some(next_sample_start); + current_sync_sample = is_sync_sample; + } + } + + carry = if combined.len() > 3 { + combined[combined.len() - 3..].to_vec() + } else { + combined + }; + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("MPEG-2 video scan offset"))?; + } + + Ok(Mpeg2ScanState { + sequence_start, + first_picture_start, + current_sample_start, + current_sync_sample, + samples, + }) +} + +#[cfg(feature = "async")] +async fn scan_mpeg2v_boundaries_file_async( + file: &mut TokioFile, + logical_size: u64, + spec: &str, +) -> Result { + let mut samples = Vec::new(); + let mut carry = Vec::new(); + let mut offset = 0_u64; + let mut sequence_start = None::; + let mut first_picture_start = None::; + let mut current_sample_start = None::; + let mut pending_sample_start = None::; + let mut current_sync_sample = false; + + while offset < logical_size { + let read_len = + usize::try_from((logical_size - offset).min(u64::try_from(SCAN_CHUNK_SIZE).unwrap())) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_exact_at_async( + file, + offset, + &mut chunk, + spec, + "MPEG-2 video scan chunk is truncated", + ) + .await?; + + let combined_offset = offset + .checked_sub(u64::try_from(carry.len()).unwrap()) + .ok_or(MuxError::LayoutOverflow( + "MPEG-2 video combined scan offset", + ))?; + let mut combined = carry; + combined.extend_from_slice(&chunk); + + if combined.len() >= 4 { + for index in 0..=combined.len() - 4 { + if !combined[index..].starts_with(&[0x00, 0x00, 0x01]) { + continue; + } + let start_code = combined[index + 3]; + let start_offset = combined_offset + .checked_add(u64::try_from(index).unwrap()) + .ok_or(MuxError::LayoutOverflow("MPEG-2 video start-code offset"))?; + if start_code == SEQUENCE_START_CODE { + sequence_start.get_or_insert(start_offset); + pending_sample_start = Some(start_offset); + continue; + } + if start_code == SEQUENCE_END_START_CODE { + if let Some(sample_start) = current_sample_start.take() + && start_offset > sample_start + { + samples.push(StagedSample { + data_offset: sample_start, + data_size: u32::try_from(start_offset - sample_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video frame size"))?, + duration: 0, + composition_time_offset: 0, + is_sync_sample: current_sync_sample, + }); + } + pending_sample_start = None; + current_sync_sample = false; + continue; + } + if start_code != PICTURE_START_CODE { + continue; + } + let is_sync_sample = mpeg2v_picture_is_sync_sample_file_async( + file, + logical_size, + start_offset, + spec, + ) + .await?; + let Some(sample_start) = current_sample_start else { + first_picture_start = Some(start_offset); + current_sample_start = Some( + pending_sample_start + .take() + .or(sequence_start) + .unwrap_or(start_offset), + ); + current_sync_sample = is_sync_sample; + continue; + }; + let next_sample_start = pending_sample_start.take().unwrap_or(start_offset); + if next_sample_start <= sample_start { + continue; + } + samples.push(StagedSample { + data_offset: sample_start, + data_size: u32::try_from(next_sample_start - sample_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video frame size"))?, + duration: 0, + composition_time_offset: 0, + is_sync_sample: current_sync_sample, + }); + current_sample_start = Some(next_sample_start); + current_sync_sample = is_sync_sample; + } + } + + carry = if combined.len() > 3 { + combined[combined.len() - 3..].to_vec() + } else { + combined + }; + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("MPEG-2 video scan offset"))?; + } + + Ok(Mpeg2ScanState { + sequence_start, + first_picture_start, + current_sample_start, + current_sync_sample, + samples, + }) +} + +#[cfg(feature = "async")] +async fn scan_mpeg2v_boundaries_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + logical_size: u64, + spec: &str, +) -> Result { + let mut samples = Vec::new(); + let mut carry = Vec::new(); + let mut offset = 0_u64; + let mut sequence_start = None::; + let mut first_picture_start = None::; + let mut current_sample_start = None::; + let mut pending_sample_start = None::; + let mut current_sync_sample = false; + + while offset < logical_size { + let read_len = + usize::try_from((logical_size - offset).min(u64::try_from(SCAN_CHUNK_SIZE).unwrap())) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_segmented_bytes_async( + file, + segments, + logical_size, + offset, + &mut chunk, + spec, + "MPEG-2 video scan chunk is truncated", + ) + .await?; + + let combined_offset = offset + .checked_sub(u64::try_from(carry.len()).unwrap()) + .ok_or(MuxError::LayoutOverflow( + "MPEG-2 video combined scan offset", + ))?; + let mut combined = carry; + combined.extend_from_slice(&chunk); + + if combined.len() >= 4 { + for index in 0..=combined.len() - 4 { + if !combined[index..].starts_with(&[0x00, 0x00, 0x01]) { + continue; + } + let start_code = combined[index + 3]; + let start_offset = combined_offset + .checked_add(u64::try_from(index).unwrap()) + .ok_or(MuxError::LayoutOverflow("MPEG-2 video start-code offset"))?; + if start_code == SEQUENCE_START_CODE { + sequence_start.get_or_insert(start_offset); + pending_sample_start = Some(start_offset); + continue; + } + if start_code == SEQUENCE_END_START_CODE { + if let Some(sample_start) = current_sample_start.take() + && start_offset > sample_start + { + samples.push(StagedSample { + data_offset: sample_start, + data_size: u32::try_from(start_offset - sample_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video frame size"))?, + duration: 0, + composition_time_offset: 0, + is_sync_sample: current_sync_sample, + }); + } + pending_sample_start = None; + current_sync_sample = false; + continue; + } + if start_code != PICTURE_START_CODE { + continue; + } + let is_sync_sample = mpeg2v_picture_is_sync_sample_segmented_async( + file, + segments, + logical_size, + start_offset, + spec, + ) + .await?; + let Some(sample_start) = current_sample_start else { + first_picture_start = Some(start_offset); + current_sample_start = Some( + pending_sample_start + .take() + .or(sequence_start) + .unwrap_or(start_offset), + ); + current_sync_sample = is_sync_sample; + continue; + }; + let next_sample_start = pending_sample_start.take().unwrap_or(start_offset); + if next_sample_start <= sample_start { + continue; + } + samples.push(StagedSample { + data_offset: sample_start, + data_size: u32::try_from(next_sample_start - sample_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video frame size"))?, + duration: 0, + composition_time_offset: 0, + is_sync_sample: current_sync_sample, + }); + current_sample_start = Some(next_sample_start); + current_sync_sample = is_sync_sample; + } + } + + carry = if combined.len() > 3 { + combined[combined.len() - 3..].to_vec() + } else { + combined + }; + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("MPEG-2 video scan offset"))?; + } + + Ok(Mpeg2ScanState { + sequence_start, + first_picture_start, + current_sample_start, + current_sync_sample, + samples, + }) +} + +fn finalize_mpeg2v_track_sync( + logical_size: u64, + spec: &str, + scan: Mpeg2ScanState, + mut read_exact: F, +) -> Result +where + F: FnMut(u64, &mut [u8], &'static str) -> Result<(), MuxError>, +{ + let sequence_start = scan.sequence_start.ok_or_else(|| { + invalid_mpeg2v( + spec, + "MPEG-2 video input did not contain a sequence header before the first picture", + ) + })?; + let first_picture_start = scan.first_picture_start.ok_or_else(|| { + invalid_mpeg2v( + spec, + "MPEG-2 video input did not contain any picture start codes", + ) + })?; + if first_picture_start <= sequence_start { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video decoder config did not precede the first picture sample", + )); + } + let decoder_specific_info_size = usize::try_from(first_picture_start - sequence_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video decoder config size"))?; + let mut decoder_specific_info = vec![0_u8; decoder_specific_info_size]; + read_exact( + sequence_start, + &mut decoder_specific_info, + "MPEG-2 video decoder config is truncated", + )?; + let parsed_config = parse_mpeg2_decoder_specific_info(&decoder_specific_info, spec)?; + + let eof_terminated_trailing_sample = scan.current_sample_start.is_some(); + let mut samples = scan.samples; + if let Some(current_sample_start) = scan.current_sample_start { + samples.push(StagedSample { + data_offset: current_sample_start, + data_size: u32::try_from(logical_size - current_sample_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video trailing frame size"))?, + duration: parsed_config.sample_duration, + composition_time_offset: 0, + is_sync_sample: scan.current_sync_sample, + }); + } + if samples.is_empty() { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video input did not contain any complete picture samples", + )); + } + for sample in &mut samples { + if sample.duration == 0 { + sample.duration = parsed_config.sample_duration; + } + } + + let sample_entry_box = build_mpeg2v_sample_entry_box( + parsed_config.width, + parsed_config.height, + &decoder_specific_info, + parsed_config.object_type_indication, + parsed_config.timescale, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + None, + )?; + Ok(ParsedMpeg2VideoTrack { + width: parsed_config.width, + height: parsed_config.height, + timescale: parsed_config.timescale, + decoder_specific_info, + object_type_indication: parsed_config.object_type_indication, + sample_entry_box, + pixel_aspect_ratio: parsed_config.pixel_aspect_ratio, + eof_terminated_trailing_sample, + samples, + }) +} + +#[cfg(feature = "async")] +async fn finalize_mpeg2v_track_file_async( + file: &mut TokioFile, + logical_size: u64, + spec: &str, + scan: Mpeg2ScanState, +) -> Result { + let sequence_start = scan.sequence_start.ok_or_else(|| { + invalid_mpeg2v( + spec, + "MPEG-2 video input did not contain a sequence header before the first picture", + ) + })?; + let first_picture_start = scan.first_picture_start.ok_or_else(|| { + invalid_mpeg2v( + spec, + "MPEG-2 video input did not contain any picture start codes", + ) + })?; + if first_picture_start <= sequence_start { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video decoder config did not precede the first picture sample", + )); + } + let decoder_specific_info_size = usize::try_from(first_picture_start - sequence_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video decoder config size"))?; + let mut decoder_specific_info = vec![0_u8; decoder_specific_info_size]; + read_exact_at_async( + file, + sequence_start, + &mut decoder_specific_info, + spec, + "MPEG-2 video decoder config is truncated", + ) + .await?; + let parsed_config = parse_mpeg2_decoder_specific_info(&decoder_specific_info, spec)?; + + let eof_terminated_trailing_sample = scan.current_sample_start.is_some(); + let mut samples = scan.samples; + if let Some(current_sample_start) = scan.current_sample_start { + samples.push(StagedSample { + data_offset: current_sample_start, + data_size: u32::try_from(logical_size - current_sample_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video trailing frame size"))?, + duration: parsed_config.sample_duration, + composition_time_offset: 0, + is_sync_sample: scan.current_sync_sample, + }); + } + if samples.is_empty() { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video input did not contain any complete picture samples", + )); + } + for sample in &mut samples { + if sample.duration == 0 { + sample.duration = parsed_config.sample_duration; + } + } + + let sample_entry_box = build_mpeg2v_sample_entry_box( + parsed_config.width, + parsed_config.height, + &decoder_specific_info, + parsed_config.object_type_indication, + parsed_config.timescale, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + None, + )?; + Ok(ParsedMpeg2VideoTrack { + width: parsed_config.width, + height: parsed_config.height, + timescale: parsed_config.timescale, + decoder_specific_info, + object_type_indication: parsed_config.object_type_indication, + sample_entry_box, + pixel_aspect_ratio: parsed_config.pixel_aspect_ratio, + eof_terminated_trailing_sample, + samples, + }) +} + +#[cfg(feature = "async")] +async fn finalize_mpeg2v_track_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + logical_size: u64, + spec: &str, + scan: Mpeg2ScanState, +) -> Result { + let sequence_start = scan.sequence_start.ok_or_else(|| { + invalid_mpeg2v( + spec, + "MPEG-2 video input did not contain a sequence header before the first picture", + ) + })?; + let first_picture_start = scan.first_picture_start.ok_or_else(|| { + invalid_mpeg2v( + spec, + "MPEG-2 video input did not contain any picture start codes", + ) + })?; + if first_picture_start <= sequence_start { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video decoder config did not precede the first picture sample", + )); + } + let decoder_specific_info_size = usize::try_from(first_picture_start - sequence_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video decoder config size"))?; + let mut decoder_specific_info = vec![0_u8; decoder_specific_info_size]; + read_segmented_bytes_async( + file, + segments, + logical_size, + sequence_start, + &mut decoder_specific_info, + spec, + "MPEG-2 video decoder config is truncated", + ) + .await?; + let parsed_config = parse_mpeg2_decoder_specific_info(&decoder_specific_info, spec)?; + + let eof_terminated_trailing_sample = scan.current_sample_start.is_some(); + let mut samples = scan.samples; + if let Some(current_sample_start) = scan.current_sample_start { + samples.push(StagedSample { + data_offset: current_sample_start, + data_size: u32::try_from(logical_size - current_sample_start) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video trailing frame size"))?, + duration: parsed_config.sample_duration, + composition_time_offset: 0, + is_sync_sample: scan.current_sync_sample, + }); + } + if samples.is_empty() { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video input did not contain any complete picture samples", + )); + } + for sample in &mut samples { + if sample.duration == 0 { + sample.duration = parsed_config.sample_duration; + } + } + + let sample_entry_box = build_mpeg2v_sample_entry_box( + parsed_config.width, + parsed_config.height, + &decoder_specific_info, + parsed_config.object_type_indication, + parsed_config.timescale, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + None, + )?; + Ok(ParsedMpeg2VideoTrack { + width: parsed_config.width, + height: parsed_config.height, + timescale: parsed_config.timescale, + decoder_specific_info, + object_type_indication: parsed_config.object_type_indication, + sample_entry_box, + pixel_aspect_ratio: parsed_config.pixel_aspect_ratio, + eof_terminated_trailing_sample, + samples, + }) +} + +pub(in crate::mux) fn build_mpeg2v_sample_entry_box( + width: u16, + height: u16, + decoder_specific_info: &[u8], + object_type_indication: u8, + timescale: u32, + samples: I, + pixel_aspect_ratio: Option<(u32, u32)>, +) -> Result, MuxError> +where + I: IntoIterator, +{ + let decoder_bitrates = build_btrt_from_sample_sizes(samples, timescale)?; + encode_mpeg2v_sample_entry_box( + width, + height, + decoder_specific_info, + object_type_indication, + decoder_bitrates, + pixel_aspect_ratio, + false, + ) +} + +pub(in crate::mux) fn build_transport_mpeg2v_sample_entry_box( + width: u16, + height: u16, + decoder_specific_info: &[u8], + object_type_indication: u8, + timescale: u32, + samples: I, + pixel_aspect_ratio: Option<(u32, u32)>, +) -> Result, MuxError> +where + I: IntoIterator, +{ + let decoder_bitrates = build_btrt_from_sample_sizes(samples, timescale)?; + encode_mpeg2v_sample_entry_box( + width, + height, + decoder_specific_info, + object_type_indication, + decoder_bitrates, + pixel_aspect_ratio, + true, + ) +} + +pub(in crate::mux) fn build_program_stream_mpeg2v_sample_entry_box( + config: ProgramStreamMpeg2vSampleEntryConfig<'_>, + samples: I, +) -> Result, MuxError> +where + I: IntoIterator, +{ + let decoder_bitrates = + build_program_stream_mpeg2v_btrt(samples, config.timescale, config.leading_media_time)?; + encode_mpeg2v_sample_entry_box( + config.width, + config.height, + config.decoder_specific_info, + config.object_type_indication, + decoder_bitrates, + config.pixel_aspect_ratio, + true, + ) +} + +fn encode_mpeg2v_sample_entry_box( + width: u16, + height: u16, + decoder_specific_info: &[u8], + object_type_indication: u8, + decoder_bitrates: Btrt, + pixel_aspect_ratio: Option<(u32, u32)>, + force_pixel_aspect_ratio_box: bool, +) -> Result, MuxError> { + let mut esds = Esds::default(); + esds.descriptors = vec![ + Descriptor { + tag: ES_DESCRIPTOR_TAG, + es_descriptor: Some(EsDescriptor::default()), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some(DecoderConfigDescriptor { + object_type_indication, + stream_type: 4, + reserved: true, + buffer_size_db: decoder_bitrates.buffer_size_db, + max_bitrate: decoder_bitrates.max_bitrate, + avg_bitrate: decoder_bitrates.avg_bitrate, + ..DecoderConfigDescriptor::default() + }), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_SPECIFIC_INFO_TAG, + size: u32::try_from(decoder_specific_info.len()) + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video decoder config size"))?, + data: decoder_specific_info.to_vec(), + ..Descriptor::default() + }, + Descriptor { + tag: SL_CONFIG_DESCRIPTOR_TAG, + size: 1, + data: vec![0x02], + ..Descriptor::default() + }, + ]; + esds.normalize_descriptor_sizes_for_mux() + .map_err(|_| MuxError::LayoutOverflow("MPEG-2 video esds"))?; + let mut child_boxes = vec![super::super::mp4::encode_typed_box(&esds, &[])?]; + if let Some((h_spacing, v_spacing)) = pixel_aspect_ratio + && (force_pixel_aspect_ratio_box || !(h_spacing == 1 && v_spacing == 1)) + { + child_boxes.push(super::super::mp4::encode_typed_box( + &Pasp { + h_spacing, + v_spacing, + }, + &[], + )?); + } + build_visual_sample_entry_box_with_compressor_name( + SAMPLE_ENTRY_MP4V, + width, + height, + DIRECT_COMPRESSOR_NAME, + &child_boxes, + ) +} + +fn build_program_stream_mpeg2v_btrt( + samples: I, + timescale: u32, + leading_media_time: u64, +) -> Result +where + I: IntoIterator, +{ + if timescale == 0 { + return Ok(Btrt::default()); + } + let mut saw_sample = false; + let mut buffer_size_db = 0_u32; + let mut total_payload_bytes = 0_u64; + let mut total_duration = 0_u64; + for (data_size, duration) in samples { + saw_sample = true; + buffer_size_db = buffer_size_db.max(data_size); + total_payload_bytes = total_payload_bytes + .checked_add(u64::from(data_size)) + .ok_or(MuxError::LayoutOverflow( + "program stream MPEG-2 video total payload bytes", + ))?; + total_duration = + total_duration + .checked_add(u64::from(duration)) + .ok_or(MuxError::LayoutOverflow( + "program stream MPEG-2 video total duration", + ))?; + } + if !saw_sample || total_duration == 0 { + return Ok(Btrt::default()); + } + total_duration = + total_duration + .checked_add(leading_media_time) + .ok_or(MuxError::LayoutOverflow( + "program stream MPEG-2 video total duration", + ))?; + let avg_bitrate = total_payload_bytes + .checked_mul(8) + .and_then(|bits| bits.checked_mul(u64::from(timescale))) + .ok_or(MuxError::LayoutOverflow( + "program stream MPEG-2 video average bitrate", + ))? + / total_duration; + let avg_bitrate = avg_bitrate & !7; + Ok(Btrt { + buffer_size_db, + max_bitrate: u32::try_from(avg_bitrate) + .map_err(|_| MuxError::LayoutOverflow("program stream MPEG-2 video maximum bitrate"))?, + avg_bitrate: u32::try_from(avg_bitrate) + .map_err(|_| MuxError::LayoutOverflow("program stream MPEG-2 video average bitrate"))?, + }) +} + +fn parse_mpeg2_decoder_specific_info( + decoder_specific_info: &[u8], + spec: &str, +) -> Result { + let sequence_start = find_mpeg2_start_code(decoder_specific_info, SEQUENCE_START_CODE) + .ok_or_else(|| { + invalid_mpeg2v( + spec, + "MPEG-2 video decoder config did not contain a sequence header start code", + ) + })?; + if decoder_specific_info.len() < sequence_start + 8 { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video sequence header is truncated", + )); + } + + let header = &decoder_specific_info[sequence_start + 4..sequence_start + 8]; + let mut width = (u16::from(header[0]) << 4) | u16::from(header[1] >> 4); + let mut height = (u16::from(header[1] & 0x0F) << 8) | u16::from(header[2]); + let aspect_ratio_code = header[3] >> 4; + let frame_rate_code = header[3] & 0x0F; + let (timescale, sample_duration) = + frame_rate_code_to_timing(frame_rate_code).ok_or_else(|| { + invalid_mpeg2v( + spec, + "MPEG-2 video sequence header carried an unsupported frame-rate code", + ) + })?; + let pixel_aspect_ratio = + aspect_ratio_code_to_pixel_aspect_ratio(aspect_ratio_code, width, height); + + let mut object_type_indication = MPEG1_VISUAL_OBJECT_TYPE; + if let Some(extension_start) = + find_mpeg2_sequence_extension_start(decoder_specific_info, sequence_start + 8) + { + if decoder_specific_info.len() < extension_start + 10 { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video sequence extension is truncated", + )); + } + let ext = &decoder_specific_info[extension_start + 4..extension_start + 10]; + let extension_id = ext[0] >> 4; + if extension_id != 0x01 { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video decoder config did not contain a sequence extension before the first picture", + )); + } + let profile_and_level_indication = ((ext[0] & 0x0F) << 4) | (ext[1] >> 4); + object_type_indication = map_mpeg2_profile_to_object_type(profile_and_level_indication) + .ok_or_else(|| { + invalid_mpeg2v( + spec, + "MPEG-2 video sequence extension carried an unsupported profile indication", + ) + })?; + let horizontal_size_extension = ((ext[1] & 0x01) << 1) | (ext[2] >> 7); + let vertical_size_extension = (ext[2] >> 5) & 0x03; + width |= u16::from(horizontal_size_extension) << 12; + height |= u16::from(vertical_size_extension) << 12; + } + + if width == 0 || height == 0 { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video decoder config carried a zero coded dimension", + )); + } + + Ok(ParsedMpeg2DecoderConfig { + width, + height, + timescale, + sample_duration, + object_type_indication, + pixel_aspect_ratio, + }) +} + +fn aspect_ratio_code_to_pixel_aspect_ratio( + aspect_ratio_code: u8, + width: u16, + height: u16, +) -> Option<(u32, u32)> { + match aspect_ratio_code { + 1 => Some((1, 1)), + 2 => reduce_ratio(u64::from(height) * 4, u64::from(width) * 3), + 3 => reduce_ratio(u64::from(height) * 16, u64::from(width) * 9), + 4 => reduce_ratio(u64::from(height) * 221, u64::from(width) * 100), + _ => None, + } +} + +fn reduce_ratio(numerator: u64, denominator: u64) -> Option<(u32, u32)> { + if numerator == 0 || denominator == 0 { + return None; + } + let divisor = gcd_u64(numerator, denominator); + let reduced_numerator = numerator / divisor; + let reduced_denominator = denominator / divisor; + Some(( + u32::try_from(reduced_numerator).ok()?, + u32::try_from(reduced_denominator).ok()?, + )) +} + +fn gcd_u64(mut lhs: u64, mut rhs: u64) -> u64 { + while rhs != 0 { + let remainder = lhs % rhs; + lhs = rhs; + rhs = remainder; + } + lhs +} + +fn find_mpeg2_start_code(bytes: &[u8], start_code: u8) -> Option { + let mut index = 0usize; + while index + 4 <= bytes.len() { + if bytes[index..].starts_with(&[0x00, 0x00, 0x01, start_code]) { + return Some(index); + } + index += 1; + } + None +} + +fn find_mpeg2_sequence_extension_start(bytes: &[u8], from: usize) -> Option { + let mut index = from; + while index + 5 <= bytes.len() { + if bytes[index..].starts_with(&[0x00, 0x00, 0x01, EXTENSION_START_CODE]) { + return Some(index); + } + if bytes[index..].starts_with(&[0x00, 0x00, 0x01, PICTURE_START_CODE]) { + return None; + } + index += 1; + } + None +} + +fn frame_rate_code_to_timing(frame_rate_code: u8) -> Option<(u32, u32)> { + match frame_rate_code { + 0x01 => Some(FRAME_RATE_23_976), + 0x02 => Some(FRAME_RATE_24), + 0x03 => Some(FRAME_RATE_25), + 0x04 => Some(FRAME_RATE_29_97), + 0x05 => Some(FRAME_RATE_30), + 0x06 => Some(FRAME_RATE_50), + 0x07 => Some(FRAME_RATE_59_94), + 0x08 => Some(FRAME_RATE_60), + _ => None, + } +} + +fn map_mpeg2_profile_to_object_type(profile_and_level_indication: u8) -> Option { + let escape_bit = profile_and_level_indication >> 7; + let profile = (profile_and_level_indication >> 4) & 0x07; + if escape_bit != 0 && profile == 0 { + return Some(MPEG2_VISUAL_OBJECT_TYPE_422); + } + match profile { + 0x05 => Some(MPEG2_VISUAL_OBJECT_TYPE_SIMPLE), + 0x04 => Some(MPEG2_VISUAL_OBJECT_TYPE_MAIN), + 0x03 => Some(MPEG2_VISUAL_OBJECT_TYPE_SNR), + 0x02 => Some(MPEG2_VISUAL_OBJECT_TYPE_SPATIAL), + 0x01 => Some(MPEG2_VISUAL_OBJECT_TYPE_HIGH), + _ => None, + } +} + +fn mpeg2v_picture_is_sync_sample_sync( + read_exact: &mut F, + logical_size: u64, + sample_start: u64, + spec: &str, +) -> Result +where + F: FnMut(u64, &mut [u8], &'static str) -> Result<(), MuxError>, +{ + if sample_start + .checked_add(6) + .is_none_or(|end| end > logical_size) + { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video picture header is truncated", + )); + } + let mut header = [0_u8; 2]; + read_exact( + sample_start + 4, + &mut header, + "MPEG-2 video picture coding-type header is truncated", + )?; + Ok(mpeg2v_picture_type(header) == 0x01) +} + +#[cfg(feature = "async")] +async fn mpeg2v_picture_is_sync_sample_file_async( + file: &mut TokioFile, + logical_size: u64, + sample_start: u64, + spec: &str, +) -> Result { + if sample_start + .checked_add(6) + .is_none_or(|end| end > logical_size) + { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video picture header is truncated", + )); + } + let mut header = [0_u8; 2]; + read_exact_at_async( + file, + sample_start + 4, + &mut header, + spec, + "MPEG-2 video picture coding-type header is truncated", + ) + .await?; + Ok(mpeg2v_picture_type(header) == 0x01) +} + +#[cfg(feature = "async")] +async fn mpeg2v_picture_is_sync_sample_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + logical_size: u64, + sample_start: u64, + spec: &str, +) -> Result { + if sample_start + .checked_add(6) + .is_none_or(|end| end > logical_size) + { + return Err(invalid_mpeg2v( + spec, + "MPEG-2 video picture header is truncated", + )); + } + let mut header = [0_u8; 2]; + read_segmented_bytes_async( + file, + segments, + logical_size, + sample_start + 4, + &mut header, + spec, + "MPEG-2 video picture coding-type header is truncated", + ) + .await?; + Ok(mpeg2v_picture_type(header) == 0x01) +} + +fn mpeg2v_picture_type(header: [u8; 2]) -> u8 { + ((u16::from_be_bytes(header) >> 3) & 0x07) as u8 +} + +fn invalid_mpeg2v(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::parse_mpeg2_decoder_specific_info; + + #[test] + fn parse_mpeg2_decoder_specific_info_reads_sequence_extension_size_bits() { + let decoder_specific_info = [ + 0x00, 0x00, 0x01, 0xB3, 0x14, 0x00, 0xB4, 0x33, 0xFF, 0xFF, 0xE0, 0x18, 0x00, 0x00, + 0x01, 0xB5, 0x14, 0x8A, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0xB8, 0x00, 0x08, + 0x00, 0x40, + ]; + let parsed = parse_mpeg2_decoder_specific_info(&decoder_specific_info, "test").unwrap(); + + assert_eq!(parsed.width, 320); + assert_eq!(parsed.height, 180); + assert_eq!(parsed.pixel_aspect_ratio, Some((1, 1))); + assert_eq!(parsed.object_type_indication, 0x61); + } +} diff --git a/src/mux/demux/nhml.rs b/src/mux/demux/nhml.rs new file mode 100644 index 0000000..0055ef3 --- /dev/null +++ b/src/mux/demux/nhml.rs @@ -0,0 +1,900 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +#[cfg(feature = "async")] +use tokio::fs as tokio_fs; +#[cfg(feature = "async")] +use tokio::io::{AsyncBufReadExt, BufReader as AsyncBufReader}; + +use super::super::import::{ + CandidateSample, SegmentedMuxSourceSegment, SegmentedMuxSourceSegmentData, + SegmentedMuxSourceSpec, TrackCandidate, direct_ingest_handler_name, + direct_ingest_mux_policy_with_preferred_track_id, +}; +use super::super::{MuxError, MuxTrackKind}; +use super::detect::DetectedContainerPathKind; + +/// One detected XML sidecar family supported by the current direct-ingest importer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::mux) enum DetectedNhmlSidecarKind { + Nhml, + Nhnt, +} + +/// One parsed staged-source spec recovered from an NHML/NHNT-like sidecar. +#[derive(Clone)] +pub(in crate::mux) enum ParsedNhmlSourceSpec { + File(PathBuf), + Segmented(SegmentedMuxSourceSpec), +} + +/// Parsed direct-ingest tracks plus staged-source specs recovered from one sidecar. +#[derive(Clone)] +pub(in crate::mux) struct ParsedNhmlSource { + pub(in crate::mux) source_specs: BTreeMap, + pub(in crate::mux) tracks: Vec, +} + +#[derive(Clone)] +struct ParsedTrackDescriptor { + track_id: u32, + kind: MuxTrackKind, + timescale: u32, + language: [u8; 3], + handler_name: String, + sample_entry_type: String, + sample_entry_box: Vec, + width: u16, + height: u16, + source_edit_media_time: Option, + sample_roll_distance: Option, +} + +#[derive(Clone)] +struct PendingSource { + index: usize, + path: PathBuf, + segmented: bool, + total_size: u64, + segments: Vec, +} + +#[derive(Clone)] +struct XmlTag { + name: String, + attrs: BTreeMap, + self_closing: bool, + closing: bool, +} + +#[derive(Default)] +struct NhmlParserState { + source_specs: BTreeMap, + tracks: Vec, + pending_source: Option, + pending_track: Option, + packet_tracks: BTreeMap, + packet_samples: BTreeMap>, + root_kind: Option, + saw_root: bool, +} + +/// Detects whether one path/prefix pair looks like one supported NHML/NHNT-like sidecar. +pub(in crate::mux) fn detect_nhml_sidecar_kind( + path: &Path, + prefix: &[u8], +) -> Option { + let root_name = extract_xml_root_name(prefix)?; + if root_name.eq_ignore_ascii_case("nhml") || root_name.eq_ignore_ascii_case("nhmlstream") { + return Some(DetectedContainerPathKind::Nhml); + } + if root_name.eq_ignore_ascii_case("nhnt") || root_name.eq_ignore_ascii_case("nhntstream") { + return Some(DetectedContainerPathKind::Nhnt); + } + let extension = path.extension()?.to_str()?; + if extension.eq_ignore_ascii_case("nhml") { + return Some(DetectedContainerPathKind::Nhml); + } + if extension.eq_ignore_ascii_case("nhnt") { + return Some(DetectedContainerPathKind::Nhnt); + } + None +} + +pub(in crate::mux) fn parse_nhml_source_sync( + path: &Path, + kind: DetectedNhmlSidecarKind, +) -> Result { + let file = fs::File::open(path)?; + let mut reader = BufReader::new(file); + let mut line = String::new(); + let mut first_line = true; + let mut state = NhmlParserState::default(); + loop { + line.clear(); + if reader.read_line(&mut line)? == 0 { + break; + } + let rendered = if first_line { + first_line = false; + line.strip_prefix('\u{FEFF}').unwrap_or(&line) + } else { + &line + }; + state.push_line(path, kind, rendered)?; + } + state.finish(path, kind) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn parse_nhml_source_async( + path: &Path, + kind: DetectedNhmlSidecarKind, +) -> Result { + let file = tokio_fs::File::open(path).await?; + let mut reader = AsyncBufReader::new(file); + let mut line = String::new(); + let mut first_line = true; + let mut state = NhmlParserState::default(); + loop { + line.clear(); + if reader.read_line(&mut line).await? == 0 { + break; + } + let rendered = if first_line { + first_line = false; + line.strip_prefix('\u{FEFF}').unwrap_or(&line) + } else { + &line + }; + state.push_line(path, kind, rendered)?; + } + state.finish(path, kind) +} + +impl NhmlParserState { + fn push_line( + &mut self, + path: &Path, + expected_kind: DetectedNhmlSidecarKind, + raw_line: &str, + ) -> Result<(), MuxError> { + let Some(tag) = + parse_xml_tag(raw_line).map_err(|message| invalid_sidecar(path, &message))? + else { + return Ok(()); + }; + let name = tag.name.as_str(); + if tag.closing { + if name.eq_ignore_ascii_case("source") { + let Some(source) = self.pending_source.take() else { + return Err(invalid_sidecar( + path, + "encountered `` without ``", + )); + }; + insert_source_spec(path, &mut self.source_specs, source)?; + return Ok(()); + } + if name.eq_ignore_ascii_case("track") { + let Some(track) = self.pending_track.take() else { + return Err(invalid_sidecar( + path, + "encountered `` without ``", + )); + }; + self.tracks.push(track); + return Ok(()); + } + return Ok(()); + } + + if name.eq_ignore_ascii_case("nhml") || name.eq_ignore_ascii_case("nhmlstream") { + self.root_kind = Some(DetectedNhmlSidecarKind::Nhml); + self.saw_root = true; + return Ok(()); + } + if name.eq_ignore_ascii_case("nhnt") || name.eq_ignore_ascii_case("nhntstream") { + self.root_kind = Some(DetectedNhmlSidecarKind::Nhnt); + self.saw_root = true; + return Ok(()); + } + if !self.saw_root { + return Err(invalid_sidecar(path, "missing NHML/NHNT root element")); + } + + if name.eq_ignore_ascii_case("source") { + let source = parse_source_tag(path, &tag.attrs)?; + if tag.self_closing { + insert_source_spec(path, &mut self.source_specs, source)?; + } else if self.pending_source.replace(source).is_some() { + return Err(invalid_sidecar( + path, + "encountered nested `` elements", + )); + } + return Ok(()); + } + + if name.eq_ignore_ascii_case("segment") { + let Some(source) = self.pending_source.as_mut() else { + return Err(invalid_sidecar( + path, + "encountered `` outside ``", + )); + }; + source.segments.push(parse_segment_tag(path, &tag.attrs)?); + return Ok(()); + } + + match expected_kind { + DetectedNhmlSidecarKind::Nhml => { + if name.eq_ignore_ascii_case("track") { + let descriptor = parse_track_descriptor(path, &tag.attrs)?; + let track = track_from_descriptor(descriptor, Vec::new()); + if tag.self_closing { + self.tracks.push(track); + } else if self.pending_track.replace(track).is_some() { + return Err(invalid_sidecar( + path, + "encountered nested `` elements", + )); + } + return Ok(()); + } + if name.eq_ignore_ascii_case("sample") { + let Some(track) = self.pending_track.as_mut() else { + return Err(invalid_sidecar( + path, + "encountered `` outside ``", + )); + }; + track.samples.push(parse_sample_tag(path, &tag.attrs)?); + return Ok(()); + } + } + DetectedNhmlSidecarKind::Nhnt => { + if name.eq_ignore_ascii_case("track") { + let descriptor = parse_track_descriptor(path, &tag.attrs)?; + self.packet_tracks.insert(descriptor.track_id, descriptor); + return Ok(()); + } + if name.eq_ignore_ascii_case("packet") || name.eq_ignore_ascii_case("nhntsample") { + let (track_id, packet_index, sample) = parse_packet_tag(path, &tag.attrs)?; + self.packet_samples + .entry(track_id) + .or_default() + .push((packet_index, sample)); + return Ok(()); + } + } + } + Ok(()) + } + + fn finish( + mut self, + path: &Path, + expected_kind: DetectedNhmlSidecarKind, + ) -> Result { + let Some(actual_kind) = self.root_kind else { + return Err(invalid_sidecar(path, "missing NHML/NHNT root element")); + }; + if actual_kind != expected_kind { + return Err(invalid_sidecar( + path, + "sidecar root does not match the detected sidecar kind", + )); + } + if self.pending_source.is_some() { + return Err(invalid_sidecar(path, "unterminated `` element")); + } + if self.pending_track.is_some() { + return Err(invalid_sidecar(path, "unterminated `` element")); + } + + if expected_kind == DetectedNhmlSidecarKind::Nhnt { + for (track_id, descriptor) in self.packet_tracks { + let Some(mut samples) = self.packet_samples.remove(&track_id) else { + return Err(invalid_sidecar( + path, + &format!("NHNT track {track_id} does not carry any packet entries"), + )); + }; + samples.sort_by_key(|(packet_index, _)| *packet_index); + let samples = samples.into_iter().map(|(_, sample)| sample).collect(); + self.tracks.push(track_from_descriptor(descriptor, samples)); + } + if !self.packet_samples.is_empty() { + let missing_track_id = *self.packet_samples.keys().next().unwrap(); + return Err(invalid_sidecar( + path, + &format!( + "NHNT packet track {missing_track_id} is missing the required `` metadata entry" + ), + )); + } + } + + Ok(ParsedNhmlSource { + source_specs: self.source_specs, + tracks: self.tracks, + }) + } +} + +fn parse_source_tag( + path: &Path, + attrs: &BTreeMap, +) -> Result { + let index = required_attr_usize(path, attrs, "index")?; + let path_attr = required_attr_string(path, attrs, "path")?; + let segmented = required_attr_bool(path, attrs, "segmented")?; + let total_size = required_attr_u64(path, attrs, "totalSize")?; + Ok(PendingSource { + index, + path: resolve_sidecar_path(path, &path_attr), + segmented, + total_size, + segments: Vec::new(), + }) +} + +fn parse_segment_tag( + path: &Path, + attrs: &BTreeMap, +) -> Result { + let kind = required_attr_string(path, attrs, "kind")?; + let logical_offset = required_attr_u64(path, attrs, "logicalOffset")?; + let logical_size = required_attr_u64(path, attrs, "logicalSize")?; + let data = match kind.as_str() { + "prefix" => { + let data_hex = required_attr_string(path, attrs, "dataHex")?; + let bytes = decode_hex(path, "dataHex", &data_hex)?; + let prefix: [u8; 4] = bytes.try_into().map_err(|_| { + invalid_sidecar(path, "prefix segment `dataHex` must decode to four bytes") + })?; + if logical_size != 4 { + return Err(invalid_sidecar( + path, + "prefix segment `logicalSize` must stay equal to four bytes", + )); + } + SegmentedMuxSourceSegmentData::Prefix(prefix) + } + "bytes" => { + let data_hex = required_attr_string(path, attrs, "dataHex")?; + let bytes = decode_hex(path, "dataHex", &data_hex)?; + if logical_size != bytes.len() as u64 { + return Err(invalid_sidecar( + path, + "inline segment `logicalSize` does not match the decoded `dataHex` length", + )); + } + SegmentedMuxSourceSegmentData::Bytes(bytes) + } + "file_range" => { + let source_offset = required_attr_u64(path, attrs, "sourceOffset")?; + let size = u32::try_from(logical_size) + .map_err(|_| invalid_sidecar(path, "file-range segment size exceeds u32"))?; + if let Some(source_path) = attrs.get("sourcePath") { + SegmentedMuxSourceSegmentData::ExternalFileRange { + path: resolve_sidecar_path(path, source_path), + source_offset, + size, + } + } else { + SegmentedMuxSourceSegmentData::FileRange { + source_offset, + size, + } + } + } + other => { + return Err(invalid_sidecar( + path, + &format!("unsupported NHML/NHNT source segment kind `{other}`"), + )); + } + }; + Ok(SegmentedMuxSourceSegment { + logical_offset, + data, + }) +} + +fn insert_source_spec( + path: &Path, + source_specs: &mut BTreeMap, + source: PendingSource, +) -> Result<(), MuxError> { + let spec = if source.segmented { + if source.segments.is_empty() { + return Err(invalid_sidecar( + path, + "segmented sidecar sources must carry one or more `` entries", + )); + } + ParsedNhmlSourceSpec::Segmented(SegmentedMuxSourceSpec { + path: source.path, + segments: source.segments, + total_size: source.total_size, + }) + } else { + ParsedNhmlSourceSpec::File(source.path) + }; + if source_specs.insert(source.index, spec).is_some() { + return Err(invalid_sidecar( + path, + &format!("duplicate staged source index {}", source.index), + )); + } + Ok(()) +} + +fn parse_track_descriptor( + path: &Path, + attrs: &BTreeMap, +) -> Result { + let track_id = required_attr_u32(path, attrs, "trackID")?; + let kind = parse_track_kind(path, &required_attr_string(path, attrs, "kind")?)?; + let timescale = required_attr_u32(path, attrs, "timescale")?; + let language = parse_language(path, &required_attr_string(path, attrs, "language")?)?; + let handler_name = attrs + .get("handlerName") + .cloned() + .unwrap_or_else(|| default_handler_name_for_kind(kind)); + let sample_entry_box_hex = required_attr_string(path, attrs, "sampleEntryBoxHex")?; + let sample_entry_box = decode_hex(path, "sampleEntryBoxHex", &sample_entry_box_hex)?; + if sample_entry_box.len() < 8 { + return Err(invalid_sidecar( + path, + "sample entry box hex must decode to one full MP4 box header and payload", + )); + } + let declared_box_size = u32::from_be_bytes([ + sample_entry_box[0], + sample_entry_box[1], + sample_entry_box[2], + sample_entry_box[3], + ]); + if declared_box_size != sample_entry_box.len() as u32 { + return Err(invalid_sidecar( + path, + "sample entry box hex does not decode to one self-sized MP4 box payload", + )); + } + let sample_entry_type = attrs + .get("sampleEntryType") + .cloned() + .unwrap_or_else(|| String::from_utf8_lossy(&sample_entry_box[4..8]).into_owned()); + let width = optional_attr_u16(path, attrs, "width")?.unwrap_or(0); + let height = optional_attr_u16(path, attrs, "height")?.unwrap_or(0); + let source_edit_media_time = optional_attr_u64(path, attrs, "sourceEditMediaTime")?; + let sample_roll_distance = optional_attr_i16(path, attrs, "sampleRollDistance")?; + Ok(ParsedTrackDescriptor { + track_id, + kind, + timescale, + language, + handler_name, + sample_entry_type, + sample_entry_box, + width, + height, + source_edit_media_time, + sample_roll_distance, + }) +} + +fn track_from_descriptor( + descriptor: ParsedTrackDescriptor, + samples: Vec, +) -> TrackCandidate { + let codec_label = + codec_label_from_sample_entry_type(&descriptor.sample_entry_type, descriptor.kind); + let mut mux_policy = direct_ingest_mux_policy_with_preferred_track_id( + &codec_label, + descriptor.kind, + descriptor.track_id, + ); + if let Some(sample_roll_distance) = descriptor.sample_roll_distance { + mux_policy = mux_policy.with_sample_roll_distance(sample_roll_distance); + } + TrackCandidate { + track_id: descriptor.track_id, + kind: descriptor.kind, + timescale: descriptor.timescale, + language: descriptor.language, + handler_name: descriptor.handler_name, + mux_policy, + width: descriptor.width, + height: descriptor.height, + sample_entry_box: descriptor.sample_entry_box, + source_edit_media_time: descriptor.source_edit_media_time, + samples, + } +} + +fn parse_sample_tag( + path: &Path, + attrs: &BTreeMap, +) -> Result { + Ok(CandidateSample { + source_index: required_attr_usize(path, attrs, "sourceIndex")?, + data_offset: required_attr_u64(path, attrs, "dataOffset")?, + data_size: required_attr_u32(path, attrs, "dataSize")?, + duration: required_attr_u32(path, attrs, "duration")?, + composition_time_offset: required_attr_i32(path, attrs, "compositionTimeOffset")?, + is_sync_sample: required_attr_bool(path, attrs, "sync")?, + }) +} + +fn parse_packet_tag( + path: &Path, + attrs: &BTreeMap, +) -> Result<(u32, usize, CandidateSample), MuxError> { + let track_id = required_attr_u32(path, attrs, "trackID")?; + let packet_index = required_attr_usize(path, attrs, "packetIndex")?; + Ok(( + track_id, + packet_index, + CandidateSample { + source_index: required_attr_usize(path, attrs, "sourceIndex")?, + data_offset: required_attr_u64(path, attrs, "dataOffset")?, + data_size: required_attr_u32(path, attrs, "dataSize")?, + duration: required_attr_u32(path, attrs, "duration")?, + composition_time_offset: required_attr_i32(path, attrs, "compositionTimeOffset")?, + is_sync_sample: required_attr_bool(path, attrs, "sync")?, + }, + )) +} + +fn parse_xml_tag(line: &str) -> Result, String> { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with("') { + return Err(format!("unsupported NHML/NHNT line `{trimmed}`")); + } + if let Some(content) = trimmed + .strip_prefix("')) + { + return Ok(Some(XmlTag { + name: content.trim().to_string(), + attrs: BTreeMap::new(), + self_closing: false, + closing: true, + })); + } + let mut inner = trimmed + .strip_prefix('<') + .and_then(|value| value.strip_suffix('>')) + .ok_or_else(|| format!("unsupported NHML/NHNT line `{trimmed}`"))? + .trim(); + let self_closing = inner.ends_with('/'); + if self_closing { + inner = inner[..inner.len() - 1].trim_end(); + } + let name_end = inner.find(char::is_whitespace).unwrap_or(inner.len()); + let name = inner[..name_end].to_string(); + let mut attrs = BTreeMap::new(); + let mut cursor = inner[name_end..].trim_start(); + while !cursor.is_empty() { + let Some(eq_pos) = cursor.find('=') else { + return Err(format!("malformed NHML/NHNT attribute list in `{trimmed}`")); + }; + let key = cursor[..eq_pos].trim(); + if key.is_empty() { + return Err(format!("malformed NHML/NHNT attribute list in `{trimmed}`")); + } + let rest = cursor[eq_pos + 1..].trim_start(); + let Some(rest) = rest.strip_prefix('"') else { + return Err(format!( + "NHML/NHNT attribute `{key}` in `{trimmed}` must use double quotes" + )); + }; + let Some(value_end) = rest.find('"') else { + return Err(format!( + "unterminated NHML/NHNT attribute `{key}` in `{trimmed}`" + )); + }; + attrs.insert(key.to_string(), xml_unescape_attr(&rest[..value_end])?); + cursor = rest[value_end + 1..].trim_start(); + } + Ok(Some(XmlTag { + name, + attrs, + self_closing, + closing: false, + })) +} + +fn extract_xml_root_name(prefix: &[u8]) -> Option { + let text = std::str::from_utf8(prefix).ok()?; + let text = text.trim_start_matches('\u{FEFF}').trim_start(); + let text = if text.starts_with("")?; + text[end + 2..].trim_start() + } else { + text + }; + let body = text.strip_prefix('<')?; + let name_end = body + .find(|ch: char| ch.is_whitespace() || ch == '>' || ch == '/') + .unwrap_or(body.len()); + if name_end == 0 { + None + } else { + Some(body[..name_end].to_string()) + } +} + +fn xml_unescape_attr(value: &str) -> Result { + let mut rendered = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + while let Some(ch) = chars.next() { + if ch != '&' { + rendered.push(ch); + continue; + } + let mut entity = String::new(); + for next in chars.by_ref() { + if next == ';' { + break; + } + entity.push(next); + } + match entity.as_str() { + "amp" => rendered.push('&'), + "lt" => rendered.push('<'), + "gt" => rendered.push('>'), + "quot" => rendered.push('"'), + "#39" => rendered.push('\''), + _ => return Err(format!("unsupported XML entity `&{entity};`")), + } + } + Ok(rendered) +} + +fn required_attr_string( + path: &Path, + attrs: &BTreeMap, + key: &str, +) -> Result { + attrs + .get(key) + .cloned() + .ok_or_else(|| invalid_sidecar(path, &format!("missing required attribute `{key}`"))) +} + +fn required_attr_bool( + path: &Path, + attrs: &BTreeMap, + key: &str, +) -> Result { + match required_attr_string(path, attrs, key)?.as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(invalid_sidecar( + path, + &format!("attribute `{key}` must stay `true` or `false`"), + )), + } +} + +fn required_attr_u32( + path: &Path, + attrs: &BTreeMap, + key: &str, +) -> Result { + required_attr_string(path, attrs, key)? + .parse::() + .map_err(|_| { + invalid_sidecar( + path, + &format!("attribute `{key}` must be one unsigned 32-bit integer"), + ) + }) +} + +fn required_attr_u64( + path: &Path, + attrs: &BTreeMap, + key: &str, +) -> Result { + required_attr_string(path, attrs, key)? + .parse::() + .map_err(|_| { + invalid_sidecar( + path, + &format!("attribute `{key}` must be one unsigned 64-bit integer"), + ) + }) +} + +fn required_attr_i32( + path: &Path, + attrs: &BTreeMap, + key: &str, +) -> Result { + required_attr_string(path, attrs, key)? + .parse::() + .map_err(|_| { + invalid_sidecar( + path, + &format!("attribute `{key}` must be one signed 32-bit integer"), + ) + }) +} + +fn required_attr_usize( + path: &Path, + attrs: &BTreeMap, + key: &str, +) -> Result { + required_attr_string(path, attrs, key)? + .parse::() + .map_err(|_| { + invalid_sidecar( + path, + &format!("attribute `{key}` must be one platform-sized unsigned integer"), + ) + }) +} + +fn optional_attr_u16( + path: &Path, + attrs: &BTreeMap, + key: &str, +) -> Result, MuxError> { + let Some(value) = attrs.get(key) else { + return Ok(None); + }; + value.parse::().map(Some).map_err(|_| { + invalid_sidecar( + path, + &format!("attribute `{key}` must be one unsigned 16-bit integer"), + ) + }) +} + +fn optional_attr_u64( + path: &Path, + attrs: &BTreeMap, + key: &str, +) -> Result, MuxError> { + let Some(value) = attrs.get(key) else { + return Ok(None); + }; + value.parse::().map(Some).map_err(|_| { + invalid_sidecar( + path, + &format!("attribute `{key}` must be one unsigned 64-bit integer"), + ) + }) +} + +fn optional_attr_i16( + path: &Path, + attrs: &BTreeMap, + key: &str, +) -> Result, MuxError> { + let Some(value) = attrs.get(key) else { + return Ok(None); + }; + value.parse::().map(Some).map_err(|_| { + invalid_sidecar( + path, + &format!("attribute `{key}` must be one signed 16-bit integer"), + ) + }) +} + +fn parse_track_kind(path: &Path, value: &str) -> Result { + match value { + "audio" => Ok(MuxTrackKind::Audio), + "video" => Ok(MuxTrackKind::Video), + "text" => Ok(MuxTrackKind::Text), + "subtitle" => Ok(MuxTrackKind::Subtitle), + _ => Err(invalid_sidecar( + path, + &format!("unsupported sidecar track kind `{value}`"), + )), + } +} + +fn parse_language(path: &Path, value: &str) -> Result<[u8; 3], MuxError> { + let bytes = value.as_bytes(); + if bytes.len() != 3 || !bytes.iter().all(|byte| byte.is_ascii_lowercase()) { + return Err(invalid_sidecar( + path, + "sidecar language codes must stay one three-letter lowercase ISO-639-2 code", + )); + } + Ok([bytes[0], bytes[1], bytes[2]]) +} + +fn default_handler_name_for_kind(kind: MuxTrackKind) -> String { + match kind { + MuxTrackKind::Audio => direct_ingest_handler_name("audio"), + MuxTrackKind::Video => direct_ingest_handler_name("h264"), + MuxTrackKind::Text => "TextHandler".to_string(), + MuxTrackKind::Subtitle => "SubtitleHandler".to_string(), + } +} + +fn codec_label_from_sample_entry_type(sample_entry_type: &str, kind: MuxTrackKind) -> String { + match sample_entry_type { + "Opus" => "opus".to_string(), + "fLaC" => "flac".to_string(), + "vp08" => "vp8".to_string(), + "vp09" => "vp9".to_string(), + "av01" => "av1".to_string(), + "avc1" | "avc3" | "AVC1" => "h264".to_string(), + "hvc1" | "hev1" => "h265".to_string(), + "mhm1" | "mha1" => "mhas".to_string(), + "fpcm" | "ipcm" => "pcm".to_string(), + "alaw" => "alaw".to_string(), + "ulaw" => "mulaw".to_string(), + _ => match kind { + MuxTrackKind::Audio => "audio".to_string(), + MuxTrackKind::Video => "video".to_string(), + MuxTrackKind::Text => "text".to_string(), + MuxTrackKind::Subtitle => "subtitle".to_string(), + }, + } +} + +fn decode_hex(path: &Path, key: &str, value: &str) -> Result, MuxError> { + if !value.len().is_multiple_of(2) { + return Err(invalid_sidecar( + path, + &format!("attribute `{key}` must carry one even-length hexadecimal string"), + )); + } + let mut bytes = Vec::with_capacity(value.len() / 2); + let as_bytes = value.as_bytes(); + let mut index = 0usize; + while index < as_bytes.len() { + let hi = decode_hex_nibble(path, key, as_bytes[index])?; + let lo = decode_hex_nibble(path, key, as_bytes[index + 1])?; + bytes.push((hi << 4) | lo); + index += 2; + } + Ok(bytes) +} + +fn decode_hex_nibble(path: &Path, key: &str, value: u8) -> Result { + match value { + b'0'..=b'9' => Ok(value - b'0'), + b'a'..=b'f' => Ok(value - b'a' + 10), + b'A'..=b'F' => Ok(value - b'A' + 10), + _ => Err(invalid_sidecar( + path, + &format!("attribute `{key}` must carry one hexadecimal string"), + )), + } +} + +fn resolve_sidecar_path(sidecar_path: &Path, value: &str) -> PathBuf { + let candidate = PathBuf::from(value); + if candidate.is_absolute() { + candidate + } else if let Some(parent) = sidecar_path.parent() { + parent.join(candidate) + } else { + candidate + } +} + +fn invalid_sidecar(path: &Path, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("invalid NHML/NHNT sidecar: {message}"), + } +} diff --git a/src/mux/demux/ogg_common.rs b/src/mux/demux/ogg_common.rs new file mode 100644 index 0000000..49df45b --- /dev/null +++ b/src/mux/demux/ogg_common.rs @@ -0,0 +1,469 @@ +use std::fs::File; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use super::super::import::{SourceFileSpan, read_exact_at_sync, read_spans_sync}; +#[cfg(feature = "async")] +use super::super::import::{read_exact_at_async, read_spans_async}; +use super::super::{MuxError, MuxRawCodec}; +use super::detect::DetectedPathTrackKind; + +#[derive(Clone)] +pub(super) struct OggPageHeader { + pub(super) header_type: u8, + pub(super) granule_position: u64, + pub(super) serial_no: u32, + pub(super) lacing_values: Vec, + pub(super) payload_offset: u64, + pub(super) payload_size: u64, +} + +#[derive(Default)] +pub(super) struct OggPacketBuilder { + spans: Vec, + total_size: u32, +} + +pub(super) struct CompletedOggPacket { + pub(super) spans: Vec, + pub(super) total_size: u32, +} + +impl OggPacketBuilder { + pub(super) fn push_span(&mut self, source_offset: u64, size: u32) -> Result<(), MuxError> { + if size == 0 { + return Ok(()); + } + self.total_size = self + .total_size + .checked_add(size) + .ok_or(MuxError::LayoutOverflow("Ogg packet size"))?; + self.spans.push(SourceFileSpan { + source_offset, + size, + }); + Ok(()) + } + + pub(super) fn is_empty(&self) -> bool { + self.total_size == 0 + } + + pub(super) fn finish(&mut self) -> CompletedOggPacket { + CompletedOggPacket { + spans: std::mem::take(&mut self.spans), + total_size: std::mem::take(&mut self.total_size), + } + } +} + +pub(in crate::mux) fn detect_ogg_track_kind_sync( + file: &mut File, +) -> Result { + detect_ogg_track_kind_with_reader_sync(file, "Ogg path detection") +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn detect_ogg_track_kind_async( + file: &mut TokioFile, +) -> Result { + detect_ogg_track_kind_with_reader_async(file, "Ogg path detection").await +} + +fn detect_ogg_track_kind_with_reader_sync( + file: &mut File, + spec: &str, +) -> Result { + let file_size = file.metadata()?.len(); + let mut offset = 0_u64; + let mut packet_builder = OggPacketBuilder::default(); + while offset < file_size { + let page = read_ogg_page_header_sync(file, offset, spec)?; + offset = page + .payload_offset + .checked_add(page.payload_size) + .ok_or(MuxError::LayoutOverflow("Ogg page range"))?; + let mut page_cursor = page.payload_offset; + for lacing in &page.lacing_values { + packet_builder.push_span(page_cursor, u32::from(*lacing))?; + page_cursor += u64::from(*lacing); + if *lacing < 255 { + let packet = packet_builder.finish(); + if packet.total_size == 0 { + continue; + } + let prefix = read_packet_prefix_sync( + file, + &packet.spans, + 64, + spec, + "Ogg packet is truncated while reading the identification payload", + )?; + if prefix.starts_with(b"OpusHead") { + return Ok(DetectedPathTrackKind::Raw(MuxRawCodec::Opus)); + } + if looks_like_vorbis_identification_packet(&prefix) { + return Ok(DetectedPathTrackKind::Raw(MuxRawCodec::Vorbis)); + } + if looks_like_speex_identification_packet(&prefix) { + return Ok(DetectedPathTrackKind::Raw(MuxRawCodec::Speex)); + } + if looks_like_ogg_flac_identification_packet(&prefix) { + return Ok(DetectedPathTrackKind::Raw(MuxRawCodec::Flac)); + } + if looks_like_theora_identification_packet(&prefix) { + return Ok(DetectedPathTrackKind::Raw(MuxRawCodec::Theora)); + } + return Ok(DetectedPathTrackKind::Unknown); + } + } + } + Ok(DetectedPathTrackKind::Unknown) +} + +#[cfg(feature = "async")] +async fn detect_ogg_track_kind_with_reader_async( + file: &mut TokioFile, + spec: &str, +) -> Result { + let file_size = file.metadata().await?.len(); + let mut offset = 0_u64; + let mut packet_builder = OggPacketBuilder::default(); + while offset < file_size { + let page = read_ogg_page_header_async(file, offset, spec).await?; + offset = page + .payload_offset + .checked_add(page.payload_size) + .ok_or(MuxError::LayoutOverflow("Ogg page range"))?; + let mut page_cursor = page.payload_offset; + for lacing in &page.lacing_values { + packet_builder.push_span(page_cursor, u32::from(*lacing))?; + page_cursor += u64::from(*lacing); + if *lacing < 255 { + let packet = packet_builder.finish(); + if packet.total_size == 0 { + continue; + } + let prefix = read_packet_prefix_async( + file, + &packet.spans, + 64, + spec, + "Ogg packet is truncated while reading the identification payload", + ) + .await?; + if prefix.starts_with(b"OpusHead") { + return Ok(DetectedPathTrackKind::Raw(MuxRawCodec::Opus)); + } + if looks_like_vorbis_identification_packet(&prefix) { + return Ok(DetectedPathTrackKind::Raw(MuxRawCodec::Vorbis)); + } + if looks_like_speex_identification_packet(&prefix) { + return Ok(DetectedPathTrackKind::Raw(MuxRawCodec::Speex)); + } + if looks_like_ogg_flac_identification_packet(&prefix) { + return Ok(DetectedPathTrackKind::Raw(MuxRawCodec::Flac)); + } + if looks_like_theora_identification_packet(&prefix) { + return Ok(DetectedPathTrackKind::Raw(MuxRawCodec::Theora)); + } + return Ok(DetectedPathTrackKind::Unknown); + } + } + } + Ok(DetectedPathTrackKind::Unknown) +} + +fn looks_like_ogg_flac_identification_packet(packet: &[u8]) -> bool { + packet.starts_with(b"fLaC") + || (packet.len() >= 13 + && packet[0] == 0x7F + && &packet[1..5] == b"FLAC" + && &packet[9..13] == b"fLaC") +} + +fn looks_like_vorbis_identification_packet(packet: &[u8]) -> bool { + packet.len() >= 7 && packet[0] == 0x01 && &packet[1..7] == b"vorbis" +} + +fn looks_like_speex_identification_packet(packet: &[u8]) -> bool { + packet.starts_with(b"Speex") +} + +fn looks_like_theora_identification_packet(packet: &[u8]) -> bool { + packet.len() >= 7 && packet[0] == 0x80 && &packet[1..7] == b"theora" +} + +fn ogg_page_crc(page_bytes: &[u8]) -> u32 { + let mut crc = 0_u32; + for byte in page_bytes { + crc ^= u32::from(*byte) << 24; + for _ in 0..8 { + crc = if crc & 0x8000_0000 != 0 { + (crc << 1) ^ 0x04C1_1DB7 + } else { + crc << 1 + }; + } + } + crc +} + +fn validate_ogg_page_crc_sync( + file: &mut File, + offset: u64, + header: &[u8; 27], + lacing_values: &[u8], + payload_offset: u64, + payload_size: u64, + spec: &str, +) -> Result<(), MuxError> { + let total_page_size = 27_u64 + .checked_add(u64::try_from(lacing_values.len()).unwrap()) + .and_then(|value| value.checked_add(payload_size)) + .ok_or(MuxError::LayoutOverflow("Ogg page size"))?; + let mut page = vec![ + 0_u8; + usize::try_from(total_page_size) + .map_err(|_| MuxError::LayoutOverflow("Ogg page size"))? + ]; + page[..27].copy_from_slice(header); + page[27..27 + lacing_values.len()].copy_from_slice(lacing_values); + if payload_size != 0 { + read_exact_at_sync( + file, + payload_offset, + &mut page[27 + lacing_values.len()..], + spec, + "Ogg page payload is truncated while validating CRC", + )?; + } + let expected_crc = u32::from_le_bytes(header[22..26].try_into().unwrap()); + page[22..26].fill(0); + if ogg_page_crc(&page) != expected_crc { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("Ogg page at byte offset {offset} failed CRC validation"), + }); + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn validate_ogg_page_crc_async( + file: &mut TokioFile, + offset: u64, + header: &[u8; 27], + lacing_values: &[u8], + payload_offset: u64, + payload_size: u64, + spec: &str, +) -> Result<(), MuxError> { + let total_page_size = 27_u64 + .checked_add(u64::try_from(lacing_values.len()).unwrap()) + .and_then(|value| value.checked_add(payload_size)) + .ok_or(MuxError::LayoutOverflow("Ogg page size"))?; + let mut page = vec![ + 0_u8; + usize::try_from(total_page_size) + .map_err(|_| MuxError::LayoutOverflow("Ogg page size"))? + ]; + page[..27].copy_from_slice(header); + page[27..27 + lacing_values.len()].copy_from_slice(lacing_values); + if payload_size != 0 { + read_exact_at_async( + file, + payload_offset, + &mut page[27 + lacing_values.len()..], + spec, + "Ogg page payload is truncated while validating CRC", + ) + .await?; + } + let expected_crc = u32::from_le_bytes(header[22..26].try_into().unwrap()); + page[22..26].fill(0); + if ogg_page_crc(&page) != expected_crc { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("Ogg page at byte offset {offset} failed CRC validation"), + }); + } + Ok(()) +} + +pub(super) fn read_ogg_page_header_sync( + file: &mut File, + offset: u64, + spec: &str, +) -> Result { + let mut header = [0_u8; 27]; + read_exact_at_sync( + file, + offset, + &mut header, + spec, + "Ogg page header is truncated before 27 bytes", + )?; + if &header[..4] != b"OggS" { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("Ogg page at byte offset {offset} did not start with `OggS`"), + }); + } + if header[4] != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "Ogg page at byte offset {offset} used unsupported stream structure version {}", + header[4] + ), + }); + } + let segment_count = usize::from(header[26]); + let mut lacing_values = vec![0_u8; segment_count]; + read_exact_at_sync( + file, + offset + 27, + &mut lacing_values, + spec, + "Ogg page segment table is truncated", + )?; + let payload_offset = offset + .checked_add(27) + .and_then(|value| value.checked_add(u64::try_from(segment_count).unwrap())) + .ok_or(MuxError::LayoutOverflow("Ogg payload offset"))?; + let payload_size = lacing_values.iter().map(|value| u64::from(*value)).sum(); + validate_ogg_page_crc_sync( + file, + offset, + &header, + &lacing_values, + payload_offset, + payload_size, + spec, + )?; + Ok(OggPageHeader { + header_type: header[5], + granule_position: u64::from_le_bytes(header[6..14].try_into().unwrap()), + serial_no: u32::from_le_bytes(header[14..18].try_into().unwrap()), + lacing_values, + payload_offset, + payload_size, + }) +} + +#[cfg(feature = "async")] +pub(super) async fn read_ogg_page_header_async( + file: &mut TokioFile, + offset: u64, + spec: &str, +) -> Result { + let mut header = [0_u8; 27]; + read_exact_at_async( + file, + offset, + &mut header, + spec, + "Ogg page header is truncated before 27 bytes", + ) + .await?; + if &header[..4] != b"OggS" { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("Ogg page at byte offset {offset} did not start with `OggS`"), + }); + } + if header[4] != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "Ogg page at byte offset {offset} used unsupported stream structure version {}", + header[4] + ), + }); + } + let segment_count = usize::from(header[26]); + let mut lacing_values = vec![0_u8; segment_count]; + read_exact_at_async( + file, + offset + 27, + &mut lacing_values, + spec, + "Ogg page segment table is truncated", + ) + .await?; + let payload_offset = offset + .checked_add(27) + .and_then(|value| value.checked_add(u64::try_from(segment_count).unwrap())) + .ok_or(MuxError::LayoutOverflow("Ogg payload offset"))?; + let payload_size = lacing_values.iter().map(|value| u64::from(*value)).sum(); + validate_ogg_page_crc_async( + file, + offset, + &header, + &lacing_values, + payload_offset, + payload_size, + spec, + ) + .await?; + Ok(OggPageHeader { + header_type: header[5], + granule_position: u64::from_le_bytes(header[6..14].try_into().unwrap()), + serial_no: u32::from_le_bytes(header[14..18].try_into().unwrap()), + lacing_values, + payload_offset, + payload_size, + }) +} + +pub(super) fn read_packet_prefix_sync( + file: &mut File, + spans: &[SourceFileSpan], + max_len: usize, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + let requested = spans + .iter() + .fold(0usize, |len: usize, span| { + len.saturating_add(usize::try_from(span.size).unwrap()) + }) + .min(max_len); + let mut bytes = read_spans_sync( + file, + spans, + u32::try_from(requested).unwrap_or(u32::MAX), + spec, + truncated_message, + )?; + bytes.truncate(requested); + Ok(bytes) +} + +#[cfg(feature = "async")] +pub(super) async fn read_packet_prefix_async( + file: &mut TokioFile, + spans: &[SourceFileSpan], + max_len: usize, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + let requested = spans + .iter() + .fold(0usize, |len: usize, span| { + len.saturating_add(usize::try_from(span.size).unwrap()) + }) + .min(max_len); + let mut bytes: Vec = read_spans_async( + file, + spans, + u32::try_from(requested).unwrap_or(u32::MAX), + spec, + truncated_message, + ) + .await?; + bytes.truncate(requested); + Ok(bytes) +} diff --git a/src/mux/demux/opus.rs b/src/mux/demux/opus.rs new file mode 100644 index 0000000..116225a --- /dev/null +++ b/src/mux/demux/opus.rs @@ -0,0 +1,550 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::iso14496_12::Btrt; +use crate::boxes::opus::DOps; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_spans_async; +use super::super::import::{ + SegmentedMuxSourceSegment, SegmentedMuxSourceSegmentData, SegmentedMuxSourceSpec, StagedSample, + build_btrt_from_sample_sizes, build_generic_audio_sample_entry_box, read_spans_sync, +}; +#[cfg(feature = "async")] +use super::ogg_common::read_ogg_page_header_async; +use super::ogg_common::{OggPacketBuilder, read_ogg_page_header_sync}; + +const OPUS_ENTRY: FourCc = FourCc::from_bytes(*b"Opus"); +const OPUS_FRAME_DURATION_TABLE_48K: [u32; 32] = [ + 480, 960, 1920, 2880, 480, 960, 1920, 2880, 480, 960, 1920, 2880, 480, 960, 480, 960, 120, 240, + 480, 960, 120, 240, 480, 960, 120, 240, 480, 960, 120, 240, 480, 960, +]; + +pub(in crate::mux) struct ParsedOggOpusTrack { + pub(in crate::mux) segmented_source: SegmentedMuxSourceSpec, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) edit_media_time: Option, + pub(in crate::mux) sample_roll_distance: Option, + pub(in crate::mux) flat_source_encoder_metadata: Option, + pub(in crate::mux) samples: Vec, +} + +struct CompletedOpusPageState { + packets: Vec, + granule_position: u64, + eos: bool, +} + +pub(in crate::mux) fn scan_ogg_opus_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let mut offset = 0_u64; + let mut packet_builder = OggPacketBuilder::default(); + let mut config = None; + let mut saw_tags_packet = false; + let mut flat_source_encoder_metadata = None; + let mut logical_size = 0_u64; + let mut transformed_segments = Vec::new(); + let mut samples = Vec::new(); + let mut decoded_samples = 0_u64; + while offset < file_size { + let page = read_ogg_page_header_sync(&mut file, offset, spec)?; + if packet_builder.is_empty() && page.header_type & 0x01 != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Opus input started in the middle of a continued packet".to_string(), + }); + } + offset = page + .payload_offset + .checked_add(page.payload_size) + .ok_or(MuxError::LayoutOverflow("Ogg page range"))?; + let mut page_cursor = page.payload_offset; + let mut completed = Vec::new(); + for lacing in &page.lacing_values { + packet_builder.push_span(page_cursor, u32::from(*lacing))?; + page_cursor += u64::from(*lacing); + if *lacing < 255 { + let packet = packet_builder.finish(); + if packet.total_size != 0 { + completed.push(packet); + } + } + } + if completed.is_empty() { + continue; + } + process_opus_completed_page_sync( + &mut file, + spec, + &mut config, + &mut saw_tags_packet, + &mut flat_source_encoder_metadata, + &mut logical_size, + &mut transformed_segments, + &mut samples, + &mut decoded_samples, + CompletedOpusPageState { + packets: completed, + granule_position: page.granule_position, + eos: page.header_type & 0x04 != 0, + }, + )?; + } + if !packet_builder.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Opus input ended in the middle of a packet".to_string(), + }); + } + let config = config.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Opus input did not contain an Opus identification packet".to_string(), + })?; + if samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Opus input did not contain any audio packets after headers".to_string(), + }); + } + let btrt = build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + 48_000, + )?; + Ok(ParsedOggOpusTrack { + segmented_source: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: transformed_segments, + total_size: logical_size, + }, + sample_entry_box: build_opus_sample_entry_box(&config, btrt)?, + edit_media_time: (config.pre_skip != 0).then_some(u64::from(config.pre_skip)), + sample_roll_distance: Some(3_840), + flat_source_encoder_metadata, + samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_ogg_opus_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let mut offset = 0_u64; + let mut packet_builder = OggPacketBuilder::default(); + let mut config = None; + let mut saw_tags_packet = false; + let mut flat_source_encoder_metadata = None; + let mut logical_size = 0_u64; + let mut transformed_segments = Vec::new(); + let mut samples = Vec::new(); + let mut decoded_samples = 0_u64; + while offset < file_size { + let page = read_ogg_page_header_async(&mut file, offset, spec).await?; + if packet_builder.is_empty() && page.header_type & 0x01 != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Opus input started in the middle of a continued packet".to_string(), + }); + } + offset = page + .payload_offset + .checked_add(page.payload_size) + .ok_or(MuxError::LayoutOverflow("Ogg page range"))?; + let mut page_cursor = page.payload_offset; + let mut completed = Vec::new(); + for lacing in &page.lacing_values { + packet_builder.push_span(page_cursor, u32::from(*lacing))?; + page_cursor += u64::from(*lacing); + if *lacing < 255 { + let packet = packet_builder.finish(); + if packet.total_size != 0 { + completed.push(packet); + } + } + } + if completed.is_empty() { + continue; + } + process_opus_completed_page_async( + &mut file, + spec, + &mut config, + &mut saw_tags_packet, + &mut flat_source_encoder_metadata, + &mut logical_size, + &mut transformed_segments, + &mut samples, + &mut decoded_samples, + CompletedOpusPageState { + packets: completed, + granule_position: page.granule_position, + eos: page.header_type & 0x04 != 0, + }, + ) + .await?; + } + if !packet_builder.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Opus input ended in the middle of a packet".to_string(), + }); + } + let config = config.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Opus input did not contain an Opus identification packet".to_string(), + })?; + if samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Opus input did not contain any audio packets after headers".to_string(), + }); + } + let btrt = build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + 48_000, + )?; + Ok(ParsedOggOpusTrack { + segmented_source: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: transformed_segments, + total_size: logical_size, + }, + sample_entry_box: build_opus_sample_entry_box(&config, btrt)?, + edit_media_time: (config.pre_skip != 0).then_some(u64::from(config.pre_skip)), + sample_roll_distance: Some(3_840), + flat_source_encoder_metadata, + samples, + }) +} + +#[allow(clippy::too_many_arguments)] +fn process_opus_completed_page_sync( + file: &mut File, + spec: &str, + config: &mut Option, + saw_tags_packet: &mut bool, + flat_source_encoder_metadata: &mut Option, + logical_size: &mut u64, + transformed_segments: &mut Vec, + samples: &mut Vec, + decoded_samples: &mut u64, + page: CompletedOpusPageState, +) -> Result<(), MuxError> { + let mut audio_packets = Vec::new(); + for packet in page.packets { + let packet_bytes = read_spans_sync( + file, + &packet.spans, + packet.total_size, + spec, + "Ogg Opus packet is truncated", + )?; + if config.is_none() { + *config = Some(parse_opus_head_packet(&packet_bytes, spec)?); + continue; + } + if !*saw_tags_packet && packet_bytes.starts_with(b"OpusTags") { + *saw_tags_packet = true; + if flat_source_encoder_metadata.is_none() { + *flat_source_encoder_metadata = parse_opus_tags_encoder_metadata(&packet_bytes); + } + continue; + } + *saw_tags_packet = true; + audio_packets.push((packet, packet_bytes)); + } + append_opus_audio_packets( + spec, + decoded_samples, + logical_size, + transformed_segments, + samples, + audio_packets, + page.granule_position, + page.eos, + ) +} + +#[cfg(feature = "async")] +#[allow(clippy::too_many_arguments)] +async fn process_opus_completed_page_async( + file: &mut TokioFile, + spec: &str, + config: &mut Option, + saw_tags_packet: &mut bool, + flat_source_encoder_metadata: &mut Option, + logical_size: &mut u64, + transformed_segments: &mut Vec, + samples: &mut Vec, + decoded_samples: &mut u64, + page: CompletedOpusPageState, +) -> Result<(), MuxError> { + let mut audio_packets = Vec::new(); + for packet in page.packets { + let packet_bytes: Vec = read_spans_async( + file, + &packet.spans, + packet.total_size, + spec, + "Ogg Opus packet is truncated", + ) + .await?; + if config.is_none() { + *config = Some(parse_opus_head_packet(&packet_bytes, spec)?); + continue; + } + if !*saw_tags_packet && packet_bytes.starts_with(b"OpusTags") { + *saw_tags_packet = true; + if flat_source_encoder_metadata.is_none() { + *flat_source_encoder_metadata = parse_opus_tags_encoder_metadata(&packet_bytes); + } + continue; + } + *saw_tags_packet = true; + audio_packets.push((packet, packet_bytes)); + } + append_opus_audio_packets( + spec, + decoded_samples, + logical_size, + transformed_segments, + samples, + audio_packets, + page.granule_position, + page.eos, + ) +} + +#[allow(clippy::too_many_arguments)] +fn append_opus_audio_packets( + spec: &str, + decoded_samples: &mut u64, + logical_size: &mut u64, + transformed_segments: &mut Vec, + samples: &mut Vec, + audio_packets: Vec<(super::ogg_common::CompletedOggPacket, Vec)>, + granule_position: u64, + eos: bool, +) -> Result<(), MuxError> { + let mut nominal_durations = Vec::with_capacity(audio_packets.len()); + for (_, packet_bytes) in &audio_packets { + nominal_durations.push(u64::from(opus_packet_duration_from_bytes( + packet_bytes, + spec, + )?)); + } + let last_index = audio_packets.len().saturating_sub(1); + for (index, (packet, packet_bytes)) in audio_packets.into_iter().enumerate() { + let mut duration = nominal_durations[index]; + if eos && index == last_index && granule_position != u64::MAX { + let remaining = granule_position.saturating_sub(*decoded_samples); + if remaining < duration { + duration = remaining; + } + } + let data_offset = *logical_size; + for span in &packet.spans { + transformed_segments.push(SegmentedMuxSourceSegment { + logical_offset: *logical_size, + data: SegmentedMuxSourceSegmentData::FileRange { + source_offset: span.source_offset, + size: span.size, + }, + }); + *logical_size = logical_size + .checked_add(u64::from(span.size)) + .ok_or(MuxError::LayoutOverflow("Ogg Opus logical source size"))?; + } + samples.push(StagedSample { + data_offset, + data_size: packet.total_size, + duration: u32::try_from(duration) + .map_err(|_| MuxError::LayoutOverflow("Ogg Opus packet duration"))?, + composition_time_offset: 0, + is_sync_sample: true, + }); + *decoded_samples = decoded_samples + .checked_add(duration) + .ok_or(MuxError::LayoutOverflow("Ogg Opus decoded sample count"))?; + let _ = packet_bytes; + } + Ok(()) +} + +fn build_opus_sample_entry_box(config: &DOps, btrt: Btrt) -> Result, MuxError> { + let dops_bytes = super::super::mp4::encode_typed_box(config, &[])?; + let btrt_bytes = super::super::mp4::encode_typed_box(&btrt, &[])?; + build_generic_audio_sample_entry_box( + OPUS_ENTRY, + 48_000, + u16::from(config.output_channel_count), + 16, + &[dops_bytes, btrt_bytes], + ) +} + +fn parse_opus_tags_encoder_metadata(packet_bytes: &[u8]) -> Option { + if !packet_bytes.starts_with(b"OpusTags") || packet_bytes.len() < 12 { + return None; + } + + let vendor_len = + usize::try_from(u32::from_le_bytes(packet_bytes[8..12].try_into().ok()?)).ok()?; + let vendor_end = 12usize.checked_add(vendor_len)?; + + let comment_count_bytes = packet_bytes.get(vendor_end..vendor_end.checked_add(4)?)?; + let comment_count = + usize::try_from(u32::from_le_bytes(comment_count_bytes.try_into().ok()?)).ok()?; + let mut cursor = vendor_end.checked_add(4)?; + for _ in 0..comment_count { + let comment_len_bytes = packet_bytes.get(cursor..cursor.checked_add(4)?)?; + let comment_len = + usize::try_from(u32::from_le_bytes(comment_len_bytes.try_into().ok()?)).ok()?; + cursor = cursor.checked_add(4)?; + let comment_end = cursor.checked_add(comment_len)?; + let comment_bytes = packet_bytes.get(cursor..comment_end)?; + let comment = String::from_utf8_lossy(comment_bytes); + if let Some((key, value)) = comment.split_once('=') + && key.eq_ignore_ascii_case("encoder") + && !value.is_empty() + { + return Some(value.to_string()); + } + cursor = comment_end; + } + + None +} + +fn parse_opus_head_packet(packet: &[u8], spec: &str) -> Result { + if packet.len() < 19 || !packet.starts_with(b"OpusHead") { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Opus identification packet was missing the `OpusHead` signature" + .to_string(), + }); + } + let version = packet[8]; + if version != 1 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported Opus identification packet version {version}"), + }); + } + let output_channel_count = packet[9]; + let pre_skip = u16::from_le_bytes([packet[10], packet[11]]); + let input_sample_rate = u32::from_le_bytes([packet[12], packet[13], packet[14], packet[15]]); + let output_gain = i16::from_le_bytes([packet[16], packet[17]]); + let channel_mapping_family = packet[18]; + let (stream_count, coupled_count, channel_mapping) = if channel_mapping_family == 0 { + (0, 0, Vec::new()) + } else { + let required = 21usize + .checked_add(usize::from(output_channel_count)) + .ok_or(MuxError::LayoutOverflow("Opus mapping header"))?; + if packet.len() < required { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Opus identification packet is truncated before channel mapping data" + .to_string(), + }); + } + (packet[19], packet[20], packet[21..required].to_vec()) + }; + Ok(DOps { + version: 0, + output_channel_count, + pre_skip, + input_sample_rate, + output_gain, + channel_mapping_family, + stream_count, + coupled_count, + channel_mapping, + }) +} + +fn opus_packet_duration_from_bytes(packet: &[u8], spec: &str) -> Result { + let Some(&toc) = packet.first() else { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Opus packet was empty".to_string(), + }); + }; + let config = toc >> 3; + let frame_duration = OPUS_FRAME_DURATION_TABLE_48K[usize::from(config)]; + let frame_count_code = toc & 0x03; + let duration = match frame_count_code { + 0 => frame_duration, + 1 | 2 => frame_duration * 2, + 3 => { + let Some(&frame_count_byte) = packet.get(1) else { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Opus packet used code 3 framing without a frame-count byte" + .to_string(), + }); + }; + frame_duration + .checked_mul(u32::from(frame_count_byte & 0x3F)) + .ok_or(MuxError::LayoutOverflow("Opus duration"))? + } + _ => unreachable!(), + }; + Ok(duration) +} + +#[cfg(test)] +mod tests { + use super::parse_opus_tags_encoder_metadata; + + #[test] + fn parse_opus_tags_encoder_metadata_prefers_encoder_comment() { + let comment = b"encoder=Lavc61.2.100 libopus"; + let mut packet = Vec::from(&b"OpusTags"[..]); + packet.extend_from_slice(&12_u32.to_le_bytes()); + packet.extend_from_slice(b"Lavf61.0.100"); + packet.extend_from_slice(&1_u32.to_le_bytes()); + packet.extend_from_slice(&(comment.len() as u32).to_le_bytes()); + packet.extend_from_slice(comment); + + assert_eq!( + parse_opus_tags_encoder_metadata(&packet).as_deref(), + Some("Lavc61.2.100 libopus") + ); + } + + #[test] + fn parse_opus_tags_encoder_metadata_ignores_vendor_without_encoder_comment() { + let mut packet = Vec::from(&b"OpusTags"[..]); + packet.extend_from_slice(&5_u32.to_le_bytes()); + packet.extend_from_slice(b"Lavf1"); + packet.extend_from_slice(&0_u32.to_le_bytes()); + + assert_eq!(parse_opus_tags_encoder_metadata(&packet), None); + } + + #[test] + fn parse_opus_tags_encoder_metadata_rejects_truncated_packet() { + let mut packet = Vec::from(&b"OpusTags"[..]); + packet.extend_from_slice(&10_u32.to_le_bytes()); + packet.extend_from_slice(b"short"); + + assert_eq!(parse_opus_tags_encoder_metadata(&packet), None); + } +} diff --git a/src/mux/demux/pcm.rs b/src/mux/demux/pcm.rs new file mode 100644 index 0000000..3b30bec --- /dev/null +++ b/src/mux/demux/pcm.rs @@ -0,0 +1,1579 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::iso14496_12::Chnl; +use crate::boxes::iso23001_5::PcmC; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{ + SegmentedMuxSourceSegment, SegmentedMuxSourceSegmentData, SegmentedMuxSourceSpec, + build_generic_audio_sample_entry_box, read_exact_at_sync, +}; + +const FORM: &[u8; 4] = b"FORM"; +const AIFF: &[u8; 4] = b"AIFF"; +const AIFC: &[u8; 4] = b"AIFC"; +const COMM: &[u8; 4] = b"COMM"; +const SSND: &[u8; 4] = b"SSND"; +const RIFF: &[u8; 4] = b"RIFF"; +const WAVE: &[u8; 4] = b"WAVE"; +const FMT: &[u8; 4] = b"fmt "; +const DATA: &[u8; 4] = b"data"; +const WAVE_FORMAT_PCM: u16 = 0x0001; +const WAVE_FORMAT_IEEE_FLOAT: u16 = 0x0003; +const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE; +const AIFC_COMPRESSION_NONE: FourCc = FourCc::from_bytes(*b"NONE"); +const AIFC_COMPRESSION_TWOS: FourCc = FourCc::from_bytes(*b"twos"); +const AIFC_COMPRESSION_ALAW: FourCc = FourCc::from_bytes(*b"ALAW"); +const AIFC_COMPRESSION_ULAW: FourCc = FourCc::from_bytes(*b"ULAW"); +const AIFC_COMPRESSION_FL32: FourCc = FourCc::from_bytes(*b"fl32"); +const AIFC_COMPRESSION_FL64: FourCc = FourCc::from_bytes(*b"fl64"); +const SAMPLE_ENTRY_IPCM: FourCc = FourCc::from_bytes(*b"ipcm"); +const SAMPLE_ENTRY_FPCM: FourCc = FourCc::from_bytes(*b"fpcm"); +const AIFC_FLOAT_VENDOR_CODE: [u8; 4] = [0, 0, 0, 0]; +const KSDATAFORMAT_SUBTYPE_PCM: [u8; 16] = [ + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71, +]; +const KSDATAFORMAT_SUBTYPE_IEEE_FLOAT: [u8; 16] = [ + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71, +]; + +pub(in crate::mux) struct ParsedPcmTrack { + pub(in crate::mux) container_kind: PcmContainerKind, + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) data_offset: u64, + pub(in crate::mux) frame_size: u32, + pub(in crate::mux) frame_count: u32, + pub(in crate::mux) transformed_source: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::mux) enum PcmContainerKind { + Wave, + Aiff, + Aifc, +} + +#[derive(Clone, Copy)] +enum CompandedPcmKind { + Alaw, + Ulaw, +} + +#[derive(Clone, Copy)] +struct ParsedPcmFormat { + sample_entry_type: FourCc, + sample_rate: u32, + channel_count: u16, + bits_per_sample: u16, + block_align: u16, + is_little_endian: bool, + companded_kind: Option, +} + +#[derive(Clone, Copy)] +struct ParsedAiffCommonChunk { + format: ParsedPcmFormat, + declared_sample_frames: u32, +} + +pub(in crate::mux) fn scan_pcm_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + parse_pcm_stream_sync(path, &mut file, file_size, spec) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_pcm_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + parse_pcm_stream_async(path, &mut file, file_size, spec).await +} + +fn parse_pcm_stream_sync( + path: &Path, + file: &mut File, + file_size: u64, + spec: &str, +) -> Result { + if file_size < 12 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "PCM input is truncated before the 12-byte container header".to_string(), + }); + } + let mut header = [0_u8; 12]; + read_exact_at_sync( + file, + 0, + &mut header, + spec, + "PCM input is truncated before the 12-byte container header", + )?; + if &header[..4] == RIFF && &header[8..12] == WAVE { + validate_riff_wave_header(&header, file_size, spec)?; + let (format, data_offset, data_size) = parse_wave_chunks_sync(file, file_size, spec)?; + return finalize_pcm_track_sync( + path, + file, + PcmContainerKind::Wave, + format, + data_offset, + data_size, + None, + spec, + ); + } + if &header[..4] == FORM && (&header[8..12] == AIFF || &header[8..12] == AIFC) { + validate_aiff_form_header(&header, file_size, spec)?; + let is_aifc = &header[8..12] == AIFC; + let (common, data_offset, data_size) = + parse_aiff_chunks_sync(file, file_size, is_aifc, spec)?; + return finalize_pcm_track_sync( + path, + file, + if is_aifc { + PcmContainerKind::Aifc + } else { + PcmContainerKind::Aiff + }, + common.format, + data_offset, + data_size, + Some(common.declared_sample_frames), + spec, + ); + } + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "PCM direct ingest currently supports WAVE, AIFF, and AIFC inputs".to_string(), + }) +} + +#[cfg(feature = "async")] +async fn parse_pcm_stream_async( + path: &Path, + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result { + if file_size < 12 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "PCM input is truncated before the 12-byte container header".to_string(), + }); + } + let mut header = [0_u8; 12]; + read_exact_at_async( + file, + 0, + &mut header, + spec, + "PCM input is truncated before the 12-byte container header", + ) + .await?; + if &header[..4] == RIFF && &header[8..12] == WAVE { + validate_riff_wave_header(&header, file_size, spec)?; + let (format, data_offset, data_size) = + parse_wave_chunks_async(file, file_size, spec).await?; + return finalize_pcm_track_async( + path, + file, + PcmContainerKind::Wave, + format, + data_offset, + data_size, + None, + spec, + ) + .await; + } + if &header[..4] == FORM && (&header[8..12] == AIFF || &header[8..12] == AIFC) { + validate_aiff_form_header(&header, file_size, spec)?; + let is_aifc = &header[8..12] == AIFC; + let (common, data_offset, data_size) = + parse_aiff_chunks_async(file, file_size, is_aifc, spec).await?; + return finalize_pcm_track_async( + path, + file, + if is_aifc { + PcmContainerKind::Aifc + } else { + PcmContainerKind::Aiff + }, + common.format, + data_offset, + data_size, + Some(common.declared_sample_frames), + spec, + ) + .await; + } + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "PCM direct ingest currently supports WAVE, AIFF, and AIFC inputs".to_string(), + }) +} + +fn validate_riff_wave_header( + riff_header: &[u8; 12], + file_size: u64, + spec: &str, +) -> Result<(), MuxError> { + if &riff_header[..4] != RIFF { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE input did not start with the `RIFF` signature".to_string(), + }); + } + if &riff_header[8..12] != WAVE { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE input did not carry the `WAVE` RIFF form type".to_string(), + }); + } + let declared_size = u64::from(u32::from_le_bytes(riff_header[4..8].try_into().unwrap())) + 8; + if declared_size > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "WAVE RIFF size field declared {declared_size} bytes but the file only contains {file_size}" + ), + }); + } + Ok(()) +} + +fn parse_wave_chunks_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result<(ParsedPcmFormat, u64, u32), MuxError> { + let mut chunk_offset = 12_u64; + let mut format = None::; + let mut data = None::<(u64, u32)>; + while chunk_offset < file_size { + if file_size - chunk_offset < 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE chunk header is truncated".to_string(), + }); + } + let mut chunk_header = [0_u8; 8]; + read_exact_at_sync( + file, + chunk_offset, + &mut chunk_header, + spec, + "WAVE chunk header is truncated", + )?; + let chunk_type = &chunk_header[..4]; + let chunk_size = u64::from(u32::from_le_bytes(chunk_header[4..8].try_into().unwrap())); + let chunk_payload_offset = chunk_offset + 8; + let chunk_end = chunk_payload_offset + .checked_add(chunk_size) + .ok_or(MuxError::LayoutOverflow("WAVE chunk range"))?; + if chunk_end > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "WAVE chunk `{}` overruns the input length", + String::from_utf8_lossy(chunk_type) + ), + }); + } + if chunk_type == FMT { + if format.is_some() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE input carried more than one `fmt ` chunk".to_string(), + }); + } + format = Some(parse_wave_format_chunk_sync( + file, + chunk_payload_offset, + chunk_size, + spec, + )?); + } else if chunk_type == DATA { + if data.is_some() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE input carried more than one `data` chunk".to_string(), + }); + } + data = Some(( + chunk_payload_offset, + u32::try_from(chunk_size) + .map_err(|_| MuxError::LayoutOverflow("WAVE data size"))?, + )); + } + chunk_offset = chunk_end + (chunk_size & 1); + } + let format = format.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE input did not contain a `fmt ` chunk".to_string(), + })?; + let (data_offset, data_size) = data.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE input did not contain a `data` chunk".to_string(), + })?; + Ok((format, data_offset, data_size)) +} + +#[cfg(feature = "async")] +async fn parse_wave_chunks_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result<(ParsedPcmFormat, u64, u32), MuxError> { + let mut chunk_offset = 12_u64; + let mut format = None::; + let mut data = None::<(u64, u32)>; + while chunk_offset < file_size { + if file_size - chunk_offset < 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE chunk header is truncated".to_string(), + }); + } + let mut chunk_header = [0_u8; 8]; + read_exact_at_async( + file, + chunk_offset, + &mut chunk_header, + spec, + "WAVE chunk header is truncated", + ) + .await?; + let chunk_type = &chunk_header[..4]; + let chunk_size = u64::from(u32::from_le_bytes(chunk_header[4..8].try_into().unwrap())); + let chunk_payload_offset = chunk_offset + 8; + let chunk_end = chunk_payload_offset + .checked_add(chunk_size) + .ok_or(MuxError::LayoutOverflow("WAVE chunk range"))?; + if chunk_end > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "WAVE chunk `{}` overruns the input length", + String::from_utf8_lossy(chunk_type) + ), + }); + } + if chunk_type == FMT { + if format.is_some() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE input carried more than one `fmt ` chunk".to_string(), + }); + } + format = Some( + parse_wave_format_chunk_async(file, chunk_payload_offset, chunk_size, spec).await?, + ); + } else if chunk_type == DATA { + if data.is_some() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE input carried more than one `data` chunk".to_string(), + }); + } + data = Some(( + chunk_payload_offset, + u32::try_from(chunk_size) + .map_err(|_| MuxError::LayoutOverflow("WAVE data size"))?, + )); + } + chunk_offset = chunk_end + (chunk_size & 1); + } + let format = format.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE input did not contain a `fmt ` chunk".to_string(), + })?; + let (data_offset, data_size) = data.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE input did not contain a `data` chunk".to_string(), + })?; + Ok((format, data_offset, data_size)) +} + +fn parse_wave_format_chunk_sync( + file: &mut File, + offset: u64, + chunk_size: u64, + spec: &str, +) -> Result { + let mut bytes = vec![ + 0_u8; + usize::try_from(chunk_size) + .map_err(|_| MuxError::LayoutOverflow("WAVE fmt chunk size"))? + ]; + read_exact_at_sync( + file, + offset, + &mut bytes, + spec, + "WAVE `fmt ` chunk is truncated", + )?; + parse_wave_format_chunk_bytes(&bytes, spec) +} + +#[cfg(feature = "async")] +async fn parse_wave_format_chunk_async( + file: &mut TokioFile, + offset: u64, + chunk_size: u64, + spec: &str, +) -> Result { + let mut bytes = vec![ + 0_u8; + usize::try_from(chunk_size) + .map_err(|_| MuxError::LayoutOverflow("WAVE fmt chunk size"))? + ]; + read_exact_at_async( + file, + offset, + &mut bytes, + spec, + "WAVE `fmt ` chunk is truncated", + ) + .await?; + parse_wave_format_chunk_bytes(&bytes, spec) +} + +fn parse_wave_format_chunk_bytes(bytes: &[u8], spec: &str) -> Result { + if bytes.len() < 16 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE `fmt ` chunk is truncated before 16 bytes".to_string(), + }); + } + let audio_format = u16::from_le_bytes(bytes[0..2].try_into().unwrap()); + let channel_count = u16::from_le_bytes(bytes[2..4].try_into().unwrap()); + let sample_rate = u32::from_le_bytes(bytes[4..8].try_into().unwrap()); + let byte_rate = u32::from_le_bytes(bytes[8..12].try_into().unwrap()); + let block_align = u16::from_le_bytes(bytes[12..14].try_into().unwrap()); + let bits_per_sample = u16::from_le_bytes(bytes[14..16].try_into().unwrap()); + if channel_count == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE `fmt ` chunk used zero channels".to_string(), + }); + } + if sample_rate == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE `fmt ` chunk used a zero sample rate".to_string(), + }); + } + if bits_per_sample == 0 || bits_per_sample % 8 != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "unsupported WAVE bits-per-sample value {bits_per_sample}; only byte-aligned PCM or float samples are supported" + ), + }); + } + + let parsed = match audio_format { + WAVE_FORMAT_PCM => parse_pcm_format( + bits_per_sample, + channel_count, + sample_rate, + block_align, + byte_rate, + spec, + )?, + WAVE_FORMAT_IEEE_FLOAT => parse_float_format( + bits_per_sample, + channel_count, + sample_rate, + block_align, + byte_rate, + spec, + )?, + WAVE_FORMAT_EXTENSIBLE => parse_extensible_format( + bytes, + bits_per_sample, + channel_count, + sample_rate, + block_align, + byte_rate, + spec, + )?, + other => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported WAVE format tag {other:#06x}"), + }); + } + }; + Ok(parsed) +} + +fn parse_pcm_format( + bits_per_sample: u16, + channel_count: u16, + sample_rate: u32, + block_align: u16, + byte_rate: u32, + spec: &str, +) -> Result { + if !matches!(bits_per_sample, 8 | 16 | 24 | 32 | 64) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported integer PCM sample size {bits_per_sample}"), + }); + } + validate_wave_stride( + bits_per_sample, + channel_count, + sample_rate, + block_align, + byte_rate, + spec, + )?; + Ok(ParsedPcmFormat { + sample_entry_type: SAMPLE_ENTRY_IPCM, + sample_rate, + channel_count, + bits_per_sample, + block_align, + is_little_endian: true, + companded_kind: None, + }) +} + +fn parse_float_format( + bits_per_sample: u16, + channel_count: u16, + sample_rate: u32, + block_align: u16, + byte_rate: u32, + spec: &str, +) -> Result { + if !matches!(bits_per_sample, 32 | 64) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported floating-point PCM sample size {bits_per_sample}"), + }); + } + validate_wave_stride( + bits_per_sample, + channel_count, + sample_rate, + block_align, + byte_rate, + spec, + )?; + Ok(ParsedPcmFormat { + sample_entry_type: SAMPLE_ENTRY_FPCM, + sample_rate, + channel_count, + bits_per_sample, + block_align, + is_little_endian: true, + companded_kind: None, + }) +} + +fn parse_extensible_format( + bytes: &[u8], + bits_per_sample: u16, + channel_count: u16, + sample_rate: u32, + block_align: u16, + byte_rate: u32, + spec: &str, +) -> Result { + if bytes.len() < 40 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "WAVE extensible `fmt ` chunk is truncated before the subtype GUID" + .to_string(), + }); + } + let cb_size = u16::from_le_bytes(bytes[16..18].try_into().unwrap()); + if cb_size < 22 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "WAVE extensible `fmt ` chunk declared an unsupported extension size of {cb_size}" + ), + }); + } + let subformat = &bytes[24..40]; + if subformat == KSDATAFORMAT_SUBTYPE_PCM { + parse_pcm_format( + bits_per_sample, + channel_count, + sample_rate, + block_align, + byte_rate, + spec, + ) + } else if subformat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT { + parse_float_format( + bits_per_sample, + channel_count, + sample_rate, + block_align, + byte_rate, + spec, + ) + } else { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "unsupported WAVE extensible subtype GUID".to_string(), + }) + } +} + +fn validate_wave_stride( + bits_per_sample: u16, + channel_count: u16, + sample_rate: u32, + block_align: u16, + byte_rate: u32, + spec: &str, +) -> Result<(), MuxError> { + let expected_block_align = u32::from(channel_count) + .checked_mul(u32::from(bits_per_sample / 8)) + .ok_or(MuxError::LayoutOverflow("WAVE block alignment"))?; + if u32::from(block_align) != expected_block_align { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "WAVE `fmt ` chunk declared block alignment {block_align}, but {expected_block_align} is required for {channel_count} channels at {bits_per_sample} bits" + ), + }); + } + let expected_byte_rate = sample_rate + .checked_mul(expected_block_align) + .ok_or(MuxError::LayoutOverflow("WAVE byte rate"))?; + if byte_rate != expected_byte_rate { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "WAVE `fmt ` chunk declared byte rate {byte_rate}, but {expected_byte_rate} is required for the declared sample rate and block alignment" + ), + }); + } + Ok(()) +} + +fn validate_aiff_form_header( + form_header: &[u8; 12], + file_size: u64, + spec: &str, +) -> Result<(), MuxError> { + if &form_header[..4] != FORM { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF input did not start with the `FORM` signature".to_string(), + }); + } + if &form_header[8..12] != AIFF && &form_header[8..12] != AIFC { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF input did not carry the `AIFF` or `AIFC` form type".to_string(), + }); + } + let declared_size = u64::from(u32::from_be_bytes(form_header[4..8].try_into().unwrap())) + 8; + if declared_size > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "AIFF FORM size field declared {declared_size} bytes but the file only contains {file_size}" + ), + }); + } + Ok(()) +} + +fn parse_aiff_chunks_sync( + file: &mut File, + file_size: u64, + is_aifc: bool, + spec: &str, +) -> Result<(ParsedAiffCommonChunk, u64, u32), MuxError> { + let mut chunk_offset = 12_u64; + let mut common = None::; + let mut data = None::<(u64, u32)>; + while chunk_offset < file_size { + if file_size - chunk_offset < 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF chunk header is truncated".to_string(), + }); + } + let mut chunk_header = [0_u8; 8]; + read_exact_at_sync( + file, + chunk_offset, + &mut chunk_header, + spec, + "AIFF chunk header is truncated", + )?; + let chunk_type = &chunk_header[..4]; + let chunk_size = u64::from(u32::from_be_bytes(chunk_header[4..8].try_into().unwrap())); + let chunk_payload_offset = chunk_offset + 8; + let chunk_end = chunk_payload_offset + .checked_add(chunk_size) + .ok_or(MuxError::LayoutOverflow("AIFF chunk range"))?; + if chunk_end > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "AIFF chunk `{}` overruns the input length", + String::from_utf8_lossy(chunk_type) + ), + }); + } + if chunk_type == COMM { + if common.is_some() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF input carried more than one `COMM` chunk".to_string(), + }); + } + common = Some(parse_aiff_common_chunk_sync( + file, + chunk_payload_offset, + chunk_size, + is_aifc, + spec, + )?); + } else if chunk_type == SSND { + if data.is_some() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF input carried more than one `SSND` chunk".to_string(), + }); + } + data = Some(parse_aiff_sound_data_chunk_sync( + file, + chunk_payload_offset, + chunk_size, + spec, + )?); + } + chunk_offset = chunk_end + (chunk_size & 1); + } + let common = common.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF input did not contain a `COMM` chunk".to_string(), + })?; + let (data_offset, data_size) = data.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF input did not contain an `SSND` chunk".to_string(), + })?; + Ok((common, data_offset, data_size)) +} + +#[cfg(feature = "async")] +async fn parse_aiff_chunks_async( + file: &mut TokioFile, + file_size: u64, + is_aifc: bool, + spec: &str, +) -> Result<(ParsedAiffCommonChunk, u64, u32), MuxError> { + let mut chunk_offset = 12_u64; + let mut common = None::; + let mut data = None::<(u64, u32)>; + while chunk_offset < file_size { + if file_size - chunk_offset < 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF chunk header is truncated".to_string(), + }); + } + let mut chunk_header = [0_u8; 8]; + read_exact_at_async( + file, + chunk_offset, + &mut chunk_header, + spec, + "AIFF chunk header is truncated", + ) + .await?; + let chunk_type = &chunk_header[..4]; + let chunk_size = u64::from(u32::from_be_bytes(chunk_header[4..8].try_into().unwrap())); + let chunk_payload_offset = chunk_offset + 8; + let chunk_end = chunk_payload_offset + .checked_add(chunk_size) + .ok_or(MuxError::LayoutOverflow("AIFF chunk range"))?; + if chunk_end > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "AIFF chunk `{}` overruns the input length", + String::from_utf8_lossy(chunk_type) + ), + }); + } + if chunk_type == COMM { + if common.is_some() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF input carried more than one `COMM` chunk".to_string(), + }); + } + common = Some( + parse_aiff_common_chunk_async( + file, + chunk_payload_offset, + chunk_size, + is_aifc, + spec, + ) + .await?, + ); + } else if chunk_type == SSND { + if data.is_some() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF input carried more than one `SSND` chunk".to_string(), + }); + } + data = Some( + parse_aiff_sound_data_chunk_async(file, chunk_payload_offset, chunk_size, spec) + .await?, + ); + } + chunk_offset = chunk_end + (chunk_size & 1); + } + let common = common.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF input did not contain a `COMM` chunk".to_string(), + })?; + let (data_offset, data_size) = data.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF input did not contain an `SSND` chunk".to_string(), + })?; + Ok((common, data_offset, data_size)) +} + +fn parse_aiff_common_chunk_sync( + file: &mut File, + offset: u64, + chunk_size: u64, + is_aifc: bool, + spec: &str, +) -> Result { + let mut bytes = vec![ + 0_u8; + usize::try_from(chunk_size) + .map_err(|_| MuxError::LayoutOverflow("AIFF COMM chunk size"))? + ]; + read_exact_at_sync( + file, + offset, + &mut bytes, + spec, + "AIFF `COMM` chunk is truncated", + )?; + parse_aiff_common_chunk_bytes(&bytes, is_aifc, spec) +} + +#[cfg(feature = "async")] +async fn parse_aiff_common_chunk_async( + file: &mut TokioFile, + offset: u64, + chunk_size: u64, + is_aifc: bool, + spec: &str, +) -> Result { + let mut bytes = vec![ + 0_u8; + usize::try_from(chunk_size) + .map_err(|_| MuxError::LayoutOverflow("AIFF COMM chunk size"))? + ]; + read_exact_at_async( + file, + offset, + &mut bytes, + spec, + "AIFF `COMM` chunk is truncated", + ) + .await?; + parse_aiff_common_chunk_bytes(&bytes, is_aifc, spec) +} + +fn parse_aiff_common_chunk_bytes( + bytes: &[u8], + is_aifc: bool, + spec: &str, +) -> Result { + let minimum = if is_aifc { 22 } else { 18 }; + if bytes.len() < minimum { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("AIFF `COMM` chunk is truncated before {minimum} bytes"), + }); + } + let channel_count = u16::from_be_bytes(bytes[0..2].try_into().unwrap()); + let declared_sample_frames = u32::from_be_bytes(bytes[2..6].try_into().unwrap()); + let bits_per_sample = u16::from_be_bytes(bytes[6..8].try_into().unwrap()); + let sample_rate = decode_aiff_extended_sample_rate(&bytes[8..18], spec)?; + if channel_count == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF `COMM` chunk used zero channels".to_string(), + }); + } + if bits_per_sample == 0 || bits_per_sample % 8 != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "unsupported AIFF bits-per-sample value {bits_per_sample}; only byte-aligned PCM or float samples are supported" + ), + }); + } + let bytes_per_sample = bits_per_sample / 8; + let block_align = channel_count + .checked_mul(bytes_per_sample) + .ok_or(MuxError::LayoutOverflow("AIFF block alignment"))?; + if block_align == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF `COMM` chunk used a zero block alignment".to_string(), + }); + } + + let format = if is_aifc { + match FourCc::from_bytes(bytes[18..22].try_into().unwrap()) { + AIFC_COMPRESSION_NONE | AIFC_COMPRESSION_TWOS => parse_pcm_format_without_stride( + bits_per_sample, + channel_count, + sample_rate, + block_align, + spec, + )?, + AIFC_COMPRESSION_FL32 | AIFC_COMPRESSION_FL64 => parse_float_format_without_stride( + bits_per_sample, + channel_count, + sample_rate, + block_align, + spec, + )?, + AIFC_COMPRESSION_ALAW => parse_companded_aifc_format( + channel_count, + sample_rate, + bits_per_sample, + CompandedPcmKind::Alaw, + spec, + )?, + AIFC_COMPRESSION_ULAW => parse_companded_aifc_format( + channel_count, + sample_rate, + bits_per_sample, + CompandedPcmKind::Ulaw, + spec, + )?, + compression => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "unsupported AIFC compression type `{compression}`; only `NONE`, `twos`, `fl32`, `fl64`, `ALAW`, and `ULAW` are supported" + ), + }); + } + } + } else { + parse_pcm_format_without_stride( + bits_per_sample, + channel_count, + sample_rate, + block_align, + spec, + )? + }; + + Ok(ParsedAiffCommonChunk { + format, + declared_sample_frames, + }) +} + +fn parse_aiff_sound_data_chunk_sync( + file: &mut File, + offset: u64, + chunk_size: u64, + spec: &str, +) -> Result<(u64, u32), MuxError> { + let mut header = [0_u8; 8]; + read_exact_at_sync( + file, + offset, + &mut header, + spec, + "AIFF `SSND` chunk is truncated", + )?; + parse_aiff_sound_data_chunk_header(&header, offset, chunk_size, spec) +} + +#[cfg(feature = "async")] +async fn parse_aiff_sound_data_chunk_async( + file: &mut TokioFile, + offset: u64, + chunk_size: u64, + spec: &str, +) -> Result<(u64, u32), MuxError> { + let mut header = [0_u8; 8]; + read_exact_at_async( + file, + offset, + &mut header, + spec, + "AIFF `SSND` chunk is truncated", + ) + .await?; + parse_aiff_sound_data_chunk_header(&header, offset, chunk_size, spec) +} + +fn parse_aiff_sound_data_chunk_header( + header: &[u8; 8], + payload_offset: u64, + chunk_size: u64, + spec: &str, +) -> Result<(u64, u32), MuxError> { + if chunk_size < 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF `SSND` chunk is shorter than its required 8-byte header".to_string(), + }); + } + let offset_bytes = u64::from(u32::from_be_bytes(header[0..4].try_into().unwrap())); + let block_size = u32::from_be_bytes(header[4..8].try_into().unwrap()); + if block_size != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF `SSND` chunks with non-zero block size are not supported".to_string(), + }); + } + let sound_payload_size = chunk_size - 8; + if offset_bytes > sound_payload_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF `SSND` chunk offset exceeds the carried sound payload".to_string(), + }); + } + let data_offset = payload_offset + .checked_add(8) + .and_then(|value| value.checked_add(offset_bytes)) + .ok_or(MuxError::LayoutOverflow("AIFF SSND payload offset"))?; + let data_size = u32::try_from(sound_payload_size - offset_bytes) + .map_err(|_| MuxError::LayoutOverflow("AIFF SSND payload size"))?; + Ok((data_offset, data_size)) +} + +fn decode_aiff_extended_sample_rate(bytes: &[u8], spec: &str) -> Result { + let bytes: &[u8; 10] = bytes + .try_into() + .map_err(|_| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF `COMM` sample rate field is truncated".to_string(), + })?; + let exponent_and_sign = u16::from_be_bytes(bytes[0..2].try_into().unwrap()); + let sign = exponent_and_sign >> 15; + if sign != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF `COMM` sample rate used a negative extended-float value".to_string(), + }); + } + let exponent = exponent_and_sign & 0x7FFF; + let mantissa = u64::from_be_bytes(bytes[2..10].try_into().unwrap()); + if exponent == 0 && mantissa == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF `COMM` sample rate used a zero extended-float value".to_string(), + }); + } + if exponent == 0x7FFF { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF `COMM` sample rate used an unsupported non-finite extended-float value" + .to_string(), + }); + } + let sample_rate = (mantissa as f64) * 2_f64.powi(i32::from(exponent) - 16383 - 63); + let rounded = sample_rate.round(); + if !rounded.is_finite() + || rounded <= 0.0 + || rounded > f64::from(u32::MAX) + || (sample_rate - rounded).abs() > 0.000_1 + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "AIFF `COMM` sample rate did not decode to a supported integer rate" + .to_string(), + }); + } + Ok(rounded as u32) +} + +fn parse_pcm_format_without_stride( + bits_per_sample: u16, + channel_count: u16, + sample_rate: u32, + block_align: u16, + spec: &str, +) -> Result { + if !matches!(bits_per_sample, 8 | 16 | 24 | 32 | 64) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported integer PCM sample size {bits_per_sample}"), + }); + } + Ok(ParsedPcmFormat { + sample_entry_type: SAMPLE_ENTRY_IPCM, + sample_rate, + channel_count, + bits_per_sample, + block_align, + is_little_endian: false, + companded_kind: None, + }) +} + +fn parse_float_format_without_stride( + bits_per_sample: u16, + channel_count: u16, + sample_rate: u32, + block_align: u16, + spec: &str, +) -> Result { + if !matches!(bits_per_sample, 32 | 64) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported floating-point PCM sample size {bits_per_sample}"), + }); + } + Ok(ParsedPcmFormat { + sample_entry_type: SAMPLE_ENTRY_FPCM, + sample_rate, + channel_count, + bits_per_sample, + block_align, + is_little_endian: true, + companded_kind: None, + }) +} + +fn parse_companded_aifc_format( + channel_count: u16, + sample_rate: u32, + bits_per_sample: u16, + companded_kind: CompandedPcmKind, + spec: &str, +) -> Result { + if !matches!(bits_per_sample, 8 | 16) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "unsupported AIFC companded sample size {bits_per_sample}; only 8-bit and 16-bit declared sample sizes are supported on the companded PCM path" + ), + }); + } + let block_align = match bits_per_sample { + 8 => channel_count, + 16 => channel_count + .checked_mul(2) + .ok_or(MuxError::LayoutOverflow("AIFC companded block alignment"))?, + _ => unreachable!(), + }; + Ok(ParsedPcmFormat { + sample_entry_type: SAMPLE_ENTRY_IPCM, + sample_rate, + channel_count, + bits_per_sample: 16, + block_align, + is_little_endian: true, + companded_kind: Some(companded_kind), + }) +} + +#[allow(clippy::too_many_arguments)] +fn finalize_pcm_track_sync( + path: &Path, + file: &mut File, + container_kind: PcmContainerKind, + format: ParsedPcmFormat, + data_offset: u64, + data_size: u32, + declared_sample_frames: Option, + spec: &str, +) -> Result { + if data_size == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "PCM input did not contain any audio payload in its media-data chunk" + .to_string(), + }); + } + if !data_size.is_multiple_of(u32::from(format.block_align)) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "PCM media-data chunk size is not a whole number of PCM frames".to_string(), + }); + } + let frame_size = u32::from(format.block_align); + let frame_count = data_size / frame_size; + if frame_count == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "PCM input did not contain a complete PCM frame".to_string(), + }); + } + if let Some(declared_sample_frames) = declared_sample_frames + && !declared_pcm_sample_frames_match(&format, declared_sample_frames, frame_count) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "PCM container declared {declared_sample_frames} sample frames but the media-data chunk encoded {frame_count}" + ), + }); + } + let transformed_source = build_companded_aifc_transformed_source_sync( + file, + path, + data_offset, + data_size, + format, + spec, + )?; + let (data_offset, frame_size) = if transformed_source.is_some() { + let frame_size = u32::from(format.channel_count) + .checked_mul(2) + .ok_or(MuxError::LayoutOverflow("PCM output frame size"))?; + (0, frame_size) + } else { + (data_offset, frame_size) + }; + let sample_entry_box = build_pcm_container_sample_entry_box(container_kind, &format)?; + Ok(ParsedPcmTrack { + container_kind, + sample_rate: format.sample_rate, + sample_entry_box, + data_offset, + frame_size, + frame_count, + transformed_source, + }) +} + +#[cfg(feature = "async")] +#[allow(clippy::too_many_arguments)] +async fn finalize_pcm_track_async( + path: &Path, + file: &mut TokioFile, + container_kind: PcmContainerKind, + format: ParsedPcmFormat, + data_offset: u64, + data_size: u32, + declared_sample_frames: Option, + spec: &str, +) -> Result { + if data_size == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "PCM input did not contain any audio payload in its media-data chunk" + .to_string(), + }); + } + if !data_size.is_multiple_of(u32::from(format.block_align)) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "PCM media-data chunk size is not a whole number of PCM frames".to_string(), + }); + } + let frame_size = u32::from(format.block_align); + let frame_count = data_size / frame_size; + if frame_count == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "PCM input did not contain a complete PCM frame".to_string(), + }); + } + if let Some(declared_sample_frames) = declared_sample_frames + && !declared_pcm_sample_frames_match(&format, declared_sample_frames, frame_count) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "PCM container declared {declared_sample_frames} sample frames but the media-data chunk encoded {frame_count}" + ), + }); + } + let transformed_source = build_companded_aifc_transformed_source_async( + file, + path, + data_offset, + data_size, + format, + spec, + ) + .await?; + let (data_offset, frame_size) = if transformed_source.is_some() { + let frame_size = u32::from(format.channel_count) + .checked_mul(2) + .ok_or(MuxError::LayoutOverflow("PCM output frame size"))?; + (0, frame_size) + } else { + (data_offset, frame_size) + }; + let sample_entry_box = build_pcm_container_sample_entry_box(container_kind, &format)?; + Ok(ParsedPcmTrack { + container_kind, + sample_rate: format.sample_rate, + sample_entry_box, + data_offset, + frame_size, + frame_count, + transformed_source, + }) +} + +fn declared_pcm_sample_frames_match( + format: &ParsedPcmFormat, + declared_sample_frames: u32, + frame_count: u32, +) -> bool { + if declared_sample_frames == frame_count { + return true; + } + let Some(_) = format.companded_kind else { + return false; + }; + if format.channel_count == 0 { + return false; + } + let bytes_per_channel = u32::from(format.block_align) / u32::from(format.channel_count); + bytes_per_channel > 1 + && frame_count + .checked_mul(bytes_per_channel) + .is_some_and(|packed_frame_count| packed_frame_count == declared_sample_frames) +} + +fn build_companded_aifc_transformed_source_sync( + file: &mut File, + path: &Path, + data_offset: u64, + data_size: u32, + format: ParsedPcmFormat, + spec: &str, +) -> Result, MuxError> { + let Some(companded_kind) = format.companded_kind else { + return Ok(None); + }; + if format.block_align != format.channel_count { + return Ok(None); + } + let mut encoded = vec![ + 0_u8; + usize::try_from(data_size) + .map_err(|_| MuxError::LayoutOverflow("companded PCM input size"))? + ]; + read_exact_at_sync( + file, + data_offset, + &mut encoded, + spec, + "PCM input is truncated while reading companded AIFC payload", + )?; + Ok(Some(build_inline_companded_pcm_source( + path, + &encoded, + companded_kind, + )?)) +} + +#[cfg(feature = "async")] +async fn build_companded_aifc_transformed_source_async( + file: &mut TokioFile, + path: &Path, + data_offset: u64, + data_size: u32, + format: ParsedPcmFormat, + spec: &str, +) -> Result, MuxError> { + let Some(companded_kind) = format.companded_kind else { + return Ok(None); + }; + if format.block_align != format.channel_count { + return Ok(None); + } + let mut encoded = vec![ + 0_u8; + usize::try_from(data_size) + .map_err(|_| MuxError::LayoutOverflow("companded PCM input size"))? + ]; + read_exact_at_async( + file, + data_offset, + &mut encoded, + spec, + "PCM input is truncated while reading companded AIFC payload", + ) + .await?; + Ok(Some(build_inline_companded_pcm_source( + path, + &encoded, + companded_kind, + )?)) +} + +fn build_inline_companded_pcm_source( + path: &Path, + encoded: &[u8], + companded_kind: CompandedPcmKind, +) -> Result { + let decoded = decode_companded_pcm_payload(encoded, companded_kind); + build_inline_pcm_source(path, decoded, "companded PCM output size") +} + +fn build_inline_pcm_source( + path: &Path, + payload: Vec, + overflow_context: &'static str, +) -> Result { + let total_size = + u64::try_from(payload.len()).map_err(|_| MuxError::LayoutOverflow(overflow_context))?; + Ok(SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: vec![SegmentedMuxSourceSegment { + logical_offset: 0, + data: SegmentedMuxSourceSegmentData::Bytes(payload), + }], + total_size, + }) +} + +fn decode_companded_pcm_payload(encoded: &[u8], companded_kind: CompandedPcmKind) -> Vec { + let mut decoded = Vec::with_capacity(encoded.len().saturating_mul(2)); + for &value in encoded { + let sample = match companded_kind { + CompandedPcmKind::Alaw => decode_alaw_pcm_sample(value), + CompandedPcmKind::Ulaw => decode_ulaw_pcm_sample(value), + }; + decoded.extend_from_slice(&sample.to_le_bytes()); + } + decoded +} + +fn decode_alaw_pcm_sample(value: u8) -> i16 { + let value = value ^ 0x55; + let mut sample = i16::from(value & 0x0F) << 4; + let segment = i16::from((value & 0x70) >> 4); + sample += 8; + if segment != 0 { + sample += 0x100; + } + if segment > 1 { + sample <<= u32::try_from(segment - 1).unwrap(); + } + if value & 0x80 == 0 { -sample } else { sample } +} + +fn decode_ulaw_pcm_sample(value: u8) -> i16 { + let value = !value; + let mut sample = (i16::from(value & 0x0F) << 3) + 0x84; + sample <<= u32::from((value & 0x70) >> 4); + if value & 0x80 != 0 { + 0x84 - sample + } else { + sample - 0x84 + } +} + +fn build_wave_sample_entry_box(format: &ParsedPcmFormat) -> Result, MuxError> { + build_pcm_sample_entry_box( + format.sample_entry_type, + format.sample_rate, + format.channel_count, + format.bits_per_sample, + format.is_little_endian, + ) +} + +fn build_pcm_container_sample_entry_box( + container_kind: PcmContainerKind, + format: &ParsedPcmFormat, +) -> Result, MuxError> { + let sample_entry_box = build_wave_sample_entry_box(format)?; + if container_kind == PcmContainerKind::Aifc && format.sample_entry_type == SAMPLE_ENTRY_FPCM { + return super::super::mp4::replace_audio_sample_entry_vendor_code( + &sample_entry_box, + AIFC_FLOAT_VENDOR_CODE, + ); + } + Ok(sample_entry_box) +} + +pub(in crate::mux) fn build_pcm_sample_entry_box( + sample_entry_type: FourCc, + sample_rate: u32, + channel_count: u16, + bits_per_sample: u16, + is_little_endian: bool, +) -> Result, MuxError> { + let mut pcmc = PcmC::default(); + pcmc.format_flags = if is_little_endian { 1 } else { 0 }; + pcmc.pcm_sample_size = + u8::try_from(bits_per_sample).map_err(|_| MuxError::LayoutOverflow("PCM sample size"))?; + let pcmc_bytes = super::super::mp4::encode_typed_box(&pcmc, &[])?; + let mut child_boxes = vec![pcmc_bytes]; + if let Some(chnl_bytes) = build_pcm_channel_layout_box(channel_count)? { + child_boxes.push(chnl_bytes); + } + build_generic_audio_sample_entry_box( + sample_entry_type, + sample_rate, + channel_count, + bits_per_sample, + &child_boxes, + ) +} + +fn build_pcm_channel_layout_box(channel_count: u16) -> Result>, MuxError> { + let payload = match channel_count { + 1 => { + let mut payload = vec![0_u8; 14]; + payload[4] = 1; + payload[5] = 1; + payload + } + 2 => { + let mut payload = vec![0_u8; 14]; + payload[4] = 1; + payload[5] = 2; + payload + } + 4 => { + let mut payload = vec![0_u8; 10]; + payload[4] = 1; + payload + } + _ => return Ok(None), + }; + Ok(Some(super::super::mp4::encode_typed_box( + &Chnl { data: payload }, + &[], + )?)) +} diff --git a/src/mux/demux/png.rs b/src/mux/demux/png.rs new file mode 100644 index 0000000..e2f3c06 --- /dev/null +++ b/src/mux/demux/png.rs @@ -0,0 +1,515 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::iso14496_12::{SampleEntry, VisualSampleEntry}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::read_exact_at_sync; + +const PNG_ENTRY: FourCc = FourCc::from_bytes(*b"png "); +const AVI_PNG_ENTRY: FourCc = FourCc::from_bytes(*b"PNG "); +const PNG_SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; +const IHDR: FourCc = FourCc::from_bytes(*b"IHDR"); +const IEND: FourCc = FourCc::from_bytes(*b"IEND"); + +pub(in crate::mux) struct ParsedPngTrack { + pub(in crate::mux) width: u16, + pub(in crate::mux) height: u16, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) data_size: u32, +} + +pub(in crate::mux) fn scan_png_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + parse_png_stream_sync(&mut file, file_size, spec) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_png_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + parse_png_stream_async(&mut file, file_size, spec).await +} + +pub(in crate::mux) fn parse_png_bytes( + spec: &str, + bytes: &[u8], +) -> Result { + let file_size = + u64::try_from(bytes.len()).map_err(|_| MuxError::LayoutOverflow("PNG bytes length"))?; + if bytes.len() < 8 { + return Err(invalid_png( + spec, + "PNG input is truncated before the 8-byte signature", + )); + } + let mut signature = [0_u8; 8]; + signature.copy_from_slice(&bytes[..8]); + validate_png_signature(&signature, spec)?; + + let mut offset = 8_u64; + let mut width = None::; + let mut height = None::; + let mut saw_iend = false; + let mut first_chunk = true; + while offset < file_size { + let offset_usize = + usize::try_from(offset).map_err(|_| MuxError::LayoutOverflow("PNG chunk offset"))?; + if bytes.len() - offset_usize < 12 { + return Err(invalid_png(spec, "PNG chunk header is truncated")); + } + let mut header = [0_u8; 8]; + header.copy_from_slice(&bytes[offset_usize..offset_usize + 8]); + let (chunk_type, data_offset, data_size, next_offset) = + decode_png_chunk_header(file_size, offset, header, spec)?; + if first_chunk && chunk_type != IHDR { + return Err(invalid_png( + spec, + "PNG input did not start its chunk stream with IHDR", + )); + } + first_chunk = false; + match chunk_type { + IHDR => { + if width.is_some() || height.is_some() { + return Err(invalid_png( + spec, + "PNG input carried more than one IHDR chunk", + )); + } + if data_size != 13 { + return Err(invalid_png( + spec, + "PNG IHDR chunk did not carry the required 13-byte payload", + )); + } + let data_offset_usize = usize::try_from(data_offset) + .map_err(|_| MuxError::LayoutOverflow("PNG IHDR data offset"))?; + let parsed_width = u32::from_be_bytes( + bytes[data_offset_usize..data_offset_usize + 4] + .try_into() + .unwrap(), + ); + let parsed_height = u32::from_be_bytes( + bytes[data_offset_usize + 4..data_offset_usize + 8] + .try_into() + .unwrap(), + ); + if parsed_width == 0 || parsed_height == 0 { + return Err(invalid_png( + spec, + "PNG IHDR declared zero width or zero height", + )); + } + width = Some(parsed_width); + height = Some(parsed_height); + } + IEND => { + if data_size != 0 { + return Err(invalid_png(spec, "PNG IEND chunk must be empty")); + } + saw_iend = true; + if next_offset != file_size { + return Err(invalid_png( + spec, + "PNG input carried trailing bytes after the IEND chunk", + )); + } + break; + } + _ => {} + } + offset = next_offset; + } + + finalize_png_track(spec, file_size, width, height, saw_iend) +} + +fn parse_png_stream_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result { + validate_png_prefix_sync(file, file_size, spec)?; + parse_png_chunks_sync(file, file_size, spec) +} + +#[cfg(feature = "async")] +async fn parse_png_stream_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result { + validate_png_prefix_async(file, file_size, spec).await?; + parse_png_chunks_async(file, file_size, spec).await +} + +fn validate_png_prefix_sync(file: &mut File, file_size: u64, spec: &str) -> Result<(), MuxError> { + if file_size < 8 { + return Err(invalid_png( + spec, + "PNG input is truncated before the 8-byte signature", + )); + } + let mut signature = [0_u8; 8]; + read_exact_at_sync( + file, + 0, + &mut signature, + spec, + "PNG input is truncated before the 8-byte signature", + )?; + validate_png_signature(&signature, spec) +} + +#[cfg(feature = "async")] +async fn validate_png_prefix_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result<(), MuxError> { + if file_size < 8 { + return Err(invalid_png( + spec, + "PNG input is truncated before the 8-byte signature", + )); + } + let mut signature = [0_u8; 8]; + read_exact_at_async( + file, + 0, + &mut signature, + spec, + "PNG input is truncated before the 8-byte signature", + ) + .await?; + validate_png_signature(&signature, spec) +} + +fn validate_png_signature(signature: &[u8; 8], spec: &str) -> Result<(), MuxError> { + if *signature != PNG_SIGNATURE { + return Err(invalid_png( + spec, + "input does not carry the PNG file signature", + )); + } + Ok(()) +} + +fn parse_png_chunks_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result { + let mut offset = 8_u64; + let mut width = None::; + let mut height = None::; + let mut saw_iend = false; + let mut first_chunk = true; + while offset < file_size { + let (chunk_type, data_offset, data_size, next_offset) = + read_png_chunk_header_sync(file, file_size, offset, spec)?; + if first_chunk && chunk_type != IHDR { + return Err(invalid_png( + spec, + "PNG input did not start its chunk stream with IHDR", + )); + } + first_chunk = false; + match chunk_type { + IHDR => { + if width.is_some() || height.is_some() { + return Err(invalid_png( + spec, + "PNG input carried more than one IHDR chunk", + )); + } + if data_size != 13 { + return Err(invalid_png( + spec, + "PNG IHDR chunk did not carry the required 13-byte payload", + )); + } + let mut ihdr = [0_u8; 13]; + read_exact_at_sync( + file, + data_offset, + &mut ihdr, + spec, + "PNG IHDR payload is truncated", + )?; + let parsed_width = u32::from_be_bytes(ihdr[0..4].try_into().unwrap()); + let parsed_height = u32::from_be_bytes(ihdr[4..8].try_into().unwrap()); + if parsed_width == 0 || parsed_height == 0 { + return Err(invalid_png( + spec, + "PNG IHDR declared zero width or zero height", + )); + } + width = Some(parsed_width); + height = Some(parsed_height); + } + IEND => { + if data_size != 0 { + return Err(invalid_png(spec, "PNG IEND chunk must be empty")); + } + saw_iend = true; + if next_offset != file_size { + return Err(invalid_png( + spec, + "PNG input carried trailing bytes after the IEND chunk", + )); + } + break; + } + _ => {} + } + offset = next_offset; + } + finalize_png_track(spec, file_size, width, height, saw_iend) +} + +#[cfg(feature = "async")] +async fn parse_png_chunks_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result { + let mut offset = 8_u64; + let mut width = None::; + let mut height = None::; + let mut saw_iend = false; + let mut first_chunk = true; + while offset < file_size { + let (chunk_type, data_offset, data_size, next_offset) = + read_png_chunk_header_async(file, file_size, offset, spec).await?; + if first_chunk && chunk_type != IHDR { + return Err(invalid_png( + spec, + "PNG input did not start its chunk stream with IHDR", + )); + } + first_chunk = false; + match chunk_type { + IHDR => { + if width.is_some() || height.is_some() { + return Err(invalid_png( + spec, + "PNG input carried more than one IHDR chunk", + )); + } + if data_size != 13 { + return Err(invalid_png( + spec, + "PNG IHDR chunk did not carry the required 13-byte payload", + )); + } + let mut ihdr = [0_u8; 13]; + read_exact_at_async( + file, + data_offset, + &mut ihdr, + spec, + "PNG IHDR payload is truncated", + ) + .await?; + let parsed_width = u32::from_be_bytes(ihdr[0..4].try_into().unwrap()); + let parsed_height = u32::from_be_bytes(ihdr[4..8].try_into().unwrap()); + if parsed_width == 0 || parsed_height == 0 { + return Err(invalid_png( + spec, + "PNG IHDR declared zero width or zero height", + )); + } + width = Some(parsed_width); + height = Some(parsed_height); + } + IEND => { + if data_size != 0 { + return Err(invalid_png(spec, "PNG IEND chunk must be empty")); + } + saw_iend = true; + if next_offset != file_size { + return Err(invalid_png( + spec, + "PNG input carried trailing bytes after the IEND chunk", + )); + } + break; + } + _ => {} + } + offset = next_offset; + } + finalize_png_track(spec, file_size, width, height, saw_iend) +} + +fn finalize_png_track( + spec: &str, + file_size: u64, + width: Option, + height: Option, + saw_iend: bool, +) -> Result { + if !saw_iend { + return Err(invalid_png( + spec, + "PNG input did not terminate with an IEND chunk", + )); + } + let width = width.ok_or_else(|| invalid_png(spec, "PNG input did not carry an IHDR chunk"))?; + let height = + height.ok_or_else(|| invalid_png(spec, "PNG input did not carry an IHDR chunk"))?; + let width = u16::try_from(width) + .map_err(|_| invalid_png(spec, "PNG width does not fit in an MP4 visual sample entry"))?; + let height = u16::try_from(height).map_err(|_| { + invalid_png( + spec, + "PNG height does not fit in an MP4 visual sample entry", + ) + })?; + let data_size = u32::try_from(file_size) + .map_err(|_| MuxError::LayoutOverflow("PNG file size exceeds MP4 sample limits"))?; + let sample_entry_box = build_png_sample_entry_box(width, height)?; + Ok(ParsedPngTrack { + width, + height, + sample_entry_box, + data_size, + }) +} + +fn read_png_chunk_header_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, +) -> Result<(FourCc, u64, u32, u64), MuxError> { + if file_size - offset < 12 { + return Err(invalid_png(spec, "PNG chunk header is truncated")); + } + let mut header = [0_u8; 8]; + read_exact_at_sync( + file, + offset, + &mut header, + spec, + "PNG chunk header is truncated", + )?; + decode_png_chunk_header(file_size, offset, header, spec) +} + +#[cfg(feature = "async")] +async fn read_png_chunk_header_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, +) -> Result<(FourCc, u64, u32, u64), MuxError> { + if file_size - offset < 12 { + return Err(invalid_png(spec, "PNG chunk header is truncated")); + } + let mut header = [0_u8; 8]; + read_exact_at_async( + file, + offset, + &mut header, + spec, + "PNG chunk header is truncated", + ) + .await?; + decode_png_chunk_header(file_size, offset, header, spec) +} + +fn decode_png_chunk_header( + file_size: u64, + offset: u64, + header: [u8; 8], + spec: &str, +) -> Result<(FourCc, u64, u32, u64), MuxError> { + let data_size = u32::from_be_bytes(header[0..4].try_into().unwrap()); + let chunk_type = FourCc::from_bytes(header[4..8].try_into().unwrap()); + let data_offset = offset + 8; + let next_offset = data_offset + .checked_add(u64::from(data_size)) + .and_then(|value| value.checked_add(4)) + .ok_or(MuxError::LayoutOverflow("PNG chunk range"))?; + if next_offset > file_size { + return Err(invalid_png( + spec, + &format!("PNG chunk `{chunk_type}` overruns the input length"), + )); + } + Ok((chunk_type, data_offset, data_size, next_offset)) +} + +fn invalid_png(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} + +fn build_png_sample_entry_box(width: u16, height: u16) -> Result, MuxError> { + let mut compressorname = [0_u8; 32]; + compressorname[0] = 3; + compressorname[1..4].copy_from_slice(b"PNG"); + super::super::mp4::encode_typed_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: PNG_ENTRY, + data_reference_index: 1, + }, + width, + height, + horizresolution: 72, + vertresolution: 72, + frame_count: 1, + compressorname, + depth: 0x0018, + pre_defined3: -1, + ..VisualSampleEntry::default() + }, + &[], + ) +} + +pub(in crate::mux) fn build_avi_png_sample_entry_box( + width: u16, + height: u16, +) -> Result, MuxError> { + let mut compressorname = [0_u8; 32]; + compressorname[0] = 3; + compressorname[1..4].copy_from_slice(b"PNG"); + super::super::mp4::encode_typed_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: AVI_PNG_ENTRY, + data_reference_index: 1, + }, + width, + height, + horizresolution: 72, + vertresolution: 72, + frame_count: 1, + compressorname, + depth: 0x0018, + pre_defined3: -1, + ..VisualSampleEntry::default() + }, + &[], + ) +} diff --git a/src/mux/demux/prores.rs b/src/mux/demux/prores.rs new file mode 100644 index 0000000..fe958dd --- /dev/null +++ b/src/mux/demux/prores.rs @@ -0,0 +1,358 @@ +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +use crate::FourCc; + +use super::super::MuxError; +use super::super::import::StagedSample; +use super::raw_visual::build_prores_sample_entry_box; + +const APCO: FourCc = FourCc::from_bytes(*b"apco"); +const APCN: FourCc = FourCc::from_bytes(*b"apcn"); +const APCH: FourCc = FourCc::from_bytes(*b"apch"); +const APCS: FourCc = FourCc::from_bytes(*b"apcs"); +const AP4X: FourCc = FourCc::from_bytes(*b"ap4x"); +const AP4H: FourCc = FourCc::from_bytes(*b"ap4h"); + +pub(in crate::mux) struct ParsedProresTrack { + pub(in crate::mux) width: u16, + pub(in crate::mux) height: u16, + pub(in crate::mux) media_timescale: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ProresTrackConfig { + sample_entry_type: FourCc, + width: u16, + height: u16, + timescale: u32, + duration: u32, + colour_primaries: u16, + transfer_characteristics: u16, + matrix_coefficients: u16, +} + +pub(in crate::mux) fn scan_prores_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + parse_prores_file_sync(path, spec, &mut file, file_size) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_prores_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + parse_prores_file_async(path, spec, &mut file, file_size).await +} + +fn parse_prores_file_sync( + path: &Path, + spec: &str, + file: &mut File, + file_size: u64, +) -> Result { + if file_size < 28 { + return Err(invalid_prores( + spec, + "ProRes input is truncated before the first frame header", + )); + } + + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut config = None::; + let mut header = [0_u8; 28]; + while offset < file_size { + let remaining = file_size - offset; + if remaining < 28 { + return Err(invalid_prores( + spec, + "ProRes input is truncated before one complete frame header", + )); + } + file.seek(SeekFrom::Start(offset))?; + file.read_exact(&mut header)?; + let frame_size = u32::from_be_bytes(header[0..4].try_into().unwrap()); + if frame_size < 28 { + return Err(invalid_prores( + spec, + "ProRes frame declared a size smaller than the required header", + )); + } + let frame_end = offset + .checked_add(u64::from(frame_size)) + .ok_or(MuxError::LayoutOverflow("ProRes frame range"))?; + if frame_end > file_size { + return Err(invalid_prores( + spec, + "ProRes frame overruns the input length", + )); + } + if &header[4..8] != b"icpf" { + return Err(invalid_prores( + spec, + "ProRes frame did not carry the required `icpf` identifier", + )); + } + let parsed = parse_prores_frame_header(path, spec, &header, frame_size)?; + if let Some(previous) = config { + if previous != parsed { + return Err(invalid_prores( + spec, + "ProRes input changed its frame configuration mid-stream", + )); + } + } else { + config = Some(parsed); + } + samples.push(StagedSample { + data_offset: offset, + data_size: frame_size, + duration: parsed.duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = frame_end; + } + + let config = + config.ok_or_else(|| invalid_prores(spec, "ProRes input did not carry any frames"))?; + // The retained reference raw-ProRes lane leaves the trailing sample duration unresolved. + // Keeping the final sample open preserves that one-frame `stts` behavior without + // changing the earlier frame-spacing we still need on longer inputs. + if let Some(last_sample) = samples.last_mut() { + last_sample.duration = 0; + } + let sample_entry_box = build_prores_sample_entry_box( + config.sample_entry_type, + config.width, + config.height, + prores_compressor_name(config.sample_entry_type), + config.colour_primaries, + config.transfer_characteristics, + config.matrix_coefficients, + )?; + Ok(ParsedProresTrack { + width: config.width, + height: config.height, + media_timescale: config.timescale, + sample_entry_box, + samples, + }) +} + +#[cfg(feature = "async")] +async fn parse_prores_file_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + file_size: u64, +) -> Result { + if file_size < 28 { + return Err(invalid_prores( + spec, + "ProRes input is truncated before the first frame header", + )); + } + + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut config = None::; + let mut header = [0_u8; 28]; + while offset < file_size { + let remaining = file_size - offset; + if remaining < 28 { + return Err(invalid_prores( + spec, + "ProRes input is truncated before one complete frame header", + )); + } + file.seek(SeekFrom::Start(offset)).await?; + file.read_exact(&mut header).await?; + let frame_size = u32::from_be_bytes(header[0..4].try_into().unwrap()); + if frame_size < 28 { + return Err(invalid_prores( + spec, + "ProRes frame declared a size smaller than the required header", + )); + } + let frame_end = offset + .checked_add(u64::from(frame_size)) + .ok_or(MuxError::LayoutOverflow("ProRes frame range"))?; + if frame_end > file_size { + return Err(invalid_prores( + spec, + "ProRes frame overruns the input length", + )); + } + if &header[4..8] != b"icpf" { + return Err(invalid_prores( + spec, + "ProRes frame did not carry the required `icpf` identifier", + )); + } + let parsed = parse_prores_frame_header(path, spec, &header, frame_size)?; + if let Some(previous) = config { + if previous != parsed { + return Err(invalid_prores( + spec, + "ProRes input changed its frame configuration mid-stream", + )); + } + } else { + config = Some(parsed); + } + samples.push(StagedSample { + data_offset: offset, + data_size: frame_size, + duration: parsed.duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = frame_end; + } + + let config = + config.ok_or_else(|| invalid_prores(spec, "ProRes input did not carry any frames"))?; + if let Some(last_sample) = samples.last_mut() { + last_sample.duration = 0; + } + let sample_entry_box = build_prores_sample_entry_box( + config.sample_entry_type, + config.width, + config.height, + prores_compressor_name(config.sample_entry_type), + config.colour_primaries, + config.transfer_characteristics, + config.matrix_coefficients, + )?; + Ok(ParsedProresTrack { + width: config.width, + height: config.height, + media_timescale: config.timescale, + sample_entry_box, + samples, + }) +} + +fn parse_prores_frame_header( + path: &Path, + spec: &str, + header: &[u8; 28], + frame_size: u32, +) -> Result { + let frame_header_size = usize::from(u16::from_be_bytes(header[8..10].try_into().unwrap())); + if frame_header_size < 20 { + return Err(invalid_prores( + spec, + "ProRes frame header declared a size smaller than the required 20-byte core layout", + )); + } + if 8 + frame_header_size > usize::try_from(frame_size).unwrap() { + return Err(invalid_prores( + spec, + "ProRes frame header overruns the declared frame size", + )); + } + + let width = u16::from_be_bytes(header[16..18].try_into().unwrap()); + let height = u16::from_be_bytes(header[18..20].try_into().unwrap()); + if width == 0 || height == 0 { + return Err(invalid_prores( + spec, + "ProRes frame header declared zero width or zero height", + )); + } + let chroma_format = header[20] >> 6; + let framerate_code = header[21] & 0x0F; + let (timescale, duration) = prores_frame_rate(framerate_code); + let colour_primaries = normalize_prores_colour_component(header[22]); + let transfer_characteristics = normalize_prores_colour_component(header[23]); + let matrix_coefficients = normalize_prores_colour_component(header[24]); + let sample_entry_type = prores_sample_entry_type(path, chroma_format); + Ok(ProresTrackConfig { + sample_entry_type, + width, + height, + timescale, + duration, + colour_primaries, + transfer_characteristics, + matrix_coefficients, + }) +} + +fn prores_frame_rate(code: u8) -> (u32, u32) { + match code { + 1 => (24_000, 1_001), + 2 | 3 => (2_400, 100), + 4 => (30_000, 1_001), + 5 => (3_000, 100), + 6 => (5_000, 100), + 7 => (60_000, 1_001), + 8 => (6_000, 100), + 9 => (10_000, 100), + 10 => (120_000, 1_001), + 11 => (12_000, 100), + _ => (2_500, 100), + } +} + +fn prores_sample_entry_type(path: &Path, chroma_format: u8) -> FourCc { + let Some(extension) = path.extension().and_then(|value| value.to_str()) else { + return default_prores_sample_entry_type(chroma_format); + }; + match extension.to_ascii_lowercase().as_str() { + "apco" => APCO, + "apcn" => APCN, + "apch" => APCH, + "apcs" => APCS, + "ap4x" => AP4X, + "ap4h" => AP4H, + _ => default_prores_sample_entry_type(chroma_format), + } +} + +fn default_prores_sample_entry_type(chroma_format: u8) -> FourCc { + if chroma_format == 3 { AP4H } else { APCH } +} + +fn prores_compressor_name(sample_entry_type: FourCc) -> &'static [u8] { + match sample_entry_type { + APCO => b"ProRes Video 422 Proxy", + APCN => b"ProRes Video 422", + APCH => b"ProRes Video 422 HQ", + APCS => b"ProRes Video 422 LT", + AP4X => b"ProRes Video 4444 XQ", + AP4H => b"ProRes Video 4444", + _ => b"ProRes Video 422 HQ", + } +} + +fn normalize_prores_colour_component(value: u8) -> u16 { + match value { + 0 => 1, + other => u16::from(other), + } +} + +fn invalid_prores(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} diff --git a/src/mux/demux/ps.rs b/src/mux/demux/ps.rs new file mode 100644 index 0000000..d1fea68 --- /dev/null +++ b/src/mux/demux/ps.rs @@ -0,0 +1,3288 @@ +use std::collections::BTreeMap; +use std::fs::File; +use std::path::Path; + +use crate::FourCc; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use super::super::MuxError; +use super::super::MuxTrackKind; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{ + CandidateSample, CompositeTrackCandidate, SegmentedMuxSourceSegment, SegmentedMuxSourceSpec, + StagedSample, TrackCandidate, direct_ingest_handler_name, direct_ingest_mux_policy, + read_exact_at_sync, +}; +#[cfg(feature = "async")] +use super::ac3::scan_ac3_segmented_async; +use super::ac3::scan_ac3_segmented_sync; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::{append_file_range_segment, read_segmented_bytes_sync}; +use super::detect::{DetectedPathTrackKind, detect_path_track_kind_from_prefix}; +#[cfg(feature = "async")] +use super::h264::stage_annex_b_h264_segmented_async; +use super::h264::stage_annex_b_h264_segmented_sync; +#[cfg(feature = "async")] +use super::h265::stage_annex_b_h265_segmented_async; +use super::h265::stage_annex_b_h265_segmented_sync; +use super::mp3::{build_mp3_sample_entry_box, parse_mp3_frame_header}; +#[cfg(feature = "async")] +use super::mp4v::scan_mp4v_segmented_async; +use super::mp4v::scan_mp4v_segmented_sync; +#[cfg(feature = "async")] +use super::mpeg2v::scan_mpeg2v_segmented_async; +use super::mpeg2v::{ + ProgramStreamMpeg2vSampleEntryConfig, build_program_stream_mpeg2v_sample_entry_box, + scan_mpeg2v_segmented_sync, +}; +use super::pcm::build_pcm_sample_entry_box; +use super::vobsub::{ + VOBSUB_TIMESCALE, build_subpicture_sample_entry_box, effective_vobsub_duration, + parse_vobsub_duration, +}; +#[cfg(feature = "async")] +use super::vvc::stage_annex_b_vvc_segmented_async; +use super::vvc::stage_annex_b_vvc_segmented_sync; + +const PACK_START_CODE: [u8; 4] = [0x00, 0x00, 0x01, 0xBA]; +const SYSTEM_HEADER_START_CODE: u8 = 0xBB; +const PROGRAM_STREAM_MAP_START_CODE: u8 = 0xBC; +const PRIVATE_STREAM_1_START_CODE: u8 = 0xBD; +const PADDING_STREAM_START_CODE: u8 = 0xBE; +const PRIVATE_STREAM_2_START_CODE: u8 = 0xBF; +const PRIVATE_STREAM_1_AC3_MIN: u8 = 0x80; +const PRIVATE_STREAM_1_AC3_MAX: u8 = 0x8F; +const PRIVATE_STREAM_1_LPCM_MIN: u8 = 0xA0; +const PRIVATE_STREAM_1_LPCM_MAX: u8 = 0xAF; +const PRIVATE_STREAM_1_PRIVATE_HEADER_BYTES: u32 = 4; +const PROGRAM_STREAM_MEDIA_TIMESCALE: u32 = 90_000; +const PROGRAM_STREAM_SCAN_CHUNK_BYTES: usize = 4096; +const PROGRAM_STREAM_LPCM_SAMPLE_ENTRY: FourCc = FourCc::from_bytes(*b"ipcm"); + +const fn program_stream_track_id(stream_id: u8) -> u32 { + 0x100 | stream_id as u32 +} + +struct ProgramStreamTrackBuilder { + stream_id: u8, + kind: ProgramStreamTrackKind, + lpcm_format: Option, + segments: Vec, + total_size: u64, + sample_offsets: Vec, + sample_pts: Vec, + sample_dts: Vec, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum ProgramStreamTrackKind { + Mp3, + Ac3, + Lpcm, + Video, + Subpicture, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +struct ProgramStreamLpcmFormat { + sample_rate: u32, + channel_count: u16, + bits_per_sample: u16, + block_align: u16, +} + +struct ParsedProgramStreamPesPacket { + payload_offset: u64, + payload_size: u32, + packet_end: u64, + presentation_time: Option, + decode_time: Option, +} + +struct ParsedPrivateStream1PesPacket { + substream_id: u8, + kind: ProgramStreamTrackKind, + lpcm_format: Option, + payload_offset: u64, + payload_size: u32, + packet_end: u64, + presentation_time: Option, +} + +pub(in crate::mux) fn scan_program_stream_sync( + path: &Path, + spec: &str, +) -> Result, MuxError> { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + validate_program_stream_header_sync(&mut file, file_size, spec)?; + + let mut builders = BTreeMap::::new(); + let mut offset = 0_u64; + while offset < file_size { + let start_code = read_program_stream_start_code_sync(&mut file, file_size, offset, spec)?; + match start_code[3] { + 0xBA => { + offset = parse_pack_header_sync(&mut file, file_size, offset, spec)?; + } + SYSTEM_HEADER_START_CODE + | PROGRAM_STREAM_MAP_START_CODE + | PADDING_STREAM_START_CODE + | PRIVATE_STREAM_2_START_CODE => { + offset = skip_length_delimited_ps_packet_sync( + &mut file, + file_size, + offset, + spec, + start_code[3], + )?; + } + PRIVATE_STREAM_1_START_CODE => { + let parsed = parse_private_stream_1_pes_packet_sync( + &mut file, + file_size, + offset, + spec, + start_code[3], + )?; + let builder = builders.entry(parsed.substream_id).or_insert_with(|| { + ProgramStreamTrackBuilder { + stream_id: parsed.substream_id, + kind: parsed.kind, + lpcm_format: parsed.lpcm_format, + segments: Vec::new(), + total_size: 0, + sample_offsets: Vec::new(), + sample_pts: Vec::new(), + sample_dts: Vec::new(), + } + }); + if builder.kind != parsed.kind { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "program stream private_stream_1 substream 0x{:02X} changed carried media kind mid-stream", + parsed.substream_id + ), + }); + } + if let Some(parsed_format) = parsed.lpcm_format { + if let Some(expected_format) = builder.lpcm_format { + if expected_format != parsed_format { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "program stream LPCM substream 0x{:02X} changed audio format mid-stream", + parsed.substream_id + ), + }); + } + } else { + builder.lpcm_format = Some(parsed_format); + } + } + if matches!( + builder.kind, + ProgramStreamTrackKind::Lpcm | ProgramStreamTrackKind::Subpicture + ) { + builder.sample_offsets.push(builder.total_size); + } + if matches!(builder.kind, ProgramStreamTrackKind::Subpicture) { + builder.sample_pts.push(parsed.presentation_time.ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream subpicture PES packets must carry presentation timestamps" + .to_string(), + } + })?); + } + append_file_range_segment( + &mut builder.segments, + &mut builder.total_size, + parsed.payload_offset, + parsed.payload_size, + ); + offset = parsed.packet_end; + } + 0xC0..=0xDF => { + let parsed = + parse_pes_packet_sync(&mut file, file_size, offset, spec, start_code[3])?; + let builder = + builders + .entry(start_code[3]) + .or_insert_with(|| ProgramStreamTrackBuilder { + stream_id: start_code[3], + kind: ProgramStreamTrackKind::Mp3, + lpcm_format: None, + segments: Vec::new(), + total_size: 0, + sample_offsets: Vec::new(), + sample_pts: Vec::new(), + sample_dts: Vec::new(), + }); + append_file_range_segment( + &mut builder.segments, + &mut builder.total_size, + parsed.payload_offset, + parsed.payload_size, + ); + offset = parsed.packet_end; + } + 0xE0..=0xEF => { + let parsed = + parse_pes_packet_sync(&mut file, file_size, offset, spec, start_code[3])?; + let builder = + builders + .entry(start_code[3]) + .or_insert_with(|| ProgramStreamTrackBuilder { + stream_id: start_code[3], + kind: ProgramStreamTrackKind::Video, + lpcm_format: None, + segments: Vec::new(), + total_size: 0, + sample_offsets: Vec::new(), + sample_pts: Vec::new(), + sample_dts: Vec::new(), + }); + if let Some(presentation_time) = parsed.presentation_time { + builder.sample_offsets.push(builder.total_size); + builder.sample_pts.push(presentation_time); + builder + .sample_dts + .push(parsed.decode_time.unwrap_or(presentation_time)); + } + append_file_range_segment( + &mut builder.segments, + &mut builder.total_size, + parsed.payload_offset, + parsed.payload_size, + ); + offset = parsed.packet_end; + } + 0xB9 => break, + other => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "unsupported MPEG program stream start code 0x{other:02X} on the native direct-ingest path" + ), + }); + } + } + } + + finalize_program_stream_tracks_sync(path, spec, &mut file, builders) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_program_stream_async( + path: &Path, + spec: &str, +) -> Result, MuxError> { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + validate_program_stream_header_async(&mut file, file_size, spec).await?; + + let mut builders = BTreeMap::::new(); + let mut offset = 0_u64; + while offset < file_size { + let start_code = + read_program_stream_start_code_async(&mut file, file_size, offset, spec).await?; + match start_code[3] { + 0xBA => { + offset = parse_pack_header_async(&mut file, file_size, offset, spec).await?; + } + SYSTEM_HEADER_START_CODE + | PROGRAM_STREAM_MAP_START_CODE + | PADDING_STREAM_START_CODE + | PRIVATE_STREAM_2_START_CODE => { + offset = skip_length_delimited_ps_packet_async( + &mut file, + file_size, + offset, + spec, + start_code[3], + ) + .await?; + } + PRIVATE_STREAM_1_START_CODE => { + let parsed = parse_private_stream_1_pes_packet_async( + &mut file, + file_size, + offset, + spec, + start_code[3], + ) + .await?; + let builder = builders.entry(parsed.substream_id).or_insert_with(|| { + ProgramStreamTrackBuilder { + stream_id: parsed.substream_id, + kind: parsed.kind, + lpcm_format: parsed.lpcm_format, + segments: Vec::new(), + total_size: 0, + sample_offsets: Vec::new(), + sample_pts: Vec::new(), + sample_dts: Vec::new(), + } + }); + if builder.kind != parsed.kind { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "program stream private_stream_1 substream 0x{:02X} changed carried media kind mid-stream", + parsed.substream_id + ), + }); + } + if let Some(parsed_format) = parsed.lpcm_format { + if let Some(expected_format) = builder.lpcm_format { + if expected_format != parsed_format { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "program stream LPCM substream 0x{:02X} changed audio format mid-stream", + parsed.substream_id + ), + }); + } + } else { + builder.lpcm_format = Some(parsed_format); + } + } + if matches!( + builder.kind, + ProgramStreamTrackKind::Lpcm | ProgramStreamTrackKind::Subpicture + ) { + builder.sample_offsets.push(builder.total_size); + } + if matches!(builder.kind, ProgramStreamTrackKind::Subpicture) { + builder.sample_pts.push(parsed.presentation_time.ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream subpicture PES packets must carry presentation timestamps" + .to_string(), + } + })?); + } + append_file_range_segment( + &mut builder.segments, + &mut builder.total_size, + parsed.payload_offset, + parsed.payload_size, + ); + offset = parsed.packet_end; + } + 0xC0..=0xDF => { + let parsed = + parse_pes_packet_async(&mut file, file_size, offset, spec, start_code[3]) + .await?; + let builder = + builders + .entry(start_code[3]) + .or_insert_with(|| ProgramStreamTrackBuilder { + stream_id: start_code[3], + kind: ProgramStreamTrackKind::Mp3, + lpcm_format: None, + segments: Vec::new(), + total_size: 0, + sample_offsets: Vec::new(), + sample_pts: Vec::new(), + sample_dts: Vec::new(), + }); + append_file_range_segment( + &mut builder.segments, + &mut builder.total_size, + parsed.payload_offset, + parsed.payload_size, + ); + offset = parsed.packet_end; + } + 0xE0..=0xEF => { + let parsed = + parse_pes_packet_async(&mut file, file_size, offset, spec, start_code[3]) + .await?; + let builder = + builders + .entry(start_code[3]) + .or_insert_with(|| ProgramStreamTrackBuilder { + stream_id: start_code[3], + kind: ProgramStreamTrackKind::Video, + lpcm_format: None, + segments: Vec::new(), + total_size: 0, + sample_offsets: Vec::new(), + sample_pts: Vec::new(), + sample_dts: Vec::new(), + }); + if let Some(presentation_time) = parsed.presentation_time { + builder.sample_offsets.push(builder.total_size); + builder.sample_pts.push(presentation_time); + builder + .sample_dts + .push(parsed.decode_time.unwrap_or(presentation_time)); + } + append_file_range_segment( + &mut builder.segments, + &mut builder.total_size, + parsed.payload_offset, + parsed.payload_size, + ); + offset = parsed.packet_end; + } + 0xB9 => break, + other => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "unsupported MPEG program stream start code 0x{other:02X} on the native direct-ingest path" + ), + }); + } + } + } + + finalize_program_stream_tracks_async(path, spec, &mut file, builders).await +} + +fn finalize_program_stream_tracks_sync( + path: &Path, + spec: &str, + file: &mut File, + builders: BTreeMap, +) -> Result, MuxError> { + if builders.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream input did not contain any supported MPEG audio, AC-3, LPCM, VobSub-style subpicture, or MPEG-2/MPEG-4 Part 2/H.264/H.265/VVC video payloads" + .to_string(), + }); + } + let mut tracks = Vec::new(); + for builder in builders.into_values() { + tracks.push(match builder.kind { + ProgramStreamTrackKind::Mp3 => { + finalize_program_stream_mp3_track_sync(path, spec, file, builder)? + } + ProgramStreamTrackKind::Ac3 => { + finalize_program_stream_ac3_track_sync(path, spec, file, builder)? + } + ProgramStreamTrackKind::Lpcm => { + finalize_program_stream_lpcm_track_sync(path, spec, builder)? + } + ProgramStreamTrackKind::Subpicture => { + finalize_program_stream_subpicture_track_sync(path, spec, file, builder)? + } + ProgramStreamTrackKind::Video => { + finalize_program_stream_video_track_sync(path, spec, file, builder)? + } + }); + } + Ok(tracks) +} + +#[cfg(feature = "async")] +async fn finalize_program_stream_tracks_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + builders: BTreeMap, +) -> Result, MuxError> { + if builders.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream input did not contain any supported MPEG audio, AC-3, LPCM, VobSub-style subpicture, or MPEG-2/MPEG-4 Part 2/H.264/H.265/VVC video payloads" + .to_string(), + }); + } + let mut tracks = Vec::new(); + for builder in builders.into_values() { + tracks.push(match builder.kind { + ProgramStreamTrackKind::Mp3 => { + finalize_program_stream_mp3_track_async(path, spec, file, builder).await? + } + ProgramStreamTrackKind::Ac3 => { + finalize_program_stream_ac3_track_async(path, spec, file, builder).await? + } + ProgramStreamTrackKind::Lpcm => { + finalize_program_stream_lpcm_track_async(path, spec, builder).await? + } + ProgramStreamTrackKind::Subpicture => { + finalize_program_stream_subpicture_track_async(path, spec, file, builder).await? + } + ProgramStreamTrackKind::Video => { + finalize_program_stream_video_track_async(path, spec, file, builder).await? + } + }); + } + Ok(tracks) +} + +fn finalize_program_stream_ac3_track_sync( + path: &Path, + spec: &str, + file: &mut File, + builder: ProgramStreamTrackBuilder, +) -> Result { + let parsed = scan_ac3_segmented_sync(file, &builder.segments, builder.total_size, spec)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(PRIVATE_STREAM_1_START_CODE), + kind: MuxTrackKind::Audio, + timescale: PROGRAM_STREAM_MEDIA_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("ac3"), + mux_policy: direct_ingest_mux_policy("ac3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples: normalize_program_stream_ac3_samples( + spec, + parsed.sample_rate, + parsed.samples, + )?, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn finalize_program_stream_mp3_track_sync( + path: &Path, + spec: &str, + file: &mut File, + builder: ProgramStreamTrackBuilder, +) -> Result { + let mut offset = 0_u64; + let mut expected = None::<(u32, u16, u32)>; + let mut samples = Vec::new(); + while offset < builder.total_size { + if builder.total_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated MPEG audio frame header inside program stream payload" + .to_string(), + }); + } + let mut header = [0_u8; 4]; + read_segmented_bytes_sync( + file, + &builder.segments, + builder.total_size, + offset, + &mut header, + spec, + "truncated MPEG audio frame header inside program stream payload", + )?; + let parsed = parse_mp3_frame_header(&header, offset, spec)?; + if offset + .checked_add(u64::from(parsed.frame_length)) + .is_none_or(|end| end > builder.total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "truncated MPEG audio frame at logical program-stream offset {offset}" + ), + }); + } + let descriptor = ( + parsed.sample_rate, + parsed.channel_count, + parsed.sample_duration, + ); + if let Some(expected) = expected { + if expected != descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream MPEG audio frames changed sample rate or channel layout mid-stream" + .to_string(), + }); + } + } else { + expected = Some(descriptor); + } + samples.push(CandidateSample { + source_index: usize::MAX, + data_offset: offset, + data_size: parsed.frame_length, + duration: parsed.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(u64::from(parsed.frame_length)) + .ok_or(MuxError::LayoutOverflow("program stream MPEG audio offset"))?; + } + + let (sample_rate, channel_count, _) = + expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream input did not contain any MPEG audio frames".to_string(), + })?; + let sample_entry_box = build_mp3_sample_entry_box( + sample_rate, + channel_count, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?; + let samples = normalize_program_stream_mp3_samples(spec, sample_rate, samples)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(builder.stream_id), + kind: MuxTrackKind::Audio, + timescale: PROGRAM_STREAM_MEDIA_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("mp3"), + mux_policy: direct_ingest_mux_policy("mp3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn finalize_program_stream_video_track_sync( + path: &Path, + spec: &str, + file: &mut File, + builder: ProgramStreamTrackBuilder, +) -> Result { + let prefix = read_program_stream_video_prefix_sync(file, &builder, spec)?; + match detect_path_track_kind_from_prefix(&prefix) { + DetectedPathTrackKind::Raw(super::super::MuxRawCodec::Mpeg2v) => { + let mut parsed = + scan_mpeg2v_segmented_sync(file, &builder.segments, builder.total_size, spec)?; + if parsed.eof_terminated_trailing_sample { + parsed.samples.pop(); + } + let (timescale, source_edit_media_time, samples) = + normalize_program_stream_mpeg2v_samples( + spec, + parsed.timescale, + parsed.samples, + &builder.sample_offsets, + &builder.sample_pts, + &builder.sample_dts, + )?; + let sample_entry_box = build_program_stream_mpeg2v_sample_entry_box( + ProgramStreamMpeg2vSampleEntryConfig { + width: parsed.width, + height: parsed.height, + decoder_specific_info: &parsed.decoder_specific_info, + object_type_indication: parsed.object_type_indication, + timescale, + leading_media_time: source_edit_media_time.unwrap_or(0), + pixel_aspect_ratio: parsed.pixel_aspect_ratio, + }, + samples.iter().map(|sample| (sample.data_size, sample.duration)), + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(builder.stream_id), + kind: MuxTrackKind::Video, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mpeg2v"), + mux_policy: direct_ingest_mux_policy("mpeg2v", MuxTrackKind::Video) + .without_terminal_flat_video_chunk_split(), + width: parsed.width, + height: parsed.height, + sample_entry_box, + source_edit_media_time, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) + } + DetectedPathTrackKind::Raw(super::super::MuxRawCodec::Mp4v) => { + let parsed = scan_mp4v_segmented_sync(file, &builder.segments, builder.total_size, spec)?; + let (timescale, source_edit_media_time, samples) = + normalize_program_stream_mp4v_samples( + spec, + parsed.timescale, + parsed.samples, + &builder.sample_offsets, + &builder.sample_pts, + &builder.sample_dts, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(builder.stream_id), + kind: MuxTrackKind::Video, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mp4v"), + mux_policy: direct_ingest_mux_policy("mp4v", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: super::super::mp4::strip_visual_sample_entry_immediate_children( + &parsed.sample_entry_box, + &[FourCc::from_bytes(*b"pasp")], + )?, + source_edit_media_time, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) + } + DetectedPathTrackKind::Raw(super::super::MuxRawCodec::H264) => { + let parsed = + stage_annex_b_h264_segmented_sync(path, file, &builder.segments, builder.total_size, spec)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(builder.stream_id), + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h264"), + mux_policy: direct_ingest_mux_policy("h264", MuxTrackKind::Video), + width: parsed.track_width, + height: parsed.track_height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: parsed.source_edit_media_time, + samples: parsed + .samples + .into_iter() + .map(|sample| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect(), + }, + source_spec: parsed.segmented_source, + }) + } + DetectedPathTrackKind::Raw(super::super::MuxRawCodec::H265) => { + let parsed = + stage_annex_b_h265_segmented_sync(path, file, &builder.segments, builder.total_size, spec)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(builder.stream_id), + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h265"), + mux_policy: direct_ingest_mux_policy("h265", MuxTrackKind::Video), + width: parsed.track_width, + height: parsed.track_height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: parsed.source_edit_media_time, + samples: parsed + .samples + .into_iter() + .map(|sample| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect(), + }, + source_spec: parsed.segmented_source, + }) + } + DetectedPathTrackKind::Raw(super::super::MuxRawCodec::Vvc) => { + let parsed = + stage_annex_b_vvc_segmented_sync(path, file, &builder.segments, builder.total_size, spec)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(builder.stream_id), + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("vvc"), + mux_policy: direct_ingest_mux_policy("vvc", MuxTrackKind::Video), + width: parsed.track_width, + height: parsed.track_height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: parsed.source_edit_media_time, + samples: parsed + .samples + .into_iter() + .map(|sample| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect(), + }, + source_spec: parsed.segmented_source, + }) + } + _ => Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream video payload is not a supported MPEG-2, MPEG-4 Part 2, H.264, H.265, or VVC elementary stream" + .to_string(), + }), + } +} + +fn finalize_program_stream_subpicture_track_sync( + path: &Path, + spec: &str, + file: &mut File, + builder: ProgramStreamTrackBuilder, +) -> Result { + let samples = build_program_stream_subpicture_samples_sync(file, spec, &builder)?; + let sample_entry_box = build_subpicture_sample_entry_box(&[], &samples)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(PRIVATE_STREAM_1_START_CODE), + kind: MuxTrackKind::Subtitle, + timescale: VOBSUB_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("vobsub"), + mux_policy: direct_ingest_mux_policy("vobsub", MuxTrackKind::Subtitle), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn finalize_program_stream_lpcm_track_sync( + path: &Path, + spec: &str, + builder: ProgramStreamTrackBuilder, +) -> Result { + let format = builder + .lpcm_format + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream LPCM track did not retain a parsed audio format".to_string(), + })?; + let sample_entry_box = build_pcm_sample_entry_box( + PROGRAM_STREAM_LPCM_SAMPLE_ENTRY, + format.sample_rate, + format.channel_count, + format.bits_per_sample, + false, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(PRIVATE_STREAM_1_START_CODE), + kind: MuxTrackKind::Audio, + timescale: format.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("pcm"), + mux_policy: direct_ingest_mux_policy("pcm", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples: build_program_stream_lpcm_samples(spec, &builder, format)?, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn finalize_program_stream_mp3_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + builder: ProgramStreamTrackBuilder, +) -> Result { + let mut offset = 0_u64; + let mut expected = None::<(u32, u16, u32)>; + let mut samples = Vec::new(); + while offset < builder.total_size { + if builder.total_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated MPEG audio frame header inside program stream payload" + .to_string(), + }); + } + let mut header = [0_u8; 4]; + read_segmented_bytes_async( + file, + &builder.segments, + builder.total_size, + offset, + &mut header, + spec, + "truncated MPEG audio frame header inside program stream payload", + ) + .await?; + let parsed = parse_mp3_frame_header(&header, offset, spec)?; + if offset + .checked_add(u64::from(parsed.frame_length)) + .is_none_or(|end| end > builder.total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "truncated MPEG audio frame at logical program-stream offset {offset}" + ), + }); + } + let descriptor = ( + parsed.sample_rate, + parsed.channel_count, + parsed.sample_duration, + ); + if let Some(expected) = expected { + if expected != descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream MPEG audio frames changed sample rate or channel layout mid-stream" + .to_string(), + }); + } + } else { + expected = Some(descriptor); + } + samples.push(CandidateSample { + source_index: usize::MAX, + data_offset: offset, + data_size: parsed.frame_length, + duration: parsed.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(u64::from(parsed.frame_length)) + .ok_or(MuxError::LayoutOverflow("program stream MPEG audio offset"))?; + } + + let (sample_rate, channel_count, _) = + expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream input did not contain any MPEG audio frames".to_string(), + })?; + let sample_entry_box = build_mp3_sample_entry_box( + sample_rate, + channel_count, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?; + let samples = normalize_program_stream_mp3_samples(spec, sample_rate, samples)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(builder.stream_id), + kind: MuxTrackKind::Audio, + timescale: PROGRAM_STREAM_MEDIA_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("mp3"), + mux_policy: direct_ingest_mux_policy("mp3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn finalize_program_stream_ac3_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + builder: ProgramStreamTrackBuilder, +) -> Result { + let parsed = + scan_ac3_segmented_async(file, &builder.segments, builder.total_size, spec).await?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(PRIVATE_STREAM_1_START_CODE), + kind: MuxTrackKind::Audio, + timescale: PROGRAM_STREAM_MEDIA_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("ac3"), + mux_policy: direct_ingest_mux_policy("ac3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples: normalize_program_stream_ac3_samples( + spec, + parsed.sample_rate, + parsed.samples, + )?, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn finalize_program_stream_subpicture_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + builder: ProgramStreamTrackBuilder, +) -> Result { + let samples = build_program_stream_subpicture_samples_async(file, spec, &builder).await?; + let sample_entry_box = build_subpicture_sample_entry_box(&[], &samples)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(PRIVATE_STREAM_1_START_CODE), + kind: MuxTrackKind::Subtitle, + timescale: VOBSUB_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("vobsub"), + mux_policy: direct_ingest_mux_policy("vobsub", MuxTrackKind::Subtitle), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn finalize_program_stream_lpcm_track_async( + path: &Path, + spec: &str, + builder: ProgramStreamTrackBuilder, +) -> Result { + let format = builder + .lpcm_format + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream LPCM track did not retain a parsed audio format".to_string(), + })?; + let sample_entry_box = build_pcm_sample_entry_box( + PROGRAM_STREAM_LPCM_SAMPLE_ENTRY, + format.sample_rate, + format.channel_count, + format.bits_per_sample, + false, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(PRIVATE_STREAM_1_START_CODE), + kind: MuxTrackKind::Audio, + timescale: format.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("pcm"), + mux_policy: direct_ingest_mux_policy("pcm", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples: build_program_stream_lpcm_samples(spec, &builder, format)?, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn build_program_stream_subpicture_samples_sync( + file: &mut File, + spec: &str, + builder: &ProgramStreamTrackBuilder, +) -> Result, MuxError> { + if builder.sample_offsets.len() != builder.sample_pts.len() || builder.sample_offsets.is_empty() + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream subpicture input did not contain any complete VobSub-style PES payloads" + .to_string(), + }); + } + let mut samples = Vec::with_capacity(builder.sample_offsets.len()); + for (index, (&sample_offset, &sample_pts)) in builder + .sample_offsets + .iter() + .zip(builder.sample_pts.iter()) + .enumerate() + { + let next_offset = builder + .sample_offsets + .get(index + 1) + .copied() + .unwrap_or(builder.total_size); + if next_offset <= sample_offset { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream subpicture samples must advance monotonically".to_string(), + }); + } + let data_size = u32::try_from(next_offset - sample_offset) + .map_err(|_| MuxError::LayoutOverflow("program stream subpicture sample size"))?; + let mut packet_bytes = vec![ + 0_u8; + usize::try_from(data_size).map_err(|_| { + MuxError::LayoutOverflow("program stream subpicture sample size") + })? + ]; + read_segmented_bytes_sync( + file, + &builder.segments, + builder.total_size, + sample_offset, + &mut packet_bytes, + spec, + "program stream subpicture payload is truncated", + )?; + let duration = subpicture_sample_duration( + spec, + &packet_bytes, + sample_pts, + builder.sample_pts.get(index + 1).copied(), + )?; + samples.push(CandidateSample { + source_index: usize::MAX, + data_offset: sample_offset, + data_size, + duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + Ok(samples) +} + +#[cfg(feature = "async")] +async fn build_program_stream_subpicture_samples_async( + file: &mut TokioFile, + spec: &str, + builder: &ProgramStreamTrackBuilder, +) -> Result, MuxError> { + if builder.sample_offsets.len() != builder.sample_pts.len() || builder.sample_offsets.is_empty() + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream subpicture input did not contain any complete VobSub-style PES payloads" + .to_string(), + }); + } + let mut samples = Vec::with_capacity(builder.sample_offsets.len()); + for (index, (&sample_offset, &sample_pts)) in builder + .sample_offsets + .iter() + .zip(builder.sample_pts.iter()) + .enumerate() + { + let next_offset = builder + .sample_offsets + .get(index + 1) + .copied() + .unwrap_or(builder.total_size); + if next_offset <= sample_offset { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream subpicture samples must advance monotonically".to_string(), + }); + } + let data_size = u32::try_from(next_offset - sample_offset) + .map_err(|_| MuxError::LayoutOverflow("program stream subpicture sample size"))?; + let mut packet_bytes = vec![ + 0_u8; + usize::try_from(data_size).map_err(|_| { + MuxError::LayoutOverflow("program stream subpicture sample size") + })? + ]; + read_segmented_bytes_async( + file, + &builder.segments, + builder.total_size, + sample_offset, + &mut packet_bytes, + spec, + "program stream subpicture payload is truncated", + ) + .await?; + let duration = subpicture_sample_duration( + spec, + &packet_bytes, + sample_pts, + builder.sample_pts.get(index + 1).copied(), + )?; + samples.push(CandidateSample { + source_index: usize::MAX, + data_offset: sample_offset, + data_size, + duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + Ok(samples) +} + +fn subpicture_sample_duration( + spec: &str, + packet_bytes: &[u8], + start_pts: u64, + next_start: Option, +) -> Result { + if packet_bytes.len() < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated program stream subpicture payload".to_string(), + }); + } + let packet_size = u32::from(u16::from_be_bytes([packet_bytes[0], packet_bytes[1]])); + let control_offset = u32::from(u16::from_be_bytes([packet_bytes[2], packet_bytes[3]])); + let parsed_duration = parse_vobsub_duration(packet_bytes, packet_size, control_offset, spec)?; + effective_vobsub_duration(parsed_duration, start_pts, next_start) +} + +fn build_program_stream_lpcm_samples( + spec: &str, + builder: &ProgramStreamTrackBuilder, + format: ProgramStreamLpcmFormat, +) -> Result, MuxError> { + if builder.sample_offsets.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream input did not contain any complete LPCM PES payloads" + .to_string(), + }); + } + builder + .sample_offsets + .iter() + .enumerate() + .map(|(index, &sample_offset)| { + let next_offset = builder + .sample_offsets + .get(index + 1) + .copied() + .unwrap_or(builder.total_size); + if next_offset <= sample_offset { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream LPCM samples must advance monotonically" + .to_string(), + }); + } + let data_size = u32::try_from(next_offset - sample_offset) + .map_err(|_| MuxError::LayoutOverflow("program stream LPCM sample size"))?; + if data_size % u32::from(format.block_align) != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "program stream LPCM sample size {data_size} is not aligned to the declared {}-byte frame size", + format.block_align + ), + }); + } + let duration = data_size / u32::from(format.block_align); + if duration == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream LPCM sample duration underflowed to zero" + .to_string(), + }); + } + Ok(CandidateSample { + source_index: usize::MAX, + data_offset: sample_offset, + data_size, + duration, + composition_time_offset: 0, + is_sync_sample: true, + }) + }) + .collect() +} + +fn normalize_program_stream_ac3_samples( + spec: &str, + sample_rate: u32, + samples: Vec, +) -> Result, MuxError> { + if sample_rate == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream AC-3 input reported a zero sample rate".to_string(), + }); + } + + let mut duration_remainder = 0_u64; + samples + .into_iter() + .map(|sample| { + let scaled_duration = u64::from(sample.duration) + .checked_mul(u64::from(PROGRAM_STREAM_MEDIA_TIMESCALE)) + .ok_or(MuxError::LayoutOverflow("program stream AC-3 duration"))? + .checked_add(duration_remainder) + .ok_or(MuxError::LayoutOverflow("program stream AC-3 duration"))?; + let duration = scaled_duration / u64::from(sample_rate); + duration_remainder = scaled_duration % u64::from(sample_rate); + let duration = u32::try_from(duration) + .map_err(|_| MuxError::LayoutOverflow("program stream AC-3 duration"))?; + if duration == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream AC-3 frame duration underflowed after media-timescale normalization" + .to_string(), + }); + } + Ok(CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + }) + .collect() +} + +fn normalize_program_stream_mp3_samples( + spec: &str, + sample_rate: u32, + samples: Vec, +) -> Result, MuxError> { + if sample_rate == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream MPEG audio reported a zero sample rate".to_string(), + }); + } + + let mut duration_remainder = 0_u64; + samples + .into_iter() + .map(|sample| { + let scaled_duration = u64::from(sample.duration) + .checked_mul(u64::from(PROGRAM_STREAM_MEDIA_TIMESCALE)) + .ok_or(MuxError::LayoutOverflow( + "program stream MPEG audio duration", + ))? + .checked_add(duration_remainder) + .ok_or(MuxError::LayoutOverflow( + "program stream MPEG audio duration", + ))?; + let duration = scaled_duration / u64::from(sample_rate); + duration_remainder = scaled_duration % u64::from(sample_rate); + Ok(CandidateSample { + duration: u32::try_from(duration) + .map_err(|_| MuxError::LayoutOverflow("program stream MPEG audio duration"))?, + ..sample + }) + }) + .collect() +} + +fn normalize_program_stream_mpeg2v_samples( + spec: &str, + elementary_timescale: u32, + mut samples: Vec, + sample_offsets: &[u64], + sample_pts: &[u64], + sample_dts: &[u64], +) -> Result<(u32, Option, Vec), MuxError> { + if sample_pts.is_empty() { + return Ok(( + elementary_timescale, + None, + samples + .into_iter() + .map(|sample| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect(), + )); + } + + if sample_pts.len() != sample_dts.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream MPEG-2 video timing anchors disagreed between presentation and decode timestamps" + .to_string(), + }); + } + if sample_offsets.len() != sample_pts.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream MPEG-2 video timing anchors disagreed between payload offsets and timestamps" + .to_string(), + }); + } + + if sample_pts.len() + 1 == samples.len() { + samples.pop(); + } + + if sample_pts.len() > samples.len() + 1 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "program stream MPEG-2 video PES timing anchors ({}) did not match parsed picture count ({})", + sample_pts.len(), + samples.len(), + ), + }); + } + + let anchor_to_sample = + map_program_stream_mpeg2v_anchor_offsets_to_picture_samples(sample_offsets, &samples); + let sample_to_anchor = build_program_stream_mpeg2v_sample_anchor_map( + spec, + sample_offsets, + &anchor_to_sample, + samples.len(), + )?; + + let mut normalized = Vec::with_capacity(samples.len()); + let mut source_edit_media_time = None; + let mut last_composition_time_offset = 0_i32; + for (index, sample) in samples.into_iter().enumerate() { + let scaled_sample_duration = scale_mpeg2v_duration_to_program_stream_clock( + spec, + elementary_timescale, + sample.duration, + )?; + let (duration, composition_time_offset) = if let Some(anchor_index) = + sample_to_anchor[index] + { + let current_pts = sample_pts[anchor_index]; + let current_dts = sample_dts[anchor_index]; + if current_pts < current_dts { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream MPEG-2 video presentation timestamps must not precede decode timestamps" + .to_string(), + }); + } + let duration = if let Some((next_sample_index, next_anchor_index)) = sample_to_anchor + [index + 1..] + .iter() + .enumerate() + .find_map(|(delta, anchor)| { + anchor.map(|anchor_index| (index + 1 + delta, anchor_index)) + }) { + let next_dts = sample_dts[next_anchor_index]; + if next_dts <= current_dts { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream MPEG-2 video decode timestamps must increase monotonically" + .to_string(), + }); + } + if next_sample_index == index + 1 { + u32::try_from(next_dts - current_dts).map_err(|_| { + MuxError::LayoutOverflow("program stream MPEG-2 video duration") + })? + } else { + scaled_sample_duration + } + } else { + scaled_sample_duration + }; + let composition_time_offset = + i32::try_from(current_pts - current_dts).map_err(|_| { + MuxError::LayoutOverflow("program stream MPEG-2 video composition offset") + })?; + last_composition_time_offset = composition_time_offset; + if index == 0 && composition_time_offset > 0 { + source_edit_media_time = + Some(u64::try_from(composition_time_offset).map_err(|_| { + MuxError::LayoutOverflow("program stream MPEG-2 video edit") + })?); + } + (duration, composition_time_offset) + } else { + let duration = if sample_to_anchor[index + 1..].iter().any(Option::is_some) { + scaled_sample_duration + } else { + sample.duration + }; + (duration, last_composition_time_offset) + }; + if duration == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream MPEG-2 video frame duration underflowed after media-timescale normalization" + .to_string(), + }); + } + normalized.push(CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration, + composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }); + } + + Ok(( + PROGRAM_STREAM_MEDIA_TIMESCALE, + source_edit_media_time, + normalized, + )) +} + +fn normalize_program_stream_mp4v_samples( + spec: &str, + elementary_timescale: u32, + mut samples: Vec, + sample_offsets: &[u64], + sample_pts: &[u64], + sample_dts: &[u64], +) -> Result<(u32, Option, Vec), MuxError> { + if sample_pts.is_empty() { + return Ok(( + elementary_timescale, + None, + samples + .into_iter() + .map(|sample| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect(), + )); + } + + if sample_pts.len() != sample_dts.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream MPEG-4 Part 2 video timing anchors disagreed between presentation and decode timestamps" + .to_string(), + }); + } + if sample_offsets.len() != sample_pts.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream MPEG-4 Part 2 video timing anchors disagreed between payload offsets and timestamps" + .to_string(), + }); + } + + if sample_pts.len() + 1 == samples.len() { + samples.pop(); + } + + if sample_pts.len() > samples.len() + 1 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "program stream MPEG-4 Part 2 video PES timing anchors ({}) did not match parsed picture count ({})", + sample_pts.len(), + samples.len(), + ), + }); + } + + let anchor_to_sample = + map_program_stream_mpeg2v_anchor_offsets_to_picture_samples(sample_offsets, &samples); + let mut sample_to_anchor = build_program_stream_mpeg2v_sample_anchor_map( + spec, + sample_offsets, + &anchor_to_sample, + samples.len(), + )?; + if sample_to_anchor.len() > 1 + && sample_to_anchor.last().is_some_and(Option::is_some) + && sample_pts.len() == samples.len() + && sample_dts.len() == samples.len() + { + samples.pop(); + sample_to_anchor.pop(); + } + + let mut normalized = Vec::with_capacity(samples.len()); + let mut source_edit_media_time = None; + let mut last_composition_time_offset = 0_i32; + for (index, sample) in samples.into_iter().enumerate() { + let scaled_sample_duration = scale_mp4v_duration_to_program_stream_clock( + spec, + elementary_timescale, + sample.duration, + )?; + let (duration, composition_time_offset) = if let Some(anchor_index) = + sample_to_anchor[index] + { + let current_pts = sample_pts[anchor_index]; + let current_dts = sample_dts[anchor_index]; + if current_pts < current_dts { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream MPEG-4 Part 2 video presentation timestamps must not precede decode timestamps" + .to_string(), + }); + } + let duration = if let Some((next_sample_index, next_anchor_index)) = sample_to_anchor + [index + 1..] + .iter() + .enumerate() + .find_map(|(delta, anchor)| { + anchor.map(|anchor_index| (index + 1 + delta, anchor_index)) + }) { + let next_dts = sample_dts[next_anchor_index]; + if next_dts <= current_dts { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream MPEG-4 Part 2 video decode timestamps must increase monotonically" + .to_string(), + }); + } + if next_sample_index == index + 1 { + u32::try_from(next_dts - current_dts).map_err(|_| { + MuxError::LayoutOverflow("program stream MPEG-4 Part 2 video duration") + })? + } else { + scaled_sample_duration + } + } else { + scaled_sample_duration + }; + let composition_time_offset = + i32::try_from(current_pts - current_dts).map_err(|_| { + MuxError::LayoutOverflow( + "program stream MPEG-4 Part 2 video composition offset", + ) + })?; + last_composition_time_offset = composition_time_offset; + if index == 0 && composition_time_offset > 0 { + source_edit_media_time = + Some(u64::try_from(composition_time_offset).map_err(|_| { + MuxError::LayoutOverflow("program stream MPEG-4 Part 2 video edit") + })?); + } + (duration, composition_time_offset) + } else { + (scaled_sample_duration, last_composition_time_offset) + }; + if duration == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream MPEG-4 Part 2 video frame duration underflowed after media-timescale normalization" + .to_string(), + }); + } + normalized.push(CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration, + composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }); + } + + Ok(( + PROGRAM_STREAM_MEDIA_TIMESCALE, + source_edit_media_time, + normalized, + )) +} + +fn map_program_stream_mpeg2v_anchor_offsets_to_picture_samples( + sample_offsets: &[u64], + samples: &[StagedSample], +) -> Vec> { + sample_offsets + .iter() + .map(|&sample_offset| { + if sample_offset == 0 { + return Some(0); + } + samples + .iter() + .position(|sample| sample.data_offset >= sample_offset) + }) + .collect() +} + +fn build_program_stream_mpeg2v_sample_anchor_map( + spec: &str, + sample_offsets: &[u64], + anchor_to_sample: &[Option], + sample_count: usize, +) -> Result>, MuxError> { + let mut sample_to_anchor = vec![None; sample_count]; + for (anchor_index, sample_index) in anchor_to_sample.iter().copied().enumerate() { + let Some(sample_index) = sample_index else { + continue; + }; + if sample_index >= sample_count { + continue; + } + if sample_to_anchor[sample_index].is_some() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "program stream MPEG-2 video carried multiple timing anchors for one parsed picture sample near byte offset {}", + sample_offsets[anchor_index] + ), + }); + } + sample_to_anchor[sample_index] = Some(anchor_index); + } + Ok(sample_to_anchor) +} + +fn scale_mpeg2v_duration_to_program_stream_clock( + spec: &str, + elementary_timescale: u32, + duration: u32, +) -> Result { + if elementary_timescale == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream MPEG-2 video reported a zero media timescale".to_string(), + }); + } + let scaled = u64::from(duration) + .checked_mul(u64::from(PROGRAM_STREAM_MEDIA_TIMESCALE)) + .ok_or(MuxError::LayoutOverflow( + "program stream MPEG-2 video duration", + ))?; + if scaled % u64::from(elementary_timescale) != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream MPEG-2 video cadence does not rescale cleanly onto the 90_000 media clock" + .to_string(), + }); + } + u32::try_from(scaled / u64::from(elementary_timescale)) + .map_err(|_| MuxError::LayoutOverflow("program stream MPEG-2 video duration")) +} + +fn scale_mp4v_duration_to_program_stream_clock( + spec: &str, + elementary_timescale: u32, + duration: u32, +) -> Result { + if elementary_timescale == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream MPEG-4 Part 2 video reported a zero media timescale" + .to_string(), + }); + } + let scaled = u64::from(duration) + .checked_mul(u64::from(PROGRAM_STREAM_MEDIA_TIMESCALE)) + .ok_or(MuxError::LayoutOverflow( + "program stream MPEG-4 Part 2 video duration", + ))?; + if scaled % u64::from(elementary_timescale) != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream MPEG-4 Part 2 video cadence does not rescale cleanly onto the 90_000 media clock" + .to_string(), + }); + } + u32::try_from(scaled / u64::from(elementary_timescale)) + .map_err(|_| MuxError::LayoutOverflow("program stream MPEG-4 Part 2 video duration")) +} + +#[cfg(feature = "async")] +async fn finalize_program_stream_video_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + builder: ProgramStreamTrackBuilder, +) -> Result { + let prefix = read_program_stream_video_prefix_async(file, &builder, spec).await?; + match detect_path_track_kind_from_prefix(&prefix) { + DetectedPathTrackKind::Raw(super::super::MuxRawCodec::Mpeg2v) => { + let mut parsed = + scan_mpeg2v_segmented_async(file, &builder.segments, builder.total_size, spec) + .await?; + if parsed.eof_terminated_trailing_sample { + parsed.samples.pop(); + } + let (timescale, source_edit_media_time, samples) = + normalize_program_stream_mpeg2v_samples( + spec, + parsed.timescale, + parsed.samples, + &builder.sample_offsets, + &builder.sample_pts, + &builder.sample_dts, + )?; + let sample_entry_box = build_program_stream_mpeg2v_sample_entry_box( + ProgramStreamMpeg2vSampleEntryConfig { + width: parsed.width, + height: parsed.height, + decoder_specific_info: &parsed.decoder_specific_info, + object_type_indication: parsed.object_type_indication, + timescale, + leading_media_time: source_edit_media_time.unwrap_or(0), + pixel_aspect_ratio: parsed.pixel_aspect_ratio, + }, + samples.iter().map(|sample| (sample.data_size, sample.duration)), + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(builder.stream_id), + kind: MuxTrackKind::Video, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mpeg2v"), + mux_policy: direct_ingest_mux_policy("mpeg2v", MuxTrackKind::Video) + .without_terminal_flat_video_chunk_split(), + width: parsed.width, + height: parsed.height, + sample_entry_box, + source_edit_media_time, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) + } + DetectedPathTrackKind::Raw(super::super::MuxRawCodec::Mp4v) => { + let parsed = + scan_mp4v_segmented_async(file, &builder.segments, builder.total_size, spec).await?; + let (timescale, source_edit_media_time, samples) = + normalize_program_stream_mp4v_samples( + spec, + parsed.timescale, + parsed.samples, + &builder.sample_offsets, + &builder.sample_pts, + &builder.sample_dts, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(builder.stream_id), + kind: MuxTrackKind::Video, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mp4v"), + mux_policy: direct_ingest_mux_policy("mp4v", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: super::super::mp4::strip_visual_sample_entry_immediate_children( + &parsed.sample_entry_box, + &[FourCc::from_bytes(*b"pasp")], + )?, + source_edit_media_time, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) + } + DetectedPathTrackKind::Raw(super::super::MuxRawCodec::H264) => { + let parsed = stage_annex_b_h264_segmented_async( + path, + file, + &builder.segments, + builder.total_size, + spec, + ) + .await?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(builder.stream_id), + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h264"), + mux_policy: direct_ingest_mux_policy("h264", MuxTrackKind::Video), + width: parsed.track_width, + height: parsed.track_height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: parsed.source_edit_media_time, + samples: parsed + .samples + .into_iter() + .map(|sample| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect(), + }, + source_spec: parsed.segmented_source, + }) + } + DetectedPathTrackKind::Raw(super::super::MuxRawCodec::H265) => { + let parsed = stage_annex_b_h265_segmented_async( + path, + file, + &builder.segments, + builder.total_size, + spec, + ) + .await?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(builder.stream_id), + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h265"), + mux_policy: direct_ingest_mux_policy("h265", MuxTrackKind::Video), + width: parsed.track_width, + height: parsed.track_height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: parsed.source_edit_media_time, + samples: parsed + .samples + .into_iter() + .map(|sample| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect(), + }, + source_spec: parsed.segmented_source, + }) + } + DetectedPathTrackKind::Raw(super::super::MuxRawCodec::Vvc) => { + let parsed = stage_annex_b_vvc_segmented_async( + path, + file, + &builder.segments, + builder.total_size, + spec, + ) + .await?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: program_stream_track_id(builder.stream_id), + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("vvc"), + mux_policy: direct_ingest_mux_policy("vvc", MuxTrackKind::Video), + width: parsed.track_width, + height: parsed.track_height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: parsed.source_edit_media_time, + samples: parsed + .samples + .into_iter() + .map(|sample| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect(), + }, + source_spec: parsed.segmented_source, + }) + } + _ => Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream video payload is not a supported MPEG-2, MPEG-4 Part 2, H.264, H.265, or VVC elementary stream" + .to_string(), + }), + } +} + +fn parse_private_stream_1_pes_packet_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, + stream_id: u8, +) -> Result { + let parsed = parse_pes_packet_sync(file, file_size, offset, spec, stream_id)?; + if parsed.payload_size < PRIVATE_STREAM_1_PRIVATE_HEADER_BYTES { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream private_stream_1 payload is truncated before the 4-byte private header" + .to_string(), + }); + } + let mut private_header = [0_u8; PRIVATE_STREAM_1_PRIVATE_HEADER_BYTES as usize]; + read_exact_at_sync( + file, + parsed.payload_offset, + &mut private_header, + spec, + "program stream private_stream_1 payload is truncated before the 4-byte private header", + )?; + finalize_private_stream_1_pes_packet( + spec, + private_header, + parsed.presentation_time, + parsed.payload_offset, + parsed.payload_size, + parsed.packet_end, + ) +} + +#[cfg(feature = "async")] +async fn parse_private_stream_1_pes_packet_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, + stream_id: u8, +) -> Result { + let parsed = parse_pes_packet_async(file, file_size, offset, spec, stream_id).await?; + if parsed.payload_size < PRIVATE_STREAM_1_PRIVATE_HEADER_BYTES { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "program stream private_stream_1 payload is truncated before the 4-byte private header" + .to_string(), + }); + } + let mut private_header = [0_u8; PRIVATE_STREAM_1_PRIVATE_HEADER_BYTES as usize]; + read_exact_at_async( + file, + parsed.payload_offset, + &mut private_header, + spec, + "program stream private_stream_1 payload is truncated before the 4-byte private header", + ) + .await?; + finalize_private_stream_1_pes_packet( + spec, + private_header, + parsed.presentation_time, + parsed.payload_offset, + parsed.payload_size, + parsed.packet_end, + ) +} + +fn finalize_private_stream_1_pes_packet( + spec: &str, + private_header: [u8; PRIVATE_STREAM_1_PRIVATE_HEADER_BYTES as usize], + presentation_time: Option, + payload_offset: u64, + payload_size: u32, + packet_end: u64, +) -> Result { + let substream_id = private_header[0]; + let (kind, lpcm_format) = if (PRIVATE_STREAM_1_AC3_MIN..=PRIVATE_STREAM_1_AC3_MAX) + .contains(&substream_id) + { + (ProgramStreamTrackKind::Ac3, None) + } else if (PRIVATE_STREAM_1_LPCM_MIN..=PRIVATE_STREAM_1_LPCM_MAX).contains(&substream_id) { + let lpcm_format = parse_program_stream_lpcm_format( + spec, + substream_id, + [private_header[1], private_header[2], private_header[3]], + )?; + (ProgramStreamTrackKind::Lpcm, Some(lpcm_format)) + } else if (0x20..=0x3F).contains(&substream_id) { + (ProgramStreamTrackKind::Subpicture, None) + } else { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "program stream private_stream_1 substream 0x{substream_id:02X} is not supported on the native direct-ingest path yet" + ), + }); + }; + Ok(ParsedPrivateStream1PesPacket { + substream_id, + kind, + lpcm_format, + presentation_time, + payload_offset: payload_offset + u64::from(PRIVATE_STREAM_1_PRIVATE_HEADER_BYTES), + payload_size: payload_size - PRIVATE_STREAM_1_PRIVATE_HEADER_BYTES, + packet_end, + }) +} + +fn parse_program_stream_lpcm_format( + spec: &str, + substream_id: u8, + private_header_bytes: [u8; 3], +) -> Result { + let format_byte = private_header_bytes[2]; + let bits_per_sample = match format_byte >> 6 { + 0 => 16, + 1 => 20, + 2 => 24, + other => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "program stream LPCM substream 0x{substream_id:02X} used unsupported sample-size code {other}" + ), + }); + } + }; + if bits_per_sample % 8 != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "program stream LPCM substream 0x{substream_id:02X} used unsupported non-byte-aligned {bits_per_sample}-bit samples" + ), + }); + } + let sample_rate = match (format_byte >> 4) & 0x03 { + 0 => 48_000, + 1 => 96_000, + 2 => 44_100, + 3 => 32_000, + _ => unreachable!(), + }; + let channel_count = u16::from(format_byte & 0x07) + 1; + let block_align = + channel_count + .checked_mul(bits_per_sample / 8) + .ok_or(MuxError::LayoutOverflow( + "program stream LPCM block alignment", + ))?; + if block_align == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "program stream LPCM substream 0x{substream_id:02X} declared an invalid zero-byte frame size" + ), + }); + } + Ok(ProgramStreamLpcmFormat { + sample_rate, + channel_count, + bits_per_sample, + block_align, + }) +} + +fn read_program_stream_video_prefix_sync( + file: &mut File, + builder: &ProgramStreamTrackBuilder, + spec: &str, +) -> Result, MuxError> { + let prefix_len = usize::try_from(builder.total_size.min(4 * 1024)) + .map_err(|_| MuxError::LayoutOverflow("program stream video prefix length"))?; + let mut prefix = vec![0_u8; prefix_len]; + read_segmented_bytes_sync( + file, + &builder.segments, + builder.total_size, + 0, + &mut prefix, + spec, + "program stream video prefix is truncated", + )?; + Ok(prefix) +} + +#[cfg(feature = "async")] +async fn read_program_stream_video_prefix_async( + file: &mut TokioFile, + builder: &ProgramStreamTrackBuilder, + spec: &str, +) -> Result, MuxError> { + let prefix_len = usize::try_from(builder.total_size.min(4 * 1024)) + .map_err(|_| MuxError::LayoutOverflow("program stream video prefix length"))?; + let mut prefix = vec![0_u8; prefix_len]; + read_segmented_bytes_async( + file, + &builder.segments, + builder.total_size, + 0, + &mut prefix, + spec, + "program stream video prefix is truncated", + ) + .await?; + Ok(prefix) +} + +fn validate_program_stream_header_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result<(), MuxError> { + if file_size < 14 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream input is truncated before the pack header".to_string(), + }); + } + let mut header = [0_u8; 4]; + read_exact_at_sync( + file, + 0, + &mut header, + spec, + "program stream input is truncated before the pack header", + )?; + if header != PACK_START_CODE { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "input is not an MPEG program stream pack header".to_string(), + }); + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn validate_program_stream_header_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result<(), MuxError> { + if file_size < 14 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream input is truncated before the pack header".to_string(), + }); + } + let mut header = [0_u8; 4]; + read_exact_at_async( + file, + 0, + &mut header, + spec, + "program stream input is truncated before the pack header", + ) + .await?; + if header != PACK_START_CODE { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "input is not an MPEG program stream pack header".to_string(), + }); + } + Ok(()) +} + +fn read_program_stream_start_code_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, +) -> Result<[u8; 4], MuxError> { + if file_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated MPEG program stream start code".to_string(), + }); + } + let mut start_code = [0_u8; 4]; + read_exact_at_sync( + file, + offset, + &mut start_code, + spec, + "truncated MPEG program stream start code", + )?; + if start_code[..3] != [0x00, 0x00, 0x01] { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("invalid MPEG program stream start code at byte offset {offset}"), + }); + } + Ok(start_code) +} + +#[cfg(feature = "async")] +async fn read_program_stream_start_code_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, +) -> Result<[u8; 4], MuxError> { + if file_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated MPEG program stream start code".to_string(), + }); + } + let mut start_code = [0_u8; 4]; + read_exact_at_async( + file, + offset, + &mut start_code, + spec, + "truncated MPEG program stream start code", + ) + .await?; + if start_code[..3] != [0x00, 0x00, 0x01] { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("invalid MPEG program stream start code at byte offset {offset}"), + }); + } + Ok(start_code) +} + +fn parse_pack_header_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, +) -> Result { + if file_size - offset < 12 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated program stream pack header".to_string(), + }); + } + let mut first_byte = [0_u8; 1]; + read_exact_at_sync( + file, + offset + 4, + &mut first_byte, + spec, + "truncated program stream pack header", + )?; + if first_byte[0] & 0xF0 == 0x20 { + return Ok(offset + 12); + } + if file_size - offset < 14 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated program stream pack header".to_string(), + }); + } + let mut header = [0_u8; 10]; + header[0] = first_byte[0]; + read_exact_at_sync( + file, + offset + 5, + &mut header[1..], + spec, + "truncated program stream pack header", + )?; + if header[0] & 0xC0 != 0x40 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "unsupported program stream pack-header layout".to_string(), + }); + } + let packet_size = 14_u64 + u64::from(header[9] & 0x07); + if offset + .checked_add(packet_size) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated program stream pack stuffing bytes".to_string(), + }); + } + Ok(offset + packet_size) +} + +#[cfg(feature = "async")] +async fn parse_pack_header_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, +) -> Result { + if file_size - offset < 12 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated program stream pack header".to_string(), + }); + } + let mut first_byte = [0_u8; 1]; + read_exact_at_async( + file, + offset + 4, + &mut first_byte, + spec, + "truncated program stream pack header", + ) + .await?; + if first_byte[0] & 0xF0 == 0x20 { + return Ok(offset + 12); + } + if file_size - offset < 14 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated program stream pack header".to_string(), + }); + } + let mut header = [0_u8; 10]; + header[0] = first_byte[0]; + read_exact_at_async( + file, + offset + 5, + &mut header[1..], + spec, + "truncated program stream pack header", + ) + .await?; + if header[0] & 0xC0 != 0x40 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "unsupported program stream pack-header layout".to_string(), + }); + } + let packet_size = 14_u64 + u64::from(header[9] & 0x07); + if offset + .checked_add(packet_size) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated program stream pack stuffing bytes".to_string(), + }); + } + Ok(offset + packet_size) +} + +fn skip_length_delimited_ps_packet_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, + packet_id: u8, +) -> Result { + if file_size - offset < 6 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "truncated program stream packet header for start code 0x{packet_id:02X}" + ), + }); + } + let mut length_bytes = [0_u8; 2]; + read_exact_at_sync( + file, + offset + 4, + &mut length_bytes, + spec, + "truncated program stream packet length", + )?; + let packet_size = 6_u64 + u64::from(u16::from_be_bytes(length_bytes)); + if offset + .checked_add(packet_size) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "truncated program stream packet body for start code 0x{packet_id:02X}" + ), + }); + } + Ok(offset + packet_size) +} + +fn is_supported_program_stream_packet_id(packet_id: u8) -> bool { + matches!( + packet_id, + 0xB9 + | 0xBA + | SYSTEM_HEADER_START_CODE + | PROGRAM_STREAM_MAP_START_CODE + | PRIVATE_STREAM_1_START_CODE + | PADDING_STREAM_START_CODE + | PRIVATE_STREAM_2_START_CODE + | 0xC0..=0xDF + | 0xE0..=0xEF + ) +} + +fn find_program_stream_packet_start_in_bytes(bytes: &[u8]) -> Option { + bytes.windows(4).position(|window| { + window[..3] == [0x00, 0x00, 0x01] && is_supported_program_stream_packet_id(window[3]) + }) +} + +// Open-ended PES packets still need a deterministic end boundary. We scan for the next +// recognized program-stream packet start code rather than treating any `00 00 01 xx` sequence as +// a boundary, which avoids colliding with carried Annex B or MPEG-4 Part 2 start-code families. +fn find_next_program_stream_packet_start_sync( + file: &mut File, + file_size: u64, + search_offset: u64, + spec: &str, +) -> Result, MuxError> { + if search_offset >= file_size { + return Ok(None); + } + + let mut scan_offset = search_offset; + let mut carry = Vec::new(); + while scan_offset < file_size { + let remaining = usize::try_from(file_size - scan_offset).unwrap_or(usize::MAX); + let chunk_len = remaining.min(PROGRAM_STREAM_SCAN_CHUNK_BYTES); + let mut chunk = vec![0_u8; chunk_len]; + read_exact_at_sync( + file, + scan_offset, + &mut chunk, + spec, + "truncated program stream open-ended PES scan chunk", + )?; + + let mut scan_bytes = carry; + let base_offset = scan_offset - u64::try_from(scan_bytes.len()).unwrap(); + scan_bytes.extend_from_slice(&chunk); + if let Some(found) = find_program_stream_packet_start_in_bytes(&scan_bytes) { + return Ok(Some(base_offset + u64::try_from(found).unwrap())); + } + + let keep = scan_bytes.len().min(3); + carry = scan_bytes[scan_bytes.len() - keep..].to_vec(); + scan_offset += u64::try_from(chunk_len).unwrap(); + } + + Ok(None) +} + +#[cfg(feature = "async")] +async fn skip_length_delimited_ps_packet_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, + packet_id: u8, +) -> Result { + if file_size - offset < 6 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "truncated program stream packet header for start code 0x{packet_id:02X}" + ), + }); + } + let mut length_bytes = [0_u8; 2]; + read_exact_at_async( + file, + offset + 4, + &mut length_bytes, + spec, + "truncated program stream packet length", + ) + .await?; + let packet_size = 6_u64 + u64::from(u16::from_be_bytes(length_bytes)); + if offset + .checked_add(packet_size) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "truncated program stream packet body for start code 0x{packet_id:02X}" + ), + }); + } + Ok(offset + packet_size) +} + +#[cfg(feature = "async")] +async fn find_next_program_stream_packet_start_async( + file: &mut TokioFile, + file_size: u64, + search_offset: u64, + spec: &str, +) -> Result, MuxError> { + if search_offset >= file_size { + return Ok(None); + } + + let mut scan_offset = search_offset; + let mut carry = Vec::new(); + while scan_offset < file_size { + let remaining = usize::try_from(file_size - scan_offset).unwrap_or(usize::MAX); + let chunk_len = remaining.min(PROGRAM_STREAM_SCAN_CHUNK_BYTES); + let mut chunk = vec![0_u8; chunk_len]; + read_exact_at_async( + file, + scan_offset, + &mut chunk, + spec, + "truncated program stream open-ended PES scan chunk", + ) + .await?; + + let mut scan_bytes = carry; + let base_offset = scan_offset - u64::try_from(scan_bytes.len()).unwrap(); + scan_bytes.extend_from_slice(&chunk); + if let Some(found) = find_program_stream_packet_start_in_bytes(&scan_bytes) { + return Ok(Some(base_offset + u64::try_from(found).unwrap())); + } + + let keep = scan_bytes.len().min(3); + carry = scan_bytes[scan_bytes.len() - keep..].to_vec(); + scan_offset += u64::try_from(chunk_len).unwrap(); + } + + Ok(None) +} + +fn parse_pes_packet_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, + stream_id: u8, +) -> Result { + if file_size - offset < 9 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated PES header for program stream id 0x{stream_id:02X}"), + }); + } + let mut header = [0_u8; 5]; + read_exact_at_sync( + file, + offset + 4, + &mut header, + spec, + "truncated program stream PES header", + )?; + let pes_packet_length = u16::from_be_bytes([header[0], header[1]]); + let (payload_offset, presentation_time, decode_time) = if header[2] & 0xC0 == 0x80 { + let header_data_length = u64::from(header[4]); + let payload_offset = offset + 9 + header_data_length; + if payload_offset > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated program stream PES payload".to_string(), + }); + } + let presentation_time = if header[3] & 0x80 != 0 { + Some(parse_program_stream_pes_timestamp_sync( + file, + offset + 9, + file_size, + spec, + )?) + } else { + None + }; + let decode_time = if header[3] & 0x40 != 0 { + Some(parse_program_stream_pes_timestamp_sync( + file, + offset + 14, + file_size, + spec, + )?) + } else { + presentation_time + }; + (payload_offset, presentation_time, decode_time) + } else { + parse_mpeg1_pes_header_sync(file, file_size, offset, spec)? + }; + let packet_end = if pes_packet_length == 0 { + if !matches!(stream_id, 0xE0..=0xEF) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "open-ended PES packets are only supported for program-stream video carriage on the native direct-ingest path" + .to_string(), + }); + } + find_next_program_stream_packet_start_sync(file, file_size, payload_offset, spec)? + .unwrap_or(file_size) + } else { + offset + 6 + u64::from(pes_packet_length) + }; + if payload_offset > packet_end || packet_end > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated program stream PES payload".to_string(), + }); + } + let payload_size = u32::try_from(packet_end - payload_offset) + .map_err(|_| MuxError::LayoutOverflow("program stream PES payload"))?; + Ok(ParsedProgramStreamPesPacket { + payload_offset, + payload_size, + packet_end, + presentation_time, + decode_time, + }) +} + +#[cfg(feature = "async")] +async fn parse_pes_packet_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, + stream_id: u8, +) -> Result { + if file_size - offset < 9 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated PES header for program stream id 0x{stream_id:02X}"), + }); + } + let mut header = [0_u8; 5]; + read_exact_at_async( + file, + offset + 4, + &mut header, + spec, + "truncated program stream PES header", + ) + .await?; + let pes_packet_length = u16::from_be_bytes([header[0], header[1]]); + let (payload_offset, presentation_time, decode_time) = if header[2] & 0xC0 == 0x80 { + let header_data_length = u64::from(header[4]); + let payload_offset = offset + 9 + header_data_length; + if payload_offset > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated program stream PES payload".to_string(), + }); + } + let presentation_time = if header[3] & 0x80 != 0 { + Some(parse_program_stream_pes_timestamp_async(file, offset + 9, file_size, spec).await?) + } else { + None + }; + let decode_time = if header[3] & 0x40 != 0 { + Some( + parse_program_stream_pes_timestamp_async(file, offset + 14, file_size, spec) + .await?, + ) + } else { + presentation_time + }; + (payload_offset, presentation_time, decode_time) + } else { + parse_mpeg1_pes_header_async(file, file_size, offset, spec).await? + }; + let packet_end = if pes_packet_length == 0 { + if !matches!(stream_id, 0xE0..=0xEF) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "open-ended PES packets are only supported for program-stream video carriage on the native direct-ingest path" + .to_string(), + }); + } + find_next_program_stream_packet_start_async(file, file_size, payload_offset, spec) + .await? + .unwrap_or(file_size) + } else { + offset + 6 + u64::from(pes_packet_length) + }; + if payload_offset > packet_end || packet_end > file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated program stream PES payload".to_string(), + }); + } + let payload_size = u32::try_from(packet_end - payload_offset) + .map_err(|_| MuxError::LayoutOverflow("program stream PES payload"))?; + Ok(ParsedProgramStreamPesPacket { + payload_offset, + payload_size, + packet_end, + presentation_time, + decode_time, + }) +} + +fn parse_mpeg1_pes_header_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, +) -> Result<(u64, Option, Option), MuxError> { + let mut cursor = offset + 6; + let mut next = read_program_stream_byte_sync(file, file_size, cursor, spec)?; + while next == 0xFF { + cursor += 1; + next = read_program_stream_byte_sync(file, file_size, cursor, spec)?; + } + if next & 0xC0 == 0x40 { + cursor += 2; + next = read_program_stream_byte_sync(file, file_size, cursor, spec)?; + } + if next & 0xF0 == 0x20 { + let presentation_time = + parse_program_stream_pes_timestamp_sync(file, cursor, file_size, spec)?; + return Ok((cursor + 5, Some(presentation_time), Some(presentation_time))); + } + if next & 0xF0 == 0x30 { + let presentation_time = + parse_program_stream_pes_timestamp_sync(file, cursor, file_size, spec)?; + let decode_time = + parse_program_stream_pes_timestamp_sync(file, cursor + 5, file_size, spec)?; + return Ok((cursor + 10, Some(presentation_time), Some(decode_time))); + } + if next == 0x0F { + return Ok((cursor + 1, None, None)); + } + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "unsupported PES header flags on the native direct-ingest program-stream path" + .to_string(), + }) +} + +#[cfg(feature = "async")] +async fn parse_mpeg1_pes_header_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, +) -> Result<(u64, Option, Option), MuxError> { + let mut cursor = offset + 6; + let mut next = read_program_stream_byte_async(file, file_size, cursor, spec).await?; + while next == 0xFF { + cursor += 1; + next = read_program_stream_byte_async(file, file_size, cursor, spec).await?; + } + if next & 0xC0 == 0x40 { + cursor += 2; + next = read_program_stream_byte_async(file, file_size, cursor, spec).await?; + } + if next & 0xF0 == 0x20 { + let presentation_time = + parse_program_stream_pes_timestamp_async(file, cursor, file_size, spec).await?; + return Ok((cursor + 5, Some(presentation_time), Some(presentation_time))); + } + if next & 0xF0 == 0x30 { + let presentation_time = + parse_program_stream_pes_timestamp_async(file, cursor, file_size, spec).await?; + let decode_time = + parse_program_stream_pes_timestamp_async(file, cursor + 5, file_size, spec).await?; + return Ok((cursor + 10, Some(presentation_time), Some(decode_time))); + } + if next == 0x0F { + return Ok((cursor + 1, None, None)); + } + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "unsupported PES header flags on the native direct-ingest program-stream path" + .to_string(), + }) +} + +fn read_program_stream_byte_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, +) -> Result { + if offset >= file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated program stream PES header".to_string(), + }); + } + let mut byte = [0_u8; 1]; + read_exact_at_sync( + file, + offset, + &mut byte, + spec, + "truncated program stream PES header", + )?; + Ok(byte[0]) +} + +#[cfg(feature = "async")] +async fn read_program_stream_byte_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, +) -> Result { + if offset >= file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated program stream PES header".to_string(), + }); + } + let mut byte = [0_u8; 1]; + read_exact_at_async( + file, + offset, + &mut byte, + spec, + "truncated program stream PES header", + ) + .await?; + Ok(byte[0]) +} + +fn parse_program_stream_pes_timestamp_sync( + file: &mut File, + timestamp_offset: u64, + file_size: u64, + spec: &str, +) -> Result { + if file_size.saturating_sub(timestamp_offset) < 5 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated program stream PES timestamp".to_string(), + }); + } + let mut pts = [0_u8; 5]; + read_exact_at_sync( + file, + timestamp_offset, + &mut pts, + spec, + "truncated program stream PES timestamp", + )?; + parse_program_stream_pes_timestamp_bytes(&pts, spec) +} + +#[cfg(feature = "async")] +async fn parse_program_stream_pes_timestamp_async( + file: &mut TokioFile, + timestamp_offset: u64, + file_size: u64, + spec: &str, +) -> Result { + if file_size.saturating_sub(timestamp_offset) < 5 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated program stream PES timestamp".to_string(), + }); + } + let mut pts = [0_u8; 5]; + read_exact_at_async( + file, + timestamp_offset, + &mut pts, + spec, + "truncated program stream PES timestamp", + ) + .await?; + parse_program_stream_pes_timestamp_bytes(&pts, spec) +} + +fn parse_program_stream_pes_timestamp_bytes(pts: &[u8; 5], spec: &str) -> Result { + let prefix = pts[0] & 0xF1; + if !matches!(prefix, 0x11 | 0x21 | 0x31) || pts[2] & 0x01 != 0x01 || pts[4] & 0x01 != 0x01 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "program stream PES timestamp markers are malformed".to_string(), + }); + } + Ok((u64::from((pts[0] >> 1) & 0x07) << 30) + | (u64::from(pts[1]) << 22) + | (u64::from((pts[2] >> 1) & 0x7F) << 15) + | (u64::from(pts[3]) << 7) + | u64::from((pts[4] >> 1) & 0x7F)) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::fs::File; + use std::path::PathBuf; + + use super::{ + PADDING_STREAM_START_CODE, PRIVATE_STREAM_1_START_CODE, PRIVATE_STREAM_2_START_CODE, + PROGRAM_STREAM_MAP_START_CODE, PROGRAM_STREAM_MEDIA_TIMESCALE, ProgramStreamTrackBuilder, + ProgramStreamTrackKind, SYSTEM_HEADER_START_CODE, append_file_range_segment, + normalize_program_stream_mpeg2v_samples, parse_pes_packet_sync, + read_program_stream_start_code_sync, scan_mpeg2v_segmented_sync, scan_program_stream_sync, + skip_length_delimited_ps_packet_sync, validate_program_stream_header_sync, + }; + use crate::mux::MuxTrackKind; + use crate::mux::import::SegmentedMuxSourceSegment; + use crate::mux::import::StagedSample; + + #[test] + fn normalize_program_stream_mpeg2v_samples_maps_timed_pes_offsets_to_picture_samples() { + let samples = vec![ + StagedSample { + data_offset: 0, + data_size: 7032, + duration: 1000, + composition_time_offset: 0, + is_sync_sample: true, + }, + StagedSample { + data_offset: 7032, + data_size: 3242, + duration: 1000, + composition_time_offset: 0, + is_sync_sample: false, + }, + StagedSample { + data_offset: 10274, + data_size: 1283, + duration: 1000, + composition_time_offset: 0, + is_sync_sample: false, + }, + StagedSample { + data_offset: 11557, + data_size: 1259, + duration: 1000, + composition_time_offset: 0, + is_sync_sample: false, + }, + StagedSample { + data_offset: 12816, + data_size: 1261, + duration: 1000, + composition_time_offset: 0, + is_sync_sample: false, + }, + ]; + + let (timescale, source_edit_media_time, normalized) = + normalize_program_stream_mpeg2v_samples( + "test", + 25_000, + samples, + &[0, 6059, 10097, 12111], + &[48_600, 52_200, 55_800, 63_000], + &[45_000, 48_600, 52_200, 59_400], + ) + .unwrap(); + + assert_eq!(timescale, PROGRAM_STREAM_MEDIA_TIMESCALE); + assert_eq!(source_edit_media_time, Some(3600)); + assert_eq!( + normalized + .iter() + .map(|sample| (sample.data_offset, sample.data_size, sample.duration)) + .collect::>(), + vec![ + (0, 7032, 3600), + (7032, 3242, 3600), + (10274, 1283, 3600), + (11557, 1259, 1000), + ] + ); + assert_eq!( + normalized + .iter() + .map(|sample| sample.composition_time_offset) + .collect::>(), + vec![3600, 3600, 3600, 3600] + ); + assert!(normalized[0].is_sync_sample); + } + + #[test] + fn program_stream_mpeg2v_fixture_maps_expected_flat_sampleization() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("mux") + .join("program_stream_video.mpeg"); + let spec = format!("{}#video", path.display()); + let mut file = File::open(&path).unwrap(); + let file_size = file.metadata().unwrap().len(); + validate_program_stream_header_sync(&mut file, file_size, &spec).unwrap(); + let mut builders = BTreeMap::::new(); + let mut offset = 0_u64; + while offset < file_size { + let start_code = + read_program_stream_start_code_sync(&mut file, file_size, offset, &spec).unwrap(); + match start_code[3] { + 0xBA => { + offset = + super::parse_pack_header_sync(&mut file, file_size, offset, &spec).unwrap(); + } + SYSTEM_HEADER_START_CODE + | PROGRAM_STREAM_MAP_START_CODE + | PADDING_STREAM_START_CODE + | PRIVATE_STREAM_2_START_CODE => { + offset = skip_length_delimited_ps_packet_sync( + &mut file, + file_size, + offset, + &spec, + start_code[3], + ) + .unwrap(); + } + PRIVATE_STREAM_1_START_CODE => { + offset = super::parse_private_stream_1_pes_packet_sync( + &mut file, + file_size, + offset, + &spec, + start_code[3], + ) + .unwrap() + .packet_end; + } + 0xC0..=0xDF => { + let parsed = + parse_pes_packet_sync(&mut file, file_size, offset, &spec, start_code[3]) + .unwrap(); + let builder = builders.entry(start_code[3]).or_insert_with(|| { + ProgramStreamTrackBuilder { + stream_id: start_code[3], + kind: ProgramStreamTrackKind::Mp3, + lpcm_format: None, + segments: Vec::::new(), + total_size: 0, + sample_offsets: Vec::new(), + sample_pts: Vec::new(), + sample_dts: Vec::new(), + } + }); + append_file_range_segment( + &mut builder.segments, + &mut builder.total_size, + parsed.payload_offset, + parsed.payload_size, + ); + offset = parsed.packet_end; + } + 0xE0..=0xEF => { + let parsed = + parse_pes_packet_sync(&mut file, file_size, offset, &spec, start_code[3]) + .unwrap(); + let builder = builders.entry(start_code[3]).or_insert_with(|| { + ProgramStreamTrackBuilder { + stream_id: start_code[3], + kind: ProgramStreamTrackKind::Video, + lpcm_format: None, + segments: Vec::::new(), + total_size: 0, + sample_offsets: Vec::new(), + sample_pts: Vec::new(), + sample_dts: Vec::new(), + } + }); + if let Some(presentation_time) = parsed.presentation_time { + builder.sample_offsets.push(builder.total_size); + builder.sample_pts.push(presentation_time); + builder + .sample_dts + .push(parsed.decode_time.unwrap_or(presentation_time)); + } + append_file_range_segment( + &mut builder.segments, + &mut builder.total_size, + parsed.payload_offset, + parsed.payload_size, + ); + offset = parsed.packet_end; + } + 0xB9 => break, + other => panic!("unexpected start code 0x{other:02X}"), + } + } + let builder = builders.remove(&0xE0).unwrap(); + let _parsed = + scan_mpeg2v_segmented_sync(&mut file, &builder.segments, builder.total_size, &spec) + .unwrap(); + let tracks = scan_program_stream_sync(&path, &spec).unwrap(); + let video = tracks + .into_iter() + .find(|candidate| candidate.track.kind == MuxTrackKind::Video) + .unwrap(); + let samples = video.track.samples; + assert_eq!(video.track.timescale, PROGRAM_STREAM_MEDIA_TIMESCALE); + assert_eq!(video.track.source_edit_media_time, Some(3003)); + assert_eq!(samples.len(), 29); + assert_eq!( + samples + .iter() + .filter(|sample| sample.is_sync_sample) + .count(), + 3 + ); + assert_eq!( + samples + .iter() + .map(|sample| sample.duration) + .collect::>(), + { + let mut durations = vec![3003; 28]; + durations.push(1001); + durations + } + ); + } +} diff --git a/src/mux/demux/qcp.rs b/src/mux/demux/qcp.rs new file mode 100644 index 0000000..97e8a17 --- /dev/null +++ b/src/mux/demux/qcp.rs @@ -0,0 +1,657 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::threegpp::{Devc, Dqcp, Dsmv}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{ + StagedSample, build_generic_audio_sample_entry_box, read_exact_at_sync, +}; + +const RIFF_MAGIC: &[u8; 4] = b"RIFF"; +const QLCM_MAGIC: &[u8; 4] = b"QLCM"; +const FMT_CHUNK: &[u8; 4] = b"fmt "; +const VRAT_CHUNK: &[u8; 4] = b"vrat"; +const DATA_CHUNK: &[u8; 4] = b"data"; +const QCP_FMT_MIN_SIZE: usize = 150; +const QCP_VRAT_MIN_SIZE: usize = 8; +const QCP_RATE_TABLE_CAP: usize = 8; +const SAMPLE_ENTRY_SQCP: FourCc = FourCc::from_bytes(*b"sqcp"); +const SAMPLE_ENTRY_SEVC: FourCc = FourCc::from_bytes(*b"sevc"); +const SAMPLE_ENTRY_SSMV: FourCc = FourCc::from_bytes(*b"ssmv"); +const QCP_QCELP_GUID_1: [u8; 16] = [ + 0x41, 0x6D, 0x7F, 0x5E, 0x15, 0xB1, 0xD0, 0x11, 0xBA, 0x91, 0x00, 0x80, 0x5F, 0xB4, 0xB9, 0x7E, +]; +const QCP_QCELP_GUID_2: [u8; 16] = [ + 0x42, 0x6D, 0x7F, 0x5E, 0x15, 0xB1, 0xD0, 0x11, 0xBA, 0x91, 0x00, 0x80, 0x5F, 0xB4, 0xB9, 0x7E, +]; +const QCP_EVRC_GUID: [u8; 16] = [ + 0x8D, 0xD4, 0x89, 0xE6, 0x76, 0x90, 0xB5, 0x46, 0x91, 0xEF, 0x73, 0x6A, 0x51, 0x00, 0xCE, 0xB4, +]; +const QCP_SMV_GUID: [u8; 16] = [ + 0x75, 0x2B, 0x7C, 0x8D, 0x97, 0xA7, 0x46, 0xED, 0x98, 0x5E, 0xD5, 0x3C, 0x8C, 0xC7, 0x5F, 0x84, +]; + +pub(in crate::mux) struct ParsedQcpTrack { + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, + pub(in crate::mux) handler_label: &'static str, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct QcpRateEntry { + packet_size: u8, + rate_index: u8, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QcpCodecKind { + Qcelp, + Evrc, + Smv, +} + +impl QcpCodecKind { + const fn handler_label(self) -> &'static str { + match self { + Self::Qcelp => "qcelp", + Self::Evrc => "evrc", + Self::Smv => "smv", + } + } + + const fn sample_entry_type(self) -> FourCc { + match self { + Self::Qcelp => SAMPLE_ENTRY_SQCP, + Self::Evrc => SAMPLE_ENTRY_SEVC, + Self::Smv => SAMPLE_ENTRY_SSMV, + } + } +} + +#[derive(Clone, Copy, Debug)] +struct ParsedQcpFormat { + codec_kind: QcpCodecKind, + sample_rate: u32, + block_size: u32, + packet_size: u32, + vrat_rate_flag: u32, + rate_table_count: usize, + rate_table: [QcpRateEntry; QCP_RATE_TABLE_CAP], + data_offset: u64, + data_size: u32, +} + +pub(in crate::mux) fn scan_qcp_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let parsed = parse_qcp_container_sync(&mut file, file_size, spec)?; + let samples = parse_qcp_samples_sync(&mut file, parsed, spec)?; + Ok(ParsedQcpTrack { + sample_rate: parsed.sample_rate, + sample_entry_box: build_qcp_sample_entry_box(parsed)?, + samples, + handler_label: parsed.codec_kind.handler_label(), + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_qcp_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let parsed = parse_qcp_container_async(&mut file, file_size, spec).await?; + let samples = parse_qcp_samples_async(&mut file, parsed, spec).await?; + Ok(ParsedQcpTrack { + sample_rate: parsed.sample_rate, + sample_entry_box: build_qcp_sample_entry_box(parsed)?, + samples, + handler_label: parsed.codec_kind.handler_label(), + }) +} + +fn parse_qcp_container_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result { + validate_qcp_file_header_sync(file, file_size, spec)?; + parse_qcp_chunks_sync(file, file_size, spec) +} + +#[cfg(feature = "async")] +async fn parse_qcp_container_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result { + validate_qcp_file_header_async(file, file_size, spec).await?; + parse_qcp_chunks_async(file, file_size, spec).await +} + +fn parse_qcp_chunks_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result { + let mut offset = 12_u64; + let mut format = None; + let mut vrat_rate_flag = None; + let mut data_chunk = None; + while offset < file_size { + let remaining = file_size - offset; + if remaining < 8 { + return qcp_error( + spec, + "QCP input is truncated before a complete chunk header", + ); + } + let mut chunk_header = [0_u8; 8]; + read_exact_at_sync( + file, + offset, + &mut chunk_header, + spec, + "truncated QCP chunk header", + )?; + let chunk_type = &chunk_header[..4]; + let chunk_size = u32::from_le_bytes([ + chunk_header[4], + chunk_header[5], + chunk_header[6], + chunk_header[7], + ]); + let chunk_data_offset = offset + 8; + let padded_size = u64::from(chunk_size) + u64::from(chunk_size & 1); + let chunk_end = chunk_data_offset + .checked_add(padded_size) + .ok_or(MuxError::LayoutOverflow("QCP chunk end"))?; + if chunk_end > file_size { + return qcp_error(spec, "QCP chunk payload extends past the end of the file"); + } + + match chunk_type { + chunk if chunk == FMT_CHUNK => { + if format.is_some() { + return qcp_error(spec, "QCP input carried more than one fmt chunk"); + } + let mut payload = vec![ + 0_u8; + usize::try_from(chunk_size).map_err(|_| { + MuxError::LayoutOverflow("QCP fmt chunk size") + })? + ]; + read_exact_at_sync( + file, + chunk_data_offset, + &mut payload, + spec, + "truncated QCP fmt chunk", + )?; + format = Some(parse_qcp_format_payload(&payload, spec)?); + } + chunk if chunk == VRAT_CHUNK => { + if vrat_rate_flag.is_some() { + return qcp_error(spec, "QCP input carried more than one vrat chunk"); + } + if chunk_size < u32::try_from(QCP_VRAT_MIN_SIZE).unwrap() { + return qcp_error(spec, "QCP vrat chunk was smaller than eight bytes"); + } + let mut payload = [0_u8; 8]; + read_exact_at_sync( + file, + chunk_data_offset, + &mut payload, + spec, + "truncated QCP vrat chunk", + )?; + vrat_rate_flag = Some(u32::from_le_bytes([ + payload[0], payload[1], payload[2], payload[3], + ])); + } + chunk if chunk == DATA_CHUNK => { + if data_chunk.is_some() { + return qcp_error(spec, "QCP input carried more than one data chunk"); + } + data_chunk = Some((chunk_data_offset, chunk_size)); + } + _ => {} + } + + offset = chunk_end; + } + + finalize_qcp_format(spec, format, vrat_rate_flag, data_chunk) +} + +#[cfg(feature = "async")] +async fn parse_qcp_chunks_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result { + let mut offset = 12_u64; + let mut format = None; + let mut vrat_rate_flag = None; + let mut data_chunk = None; + while offset < file_size { + let remaining = file_size - offset; + if remaining < 8 { + return qcp_error( + spec, + "QCP input is truncated before a complete chunk header", + ); + } + let mut chunk_header = [0_u8; 8]; + read_exact_at_async( + file, + offset, + &mut chunk_header, + spec, + "truncated QCP chunk header", + ) + .await?; + let chunk_type = &chunk_header[..4]; + let chunk_size = u32::from_le_bytes([ + chunk_header[4], + chunk_header[5], + chunk_header[6], + chunk_header[7], + ]); + let chunk_data_offset = offset + 8; + let padded_size = u64::from(chunk_size) + u64::from(chunk_size & 1); + let chunk_end = chunk_data_offset + .checked_add(padded_size) + .ok_or(MuxError::LayoutOverflow("QCP chunk end"))?; + if chunk_end > file_size { + return qcp_error(spec, "QCP chunk payload extends past the end of the file"); + } + + match chunk_type { + chunk if chunk == FMT_CHUNK => { + if format.is_some() { + return qcp_error(spec, "QCP input carried more than one fmt chunk"); + } + let mut payload = vec![ + 0_u8; + usize::try_from(chunk_size).map_err(|_| { + MuxError::LayoutOverflow("QCP fmt chunk size") + })? + ]; + read_exact_at_async( + file, + chunk_data_offset, + &mut payload, + spec, + "truncated QCP fmt chunk", + ) + .await?; + format = Some(parse_qcp_format_payload(&payload, spec)?); + } + chunk if chunk == VRAT_CHUNK => { + if vrat_rate_flag.is_some() { + return qcp_error(spec, "QCP input carried more than one vrat chunk"); + } + if chunk_size < u32::try_from(QCP_VRAT_MIN_SIZE).unwrap() { + return qcp_error(spec, "QCP vrat chunk was smaller than eight bytes"); + } + let mut payload = [0_u8; 8]; + read_exact_at_async( + file, + chunk_data_offset, + &mut payload, + spec, + "truncated QCP vrat chunk", + ) + .await?; + vrat_rate_flag = Some(u32::from_le_bytes([ + payload[0], payload[1], payload[2], payload[3], + ])); + } + chunk if chunk == DATA_CHUNK => { + if data_chunk.is_some() { + return qcp_error(spec, "QCP input carried more than one data chunk"); + } + data_chunk = Some((chunk_data_offset, chunk_size)); + } + _ => {} + } + + offset = chunk_end; + } + + finalize_qcp_format(spec, format, vrat_rate_flag, data_chunk) +} + +fn finalize_qcp_format( + spec: &str, + format: Option, + vrat_rate_flag: Option, + data_chunk: Option<(u64, u32)>, +) -> Result { + let mut format = format.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "QCP input did not carry a fmt chunk".to_string(), + })?; + let vrat_rate_flag = vrat_rate_flag.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "QCP input did not carry a vrat chunk".to_string(), + })?; + let (data_offset, data_size) = data_chunk.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "QCP input did not carry a data chunk".to_string(), + })?; + if data_size == 0 { + return qcp_error(spec, "QCP data chunk did not contain any codec packets"); + } + if vrat_rate_flag != 0 && format.rate_table_count == 0 { + return qcp_error( + spec, + "QCP input marked variable-rate packets but did not carry any usable rate-table entries", + ); + } + if vrat_rate_flag == 0 && format.packet_size == 0 { + return qcp_error( + spec, + "QCP input marked constant-rate packets but declared a zero packet size", + ); + } + format.vrat_rate_flag = vrat_rate_flag; + format.data_offset = data_offset; + format.data_size = data_size; + Ok(format) +} + +fn parse_qcp_format_payload(payload: &[u8], spec: &str) -> Result { + if payload.len() < QCP_FMT_MIN_SIZE { + return qcp_error( + spec, + "QCP fmt chunk was smaller than the required 150 bytes", + ); + } + let guid = + <[u8; 16]>::try_from(&payload[2..18]).map_err(|_| MuxError::LayoutOverflow("QCP GUID"))?; + let codec_kind = parse_qcp_codec_kind(guid, spec)?; + let packet_size = u32::from(u16::from_le_bytes([payload[102], payload[103]])); + let block_size = u32::from(u16::from_le_bytes([payload[104], payload[105]])); + let sample_rate = u32::from(u16::from_le_bytes([payload[106], payload[107]])); + if block_size == 0 { + return qcp_error( + spec, + "QCP fmt chunk declared a zero samples-per-packet block size", + ); + } + if sample_rate == 0 { + return qcp_error(spec, "QCP fmt chunk declared a zero sample rate"); + } + let rate_table_count = usize::try_from(u32::from_le_bytes([ + payload[110], + payload[111], + payload[112], + payload[113], + ])) + .map_err(|_| MuxError::LayoutOverflow("QCP rate-table count"))?; + if rate_table_count > QCP_RATE_TABLE_CAP { + return qcp_error( + spec, + "QCP fmt chunk declared more than eight rate-table entries", + ); + } + let mut rate_table = [QcpRateEntry::default(); QCP_RATE_TABLE_CAP]; + for (index, entry) in rate_table.iter_mut().enumerate() { + let offset = 114 + index * 2; + *entry = QcpRateEntry { + packet_size: payload[offset], + rate_index: payload[offset + 1], + }; + } + Ok(ParsedQcpFormat { + codec_kind, + sample_rate, + block_size, + packet_size, + vrat_rate_flag: 0, + rate_table_count, + rate_table, + data_offset: 0, + data_size: 0, + }) +} + +fn parse_qcp_codec_kind(guid: [u8; 16], spec: &str) -> Result { + match guid { + QCP_QCELP_GUID_1 | QCP_QCELP_GUID_2 => Ok(QcpCodecKind::Qcelp), + QCP_EVRC_GUID => Ok(QcpCodecKind::Evrc), + QCP_SMV_GUID => Ok(QcpCodecKind::Smv), + _ => qcp_error(spec, "QCP input carried an unsupported codec GUID"), + } +} + +fn parse_qcp_samples_sync( + file: &mut File, + format: ParsedQcpFormat, + spec: &str, +) -> Result, MuxError> { + let mut samples = Vec::new(); + let mut offset = format.data_offset; + let mut remaining = u64::from(format.data_size); + while remaining > 0 { + let packet_size = if format.vrat_rate_flag != 0 { + let mut rate_index = [0_u8; 1]; + read_exact_at_sync( + file, + offset, + &mut rate_index, + spec, + "truncated QCP variable-rate packet header", + )?; + resolve_qcp_variable_packet_size(format, rate_index[0], spec)? + } else { + format.packet_size + }; + if packet_size == 0 { + return qcp_error(spec, "QCP packet parser produced a zero packet size"); + } + if u64::from(packet_size) > remaining { + return qcp_error(spec, "QCP data chunk ended in the middle of a codec packet"); + } + samples.push(StagedSample { + data_offset: offset, + data_size: packet_size, + duration: format.block_size, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(u64::from(packet_size)) + .ok_or(MuxError::LayoutOverflow("QCP packet offset"))?; + remaining -= u64::from(packet_size); + } + if samples.is_empty() { + return qcp_error(spec, "QCP data chunk did not contain any codec packets"); + } + Ok(samples) +} + +#[cfg(feature = "async")] +async fn parse_qcp_samples_async( + file: &mut TokioFile, + format: ParsedQcpFormat, + spec: &str, +) -> Result, MuxError> { + let mut samples = Vec::new(); + let mut offset = format.data_offset; + let mut remaining = u64::from(format.data_size); + while remaining > 0 { + let packet_size = if format.vrat_rate_flag != 0 { + let mut rate_index = [0_u8; 1]; + read_exact_at_async( + file, + offset, + &mut rate_index, + spec, + "truncated QCP variable-rate packet header", + ) + .await?; + resolve_qcp_variable_packet_size(format, rate_index[0], spec)? + } else { + format.packet_size + }; + if packet_size == 0 { + return qcp_error(spec, "QCP packet parser produced a zero packet size"); + } + if u64::from(packet_size) > remaining { + return qcp_error(spec, "QCP data chunk ended in the middle of a codec packet"); + } + samples.push(StagedSample { + data_offset: offset, + data_size: packet_size, + duration: format.block_size, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(u64::from(packet_size)) + .ok_or(MuxError::LayoutOverflow("QCP packet offset"))?; + remaining -= u64::from(packet_size); + } + if samples.is_empty() { + return qcp_error(spec, "QCP data chunk did not contain any codec packets"); + } + Ok(samples) +} + +fn resolve_qcp_variable_packet_size( + format: ParsedQcpFormat, + rate_index: u8, + spec: &str, +) -> Result { + let payload_size = format.rate_table[..format.rate_table_count] + .iter() + .find(|entry| entry.rate_index == rate_index) + .map(|entry| u32::from(entry.packet_size)) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("QCP input used unknown variable-rate index {rate_index}"), + })?; + if payload_size == 0 { + return qcp_error( + spec, + "QCP input used a variable-rate index whose table entry declared a zero payload size", + ); + } + payload_size + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("QCP packet size")) +} + +fn validate_qcp_file_header_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result<(), MuxError> { + if file_size < 12 { + return qcp_error( + spec, + "QCP input is truncated before the RIFF or QLCM header", + ); + } + let mut header = [0_u8; 12]; + read_exact_at_sync(file, 0, &mut header, spec, "truncated QCP file header")?; + validate_qcp_file_header_bytes(&header, file_size, spec) +} + +#[cfg(feature = "async")] +async fn validate_qcp_file_header_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result<(), MuxError> { + if file_size < 12 { + return qcp_error( + spec, + "QCP input is truncated before the RIFF or QLCM header", + ); + } + let mut header = [0_u8; 12]; + read_exact_at_async(file, 0, &mut header, spec, "truncated QCP file header").await?; + validate_qcp_file_header_bytes(&header, file_size, spec) +} + +fn validate_qcp_file_header_bytes( + header: &[u8; 12], + file_size: u64, + spec: &str, +) -> Result<(), MuxError> { + if &header[..4] != RIFF_MAGIC || &header[8..12] != QLCM_MAGIC { + return qcp_error(spec, "QCP input did not start with a RIFF or QLCM header"); + } + let declared_riff_size = u64::from(u32::from_le_bytes([ + header[4], header[5], header[6], header[7], + ])); + if declared_riff_size + .checked_add(8) + .is_none_or(|total| total > file_size) + { + return qcp_error( + spec, + "QCP input declared a RIFF payload size larger than the file itself", + ); + } + Ok(()) +} + +fn build_qcp_sample_entry_box(format: ParsedQcpFormat) -> Result, MuxError> { + let config_box = match format.codec_kind { + QcpCodecKind::Qcelp => super::super::mp4::encode_typed_box( + &Dqcp { + vendor: 0, + decoder_version: 0, + frames_per_sample: 1, + }, + &[], + )?, + QcpCodecKind::Evrc => super::super::mp4::encode_typed_box( + &Devc { + vendor: 0, + decoder_version: 0, + frames_per_sample: 1, + }, + &[], + )?, + QcpCodecKind::Smv => super::super::mp4::encode_typed_box( + &Dsmv { + vendor: 0, + decoder_version: 0, + frames_per_sample: 1, + }, + &[], + )?, + }; + build_generic_audio_sample_entry_box( + format.codec_kind.sample_entry_type(), + format.sample_rate, + 1, + 16, + &[config_box], + ) +} + +fn qcp_error(spec: &str, message: impl Into) -> Result { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.into(), + }) +} diff --git a/src/mux/demux/raw_visual.rs b/src/mux/demux/raw_visual.rs new file mode 100644 index 0000000..ee39073 --- /dev/null +++ b/src/mux/demux/raw_visual.rs @@ -0,0 +1,541 @@ +use crate::FourCc; +use crate::boxes::iso14496_12::{Colr, Pasp, SampleEntry, VisualSampleEntry}; + +use super::super::MuxError; + +pub(super) const SAMPLE_ENTRY_UNCV: FourCc = FourCc::from_bytes(*b"uncv"); +const CMPD: FourCc = FourCc::from_bytes(*b"cmpd"); +const COLR_NCLC: FourCc = FourCc::from_bytes(*b"nclc"); +const COLR_NCLX: FourCc = FourCc::from_bytes(*b"nclx"); +const MJP2: FourCc = FourCc::from_bytes(*b"mjp2"); +const AUXI: FourCc = FourCc::from_bytes(*b"auxi"); +const UNCC: FourCc = FourCc::from_bytes(*b"uncC"); +const AUXILIARY_ALPHA_URN: &str = "urn:mpeg:mpegB:cicp:systems:auxiliary:alpha"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum UncvPixelLayout { + Grey8, + AlphaGrey8, + GreyAlpha8, + Rgb332, + Rgb444, + Rgb555, + Rgb565, + Rgb24, + Bgr24, + Rgbx32, + Bgrx32, + Xrgb32, + Xbgr32, + Argb32, + Rgba32, + Bgra32, + Abgr32, + Rgbd32, + Rgbds32, + Yuv420p8, + Yvu420p8, + Yuva420p8, + Yuvd420p8, + Yuv420p10, + Yuv422p8, + Yuv422p10, + Yuv444p8, + Yuv444p10, + Yuva444p8, + Nv12p8, + Nv21p8, + Nv12p10, + Nv21p10, + Uyvy422p8, + Vyuy422p8, + Yuyv422p8, + Yvyu422p8, + Uyvy422p10, + Vyuy422p10, + Yuyv422p10, + Yvyu422p10, + Yuv444Packed8, + Vyu444Packed8, + Yuva444Packed8, + Uyva444Packed8, + Yuv444Packed10, + V210, +} + +impl UncvPixelLayout { + fn cmpd_component_ids(self) -> &'static [u16] { + match self { + Self::Grey8 => &[0], + Self::AlphaGrey8 => &[7, 0], + Self::GreyAlpha8 => &[0, 7], + Self::Rgb332 | Self::Rgb444 | Self::Rgb555 | Self::Rgb565 | Self::Rgb24 => &[4, 5, 6], + Self::Rgbd32 => &[4, 5, 6, 8], + Self::Bgr24 => &[6, 5, 4], + Self::Rgbds32 => &[4, 5, 6, 8, 7], + Self::Rgbx32 => &[4, 5, 6, 12], + Self::Bgrx32 => &[6, 5, 4, 12], + Self::Xrgb32 => &[12, 4, 5, 6], + Self::Xbgr32 => &[12, 6, 5, 4], + Self::Argb32 => &[7, 4, 5, 6], + Self::Rgba32 => &[4, 5, 6, 7], + Self::Bgra32 => &[6, 5, 4, 7], + Self::Abgr32 => &[7, 6, 5, 4], + Self::Yuv420p8 + | Self::Yuv420p10 + | Self::Yuv422p8 + | Self::Yuv422p10 + | Self::Yuv444p8 + | Self::Yuv444p10 + | Self::Nv12p8 + | Self::Nv12p10 + | Self::Yuv444Packed8 => &[1, 2, 3], + Self::Yvu420p8 | Self::Nv21p8 | Self::Nv21p10 => &[1, 3, 2], + Self::Yuva420p8 | Self::Yuva444p8 | Self::Yuva444Packed8 => &[1, 2, 3, 7], + Self::Yuvd420p8 => &[1, 2, 3, 8], + Self::Uyvy422p8 | Self::Uyvy422p10 | Self::V210 => &[2, 1, 3, 1], + Self::Vyuy422p8 | Self::Vyuy422p10 => &[3, 1, 2, 1], + Self::Yuyv422p8 | Self::Yuyv422p10 => &[1, 2, 1, 3], + Self::Yvyu422p8 | Self::Yvyu422p10 => &[1, 3, 1, 2], + Self::Vyu444Packed8 => &[3, 1, 2], + Self::Uyva444Packed8 => &[2, 1, 3, 7], + Self::Yuv444Packed10 => &[2, 1, 3], + } + } + + fn component_bit_depths(self) -> &'static [u8] { + match self { + Self::Rgb332 => &[3, 3, 2], + Self::Rgb444 => &[4, 4, 4], + Self::Rgb555 => &[5, 5, 5], + Self::Rgb565 => &[5, 6, 5], + Self::Rgbds32 => &[8, 8, 8, 7, 1], + Self::Yuv420p10 | Self::Yuv422p10 | Self::Yuv444p10 | Self::Nv12p10 | Self::Nv21p10 => { + &[10, 10, 10] + } + Self::Uyvy422p10 | Self::Vyuy422p10 | Self::Yuyv422p10 | Self::Yvyu422p10 => { + &[10, 10, 10, 10] + } + Self::V210 => &[10, 10, 10, 8], + Self::Yuv444Packed10 => &[10, 10, 10], + Self::Grey8 => &[8], + Self::AlphaGrey8 | Self::GreyAlpha8 => &[8, 8], + Self::Rgb24 + | Self::Bgr24 + | Self::Yuv420p8 + | Self::Yvu420p8 + | Self::Yuv422p8 + | Self::Yuv444p8 + | Self::Nv12p8 + | Self::Nv21p8 + | Self::Yuv444Packed8 + | Self::Vyu444Packed8 => &[8, 8, 8], + Self::Rgbx32 + | Self::Bgrx32 + | Self::Xrgb32 + | Self::Xbgr32 + | Self::Argb32 + | Self::Rgba32 + | Self::Bgra32 + | Self::Abgr32 + | Self::Rgbd32 + | Self::Yuva420p8 + | Self::Yuvd420p8 + | Self::Yuva444p8 + | Self::Uyvy422p8 + | Self::Vyuy422p8 + | Self::Yuyv422p8 + | Self::Yvyu422p8 + | Self::Yuva444Packed8 + | Self::Uyva444Packed8 => &[8, 8, 8, 8], + } + } + + fn component_format(self) -> u8 { + match self { + Self::Yuv420p10 + | Self::Yuv422p10 + | Self::Yuv444p10 + | Self::Nv12p10 + | Self::Nv21p10 + | Self::Uyvy422p10 + | Self::Vyuy422p10 + | Self::Yuyv422p10 + | Self::Yvyu422p10 => 2, + _ => 0, + } + } + + fn sampling(self) -> u8 { + match self { + Self::Yuv420p8 + | Self::Yvu420p8 + | Self::Yuva420p8 + | Self::Yuvd420p8 + | Self::Yuv420p10 + | Self::Nv12p8 + | Self::Nv21p8 + | Self::Nv12p10 + | Self::Nv21p10 => 2, + Self::Yuv422p8 + | Self::Yuv422p10 + | Self::Uyvy422p8 + | Self::Vyuy422p8 + | Self::Yuyv422p8 + | Self::Yvyu422p8 + | Self::Uyvy422p10 + | Self::Vyuy422p10 + | Self::Yuyv422p10 + | Self::Yvyu422p10 + | Self::V210 => 1, + _ => 0, + } + } + + fn interleave(self) -> u8 { + match self { + Self::Yuv420p8 + | Self::Yvu420p8 + | Self::Yuva420p8 + | Self::Yuvd420p8 + | Self::Yuv420p10 + | Self::Yuv422p8 + | Self::Yuv422p10 + | Self::Yuv444p8 + | Self::Yuv444p10 + | Self::Yuva444p8 => 0, + Self::Nv12p8 | Self::Nv21p8 | Self::Nv12p10 | Self::Nv21p10 => 2, + Self::Uyvy422p8 + | Self::Vyuy422p8 + | Self::Yuyv422p8 + | Self::Yvyu422p8 + | Self::Uyvy422p10 + | Self::Vyuy422p10 + | Self::Yuyv422p10 + | Self::Yvyu422p10 => 5, + Self::Grey8 + | Self::AlphaGrey8 + | Self::GreyAlpha8 + | Self::Rgb332 + | Self::Rgb444 + | Self::Rgb555 + | Self::Rgb565 + | Self::Rgb24 + | Self::Bgr24 + | Self::Rgbx32 + | Self::Bgrx32 + | Self::Xrgb32 + | Self::Xbgr32 + | Self::Argb32 + | Self::Rgba32 + | Self::Bgra32 + | Self::Abgr32 + | Self::Rgbd32 + | Self::Rgbds32 + | Self::Yuv444Packed8 + | Self::Vyu444Packed8 + | Self::Yuva444Packed8 + | Self::Uyva444Packed8 + | Self::Yuv444Packed10 + | Self::V210 => 1, + } + } + + fn block_size(self) -> u8 { + match self { + Self::Yuv444Packed10 | Self::V210 => 4, + _ => 0, + } + } + + fn block_flags(self) -> u8 { + match self { + Self::Yuv444Packed10 => return 0x78, + Self::V210 => return 0x38, + _ => {} + } + let is_ten_bit = matches!( + self, + Self::Yuv420p10 + | Self::Yuv422p10 + | Self::Yuv444p10 + | Self::Nv12p10 + | Self::Nv21p10 + | Self::Uyvy422p10 + | Self::Vyuy422p10 + | Self::Yuyv422p10 + | Self::Yvyu422p10 + ); + let block_pad_lsb = false; + let block_little_endian = false; + let block_reversed = false; + ((u8::from(is_ten_bit)) << 7) + | ((u8::from(block_pad_lsb)) << 6) + | ((u8::from(block_little_endian)) << 5) + | ((u8::from(block_reversed)) << 4) + | 0x08 + } + + fn uncc_profile(self) -> Option { + match self { + Self::Rgb24 => Some(FourCc::from_bytes(*b"rgb3")), + Self::Abgr32 => Some(FourCc::from_bytes(*b"abgr")), + Self::Rgba32 => Some(FourCc::from_bytes(*b"rgba")), + Self::Yuv420p8 => Some(FourCc::from_bytes(*b"i420")), + Self::Nv12p8 => Some(FourCc::from_bytes(*b"nv12")), + Self::Nv21p8 => Some(FourCc::from_bytes(*b"nv21")), + Self::Uyvy422p8 | Self::Uyvy422p10 => Some(FourCc::from_bytes(*b"2vuy")), + Self::Vyuy422p8 | Self::Vyuy422p10 => Some(FourCc::from_bytes(*b"vyuy")), + Self::Yuyv422p8 | Self::Yuyv422p10 => Some(FourCc::from_bytes(*b"yuv2")), + Self::Yvyu422p8 | Self::Yvyu422p10 => Some(FourCc::from_bytes(*b"yvyu")), + Self::Yuv444p8 => Some(FourCc::from_bytes(*b"v308")), + Self::Vyu444Packed8 => Some(FourCc::from_bytes(*b"v308")), + Self::Uyva444Packed8 => Some(FourCc::from_bytes(*b"v408")), + Self::Yuv444Packed10 => Some(FourCc::from_bytes(*b"v410")), + Self::V210 => Some(FourCc::from_bytes(*b"v210")), + Self::Grey8 + | Self::AlphaGrey8 + | Self::GreyAlpha8 + | Self::Rgb332 + | Self::Rgb444 + | Self::Rgb555 + | Self::Rgb565 + | Self::Bgr24 + | Self::Rgbx32 + | Self::Bgrx32 + | Self::Xrgb32 + | Self::Xbgr32 + | Self::Argb32 + | Self::Bgra32 + | Self::Rgbd32 + | Self::Rgbds32 + | Self::Yvu420p8 + | Self::Yuva420p8 + | Self::Yuvd420p8 + | Self::Yuv420p10 + | Self::Yuv422p8 + | Self::Yuv422p10 + | Self::Yuv444p10 + | Self::Yuva444p8 + | Self::Nv12p10 + | Self::Nv21p10 + | Self::Yuv444Packed8 + | Self::Yuva444Packed8 => None, + } + } +} + +pub(super) fn build_uncv_sample_entry_box( + width: u16, + height: u16, + layout: UncvPixelLayout, + include_pasp: bool, + include_nclx_colr: bool, +) -> Result, MuxError> { + let mut child_boxes = vec![build_uncv_cmpd_box(layout)?, build_uncv_uncc_box(layout)?]; + if include_pasp { + child_boxes.push(build_pasp_box(1, 1)?); + } + if include_nclx_colr { + child_boxes.push(build_nclx_colr_box(1, 1, 1, false)?); + } + let mut compressorname = [0_u8; 32]; + compressorname[0] = 8; + compressorname[1..9].copy_from_slice(b"RawVideo"); + super::super::mp4::encode_typed_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: SAMPLE_ENTRY_UNCV, + data_reference_index: 1, + }, + pre_defined2: [0, 0, 0], + width, + height, + // The retained reference raw-video authoring writes literal 72 here, not 16.16 + // fixed-point. + horizresolution: 72, + vertresolution: 72, + frame_count: 1, + compressorname, + depth: 0x0018, + pre_defined3: -1, + ..VisualSampleEntry::default() + }, + &child_boxes.concat(), + ) +} + +pub(super) fn build_mjp2_sample_entry_box( + width: u16, + height: u16, + compressor_name: &[u8], + jp2h_payload: Option<&[u8]>, +) -> Result, MuxError> { + let jp2h_box = super::super::mp4::encode_raw_box( + FourCc::from_bytes(*b"jp2h"), + jp2h_payload.unwrap_or(&[]), + )?; + super::super::import::build_visual_sample_entry_box_with_compressor_name( + MJP2, + width, + height, + compressor_name, + &[jp2h_box], + ) +} + +pub(super) fn build_prores_sample_entry_box( + sample_entry_type: FourCc, + width: u16, + height: u16, + compressor_name: &[u8], + colour_primaries: u16, + transfer_characteristics: u16, + matrix_coefficients: u16, +) -> Result, MuxError> { + let mut compressorname = [0_u8; 32]; + let visible_len = compressor_name.len().min(31); + compressorname[0] = + u8::try_from(visible_len).map_err(|_| MuxError::LayoutOverflow("compressor name"))?; + compressorname[1..1 + visible_len].copy_from_slice(&compressor_name[..visible_len]); + let mut child_boxes = Vec::new(); + if sample_entry_type == FourCc::from_bytes(*b"ap4h") + || sample_entry_type == FourCc::from_bytes(*b"ap4x") + { + child_boxes.push(build_auxi_alpha_box()?); + } + child_boxes.push(build_pasp_box(1, 1)?); + child_boxes.push(build_nclc_colr_box( + colour_primaries, + transfer_characteristics, + matrix_coefficients, + )?); + super::super::mp4::encode_typed_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: sample_entry_type, + data_reference_index: 1, + }, + width, + height, + // The retained reference QuickTime-style ProRes authoring writes literal 72 here. + horizresolution: 72, + vertresolution: 72, + frame_count: 1, + compressorname, + depth: 0x0018, + pre_defined3: -1, + ..VisualSampleEntry::default() + }, + &child_boxes.concat(), + ) +} + +fn build_auxi_alpha_box() -> Result, MuxError> { + let mut payload = Vec::with_capacity(4 + AUXILIARY_ALPHA_URN.len() + 1); + payload.extend_from_slice(&0_u32.to_be_bytes()); + payload.extend_from_slice(AUXILIARY_ALPHA_URN.as_bytes()); + payload.push(0); + super::super::mp4::encode_raw_box(AUXI, &payload) +} + +fn build_uncv_cmpd_box(layout: UncvPixelLayout) -> Result, MuxError> { + let component_ids = layout.cmpd_component_ids(); + let mut payload = Vec::with_capacity(4 + component_ids.len() * 2); + payload.extend_from_slice( + &u32::try_from(component_ids.len()) + .map_err(|_| MuxError::LayoutOverflow("uncv component count"))? + .to_be_bytes(), + ); + for component_id in component_ids { + payload.extend_from_slice(&component_id.to_be_bytes()); + } + super::super::mp4::encode_raw_box(CMPD, &payload) +} + +fn build_uncv_uncc_box(layout: UncvPixelLayout) -> Result, MuxError> { + let component_ids = layout.cmpd_component_ids(); + let component_bits = layout.component_bit_depths(); + let mut payload = Vec::with_capacity(24 + component_ids.len() * 5 + 20); + payload.extend_from_slice(&0_u32.to_be_bytes()); + payload.extend_from_slice( + layout + .uncc_profile() + .unwrap_or(FourCc::from_bytes(*b"\0\0\0\0")) + .as_bytes(), + ); + payload.extend_from_slice( + &u32::try_from(component_ids.len()) + .map_err(|_| MuxError::LayoutOverflow("uncv component count"))? + .to_be_bytes(), + ); + for (component_index, component_bits) in component_bits.iter().enumerate() { + payload.extend_from_slice( + &u16::try_from(component_index) + .map_err(|_| MuxError::LayoutOverflow("uncv component index"))? + .to_be_bytes(), + ); + payload.push(component_bits.saturating_sub(1)); + payload.push(0); + payload.push(layout.component_format()); + } + payload.push(layout.sampling()); + payload.push(layout.interleave()); + payload.push(layout.block_size()); + payload.push(layout.block_flags()); + payload.extend_from_slice(&0_u32.to_be_bytes()); + payload.extend_from_slice(&0_u32.to_be_bytes()); + payload.extend_from_slice(&0_u32.to_be_bytes()); + payload.extend_from_slice(&0_u32.to_be_bytes()); + payload.extend_from_slice(&0_u32.to_be_bytes()); + super::super::mp4::encode_raw_box(UNCC, &payload) +} + +fn build_pasp_box(h_spacing: u32, v_spacing: u32) -> Result, MuxError> { + super::super::mp4::encode_typed_box( + &Pasp { + h_spacing, + v_spacing, + }, + &[], + ) +} + +fn build_nclx_colr_box( + colour_primaries: u16, + transfer_characteristics: u16, + matrix_coefficients: u16, + full_range_flag: bool, +) -> Result, MuxError> { + super::super::mp4::encode_typed_box( + &Colr { + colour_type: COLR_NCLX, + colour_primaries, + transfer_characteristics, + matrix_coefficients, + full_range_flag, + reserved: 0, + ..Colr::default() + }, + &[], + ) +} + +fn build_nclc_colr_box( + colour_primaries: u16, + transfer_characteristics: u16, + matrix_coefficients: u16, +) -> Result, MuxError> { + let mut unknown = Vec::with_capacity(6); + unknown.extend_from_slice(&colour_primaries.to_be_bytes()); + unknown.extend_from_slice(&transfer_characteristics.to_be_bytes()); + unknown.extend_from_slice(&matrix_coefficients.to_be_bytes()); + super::super::mp4::encode_typed_box( + &Colr { + colour_type: COLR_NCLC, + unknown, + ..Colr::default() + }, + &[], + ) +} diff --git a/src/mux/demux/rawvid.rs b/src/mux/demux/rawvid.rs new file mode 100644 index 0000000..8e2a850 --- /dev/null +++ b/src/mux/demux/rawvid.rs @@ -0,0 +1,703 @@ +use std::io::{BufRead, BufReader, Seek, SeekFrom}; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::{self, File}; +#[cfg(feature = "async")] +use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader as AsyncBufReader}; + +use super::super::import::StagedSample; +use super::super::{MuxError, MuxRawVideoParams, MuxRawVideoPixelFormat}; +use super::raw_visual::{UncvPixelLayout, build_uncv_sample_entry_box}; + +pub(in crate::mux) struct ParsedRawVideoTrack { + pub(in crate::mux) width: u16, + pub(in crate::mux) height: u16, + pub(in crate::mux) timescale: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +struct ParsedY4mHeader { + width: u16, + height: u16, + timescale: u32, + frame_duration: u32, + frame_size: u64, + sample_entry_box: Vec, +} + +pub(in crate::mux) fn scan_y4m_file_sync( + path: &Path, + spec: &str, +) -> Result { + let file_size = std::fs::metadata(path)?.len(); + let file = std::fs::File::open(path)?; + let mut reader = BufReader::new(file); + parse_y4m_reader_sync(spec, &mut reader, file_size) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_y4m_file_async( + path: &Path, + spec: &str, +) -> Result { + let file_size = fs::metadata(path).await?.len(); + let file = File::open(path).await?; + let mut reader = AsyncBufReader::new(file); + parse_y4m_reader_async(spec, &mut reader, file_size).await +} + +pub(in crate::mux) fn scan_raw_video_file_sync( + path: &Path, + spec: &str, + params: &MuxRawVideoParams, +) -> Result { + let file_size = std::fs::metadata(path)?.len(); + parse_raw_video_size(spec, file_size, params) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_raw_video_file_async( + path: &Path, + spec: &str, + params: &MuxRawVideoParams, +) -> Result { + let file_size = fs::metadata(path).await?.len(); + parse_raw_video_size(spec, file_size, params) +} + +fn parse_y4m_reader_sync( + spec: &str, + reader: &mut R, + file_size: u64, +) -> Result +where + R: BufRead + Seek, +{ + let mut header_bytes = Vec::new(); + if reader.read_until(b'\n', &mut header_bytes)? == 0 { + return Err(invalid_y4m( + spec, + "Y4M input did not terminate its stream header line", + )); + } + let header = + std::str::from_utf8(header_bytes.strip_suffix(b"\n").ok_or_else(|| { + invalid_y4m(spec, "Y4M input did not terminate its stream header line") + })?) + .map_err(|_| invalid_y4m(spec, "Y4M stream header is not valid UTF-8 text"))?; + let parsed_header = parse_y4m_stream_header(spec, header)?; + let mut physical_offset = u64::try_from(header_bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("Y4M header length"))?; + let mut retained_sample_offset = 0_u64; + let mut samples = Vec::new(); + while physical_offset < file_size { + let mut frame_header = Vec::new(); + if reader.read_until(b'\n', &mut frame_header)? == 0 { + break; + } + let frame_header = std::str::from_utf8( + frame_header + .strip_suffix(b"\n") + .ok_or_else(|| invalid_y4m(spec, "Y4M frame header is truncated"))?, + ) + .map_err(|_| invalid_y4m(spec, "Y4M frame header is not valid UTF-8 text"))?; + if !frame_header.starts_with("FRAME") { + return Err(invalid_y4m( + spec, + "Y4M payload did not begin its frame headers with the FRAME marker", + )); + } + physical_offset = physical_offset + .checked_add(u64::try_from(frame_header.len() + 1).unwrap()) + .ok_or(MuxError::LayoutOverflow("Y4M frame header range"))?; + if physical_offset + .checked_add(parsed_header.frame_size) + .is_none_or(|frame_end| frame_end > file_size) + { + return Err(invalid_y4m( + spec, + "Y4M frame payload overruns the input length", + )); + } + samples.push(StagedSample { + data_offset: retained_sample_offset, + data_size: u32::try_from(parsed_header.frame_size).map_err(|_| { + MuxError::LayoutOverflow("Y4M frame size exceeds MP4 sample limits") + })?, + duration: parsed_header.frame_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + retained_sample_offset = retained_sample_offset + .checked_add(parsed_header.frame_size) + .ok_or(MuxError::LayoutOverflow("Y4M retained sample offset"))?; + physical_offset = physical_offset + .checked_add(parsed_header.frame_size) + .ok_or(MuxError::LayoutOverflow("Y4M frame payload range"))?; + reader.seek(SeekFrom::Start(physical_offset))?; + } + finalize_y4m_track( + spec, + parsed_header.width, + parsed_header.height, + parsed_header.timescale, + parsed_header.sample_entry_box, + samples, + ) +} + +#[cfg(feature = "async")] +async fn parse_y4m_reader_async( + spec: &str, + reader: &mut R, + file_size: u64, +) -> Result +where + R: tokio::io::AsyncBufRead + tokio::io::AsyncSeek + Unpin, +{ + let mut header_bytes = Vec::new(); + if reader.read_until(b'\n', &mut header_bytes).await? == 0 { + return Err(invalid_y4m( + spec, + "Y4M input did not terminate its stream header line", + )); + } + let header = + std::str::from_utf8(header_bytes.strip_suffix(b"\n").ok_or_else(|| { + invalid_y4m(spec, "Y4M input did not terminate its stream header line") + })?) + .map_err(|_| invalid_y4m(spec, "Y4M stream header is not valid UTF-8 text"))?; + let parsed_header = parse_y4m_stream_header(spec, header)?; + let mut physical_offset = u64::try_from(header_bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("Y4M header length"))?; + let mut retained_sample_offset = 0_u64; + let mut samples = Vec::new(); + while physical_offset < file_size { + let mut frame_header = Vec::new(); + if reader.read_until(b'\n', &mut frame_header).await? == 0 { + break; + } + let frame_header = std::str::from_utf8( + frame_header + .strip_suffix(b"\n") + .ok_or_else(|| invalid_y4m(spec, "Y4M frame header is truncated"))?, + ) + .map_err(|_| invalid_y4m(spec, "Y4M frame header is not valid UTF-8 text"))?; + if !frame_header.starts_with("FRAME") { + return Err(invalid_y4m( + spec, + "Y4M payload did not begin its frame headers with the FRAME marker", + )); + } + physical_offset = physical_offset + .checked_add(u64::try_from(frame_header.len() + 1).unwrap()) + .ok_or(MuxError::LayoutOverflow("Y4M frame header range"))?; + if physical_offset + .checked_add(parsed_header.frame_size) + .is_none_or(|frame_end| frame_end > file_size) + { + return Err(invalid_y4m( + spec, + "Y4M frame payload overruns the input length", + )); + } + samples.push(StagedSample { + data_offset: retained_sample_offset, + data_size: u32::try_from(parsed_header.frame_size).map_err(|_| { + MuxError::LayoutOverflow("Y4M frame size exceeds MP4 sample limits") + })?, + duration: parsed_header.frame_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + retained_sample_offset = retained_sample_offset + .checked_add(parsed_header.frame_size) + .ok_or(MuxError::LayoutOverflow("Y4M retained sample offset"))?; + physical_offset = physical_offset + .checked_add(parsed_header.frame_size) + .ok_or(MuxError::LayoutOverflow("Y4M frame payload range"))?; + reader.seek(SeekFrom::Start(physical_offset)).await?; + } + finalize_y4m_track( + spec, + parsed_header.width, + parsed_header.height, + parsed_header.timescale, + parsed_header.sample_entry_box, + samples, + ) +} + +fn parse_y4m_stream_header(spec: &str, header: &str) -> Result { + if !header.starts_with("YUV4MPEG2 ") { + return Err(invalid_y4m( + spec, + "input does not start with the YUV4MPEG2 stream signature", + )); + } + + let mut width = None::; + let mut height = None::; + let mut fps_num = None::; + let mut fps_den = None::; + let mut layout = None::; + for token in header.split_ascii_whitespace().skip(1) { + if token.len() < 2 { + continue; + } + match token.as_bytes()[0] { + b'W' => { + width = Some(token[1..].parse::().map_err(|_| { + invalid_y4m(spec, "Y4M stream header carried an invalid width token") + })?); + } + b'H' => { + height = Some(token[1..].parse::().map_err(|_| { + invalid_y4m(spec, "Y4M stream header carried an invalid height token") + })?); + } + b'F' => { + let (num, den) = token[1..].split_once(':').ok_or_else(|| { + invalid_y4m( + spec, + "Y4M stream header carried an invalid frame-rate token", + ) + })?; + fps_num = Some(num.parse::().map_err(|_| { + invalid_y4m( + spec, + "Y4M stream header carried an invalid frame-rate numerator", + ) + })?); + fps_den = Some(den.parse::().map_err(|_| { + invalid_y4m( + spec, + "Y4M stream header carried an invalid frame-rate denominator", + ) + })?); + } + b'C' => { + layout = Some(match &token[1..] { + "420jpeg" | "420mpeg2" | "420paldv" | "420" => UncvPixelLayout::Yuv420p8, + "422" => UncvPixelLayout::Yuv422p8, + "444" => UncvPixelLayout::Yuv444p8, + "444alpha" => UncvPixelLayout::Yuva444p8, + "mono" => UncvPixelLayout::Grey8, + _ => { + return Err(invalid_y4m( + spec, + "Y4M stream header carried an unsupported chroma layout token", + )); + } + }); + } + _ => {} + } + } + + let width = + width.ok_or_else(|| invalid_y4m(spec, "Y4M stream header did not declare width"))?; + let height = + height.ok_or_else(|| invalid_y4m(spec, "Y4M stream header did not declare height"))?; + if width == 0 || height == 0 { + return Err(invalid_y4m( + spec, + "Y4M stream header declared zero width or zero height", + )); + } + let fps_num = + fps_num.ok_or_else(|| invalid_y4m(spec, "Y4M stream header did not declare frame rate"))?; + let fps_den = fps_den.ok_or_else(|| { + invalid_y4m( + spec, + "Y4M stream header did not declare a complete frame-rate denominator", + ) + })?; + if fps_num == 0 || fps_den == 0 { + return Err(invalid_y4m( + spec, + "Y4M stream header declared a zero frame-rate numerator or denominator", + )); + } + let layout = layout.unwrap_or(UncvPixelLayout::Yuv420p8); + validate_y4m_dimensions(spec, width, height, layout)?; + let frame_size = y4m_frame_size(width, height, layout)?; + let width_u16 = u16::try_from(width) + .map_err(|_| invalid_y4m(spec, "Y4M width does not fit in an MP4 visual sample entry"))?; + let height_u16 = u16::try_from(height).map_err(|_| { + invalid_y4m( + spec, + "Y4M height does not fit in an MP4 visual sample entry", + ) + })?; + + let sample_entry_box = build_uncv_sample_entry_box( + width_u16, + height_u16, + layout, + true, + matches!(layout, UncvPixelLayout::Yuv420p8), + )?; + Ok(ParsedY4mHeader { + width: width_u16, + height: height_u16, + timescale: fps_num, + frame_duration: fps_den, + frame_size, + sample_entry_box, + }) +} + +fn finalize_y4m_track( + spec: &str, + width: u16, + height: u16, + timescale: u32, + sample_entry_box: Vec, + samples: Vec, +) -> Result { + if samples.is_empty() { + return Err(invalid_y4m( + spec, + "Y4M input did not carry any FRAME payloads", + )); + } + Ok(ParsedRawVideoTrack { + width, + height, + timescale, + sample_entry_box, + samples, + }) +} + +fn parse_raw_video_size( + spec: &str, + file_size: u64, + params: &MuxRawVideoParams, +) -> Result { + let layout = layout_from_raw_video_pixel_format(params.pixel_format()); + validate_raw_video_dimensions(spec, params.width(), params.height(), layout)?; + let frame_size = y4m_frame_size(params.width(), params.height(), layout)?; + let frame_size_u32 = u32::try_from(frame_size) + .map_err(|_| MuxError::LayoutOverflow("rawvideo frame size exceeds MP4 sample limits"))?; + let width_u16 = u16::try_from(params.width()).map_err(|_| { + invalid_raw_video( + spec, + "rawvideo width does not fit in an MP4 visual sample entry", + ) + })?; + let height_u16 = u16::try_from(params.height()).map_err(|_| { + invalid_raw_video( + spec, + "rawvideo height does not fit in an MP4 visual sample entry", + ) + })?; + + if file_size == 0 { + return Err(invalid_raw_video( + spec, + "rawvideo input did not carry any frame payload bytes", + )); + } + if !file_size.is_multiple_of(frame_size) { + return Err(invalid_raw_video( + spec, + "rawvideo input length is not an exact multiple of the declared frame size", + )); + } + let frame_count = file_size / frame_size; + let sample_count = usize::try_from(frame_count) + .map_err(|_| MuxError::LayoutOverflow("rawvideo sample count"))?; + let mut samples = Vec::with_capacity(sample_count); + let mut offset = 0_u64; + for _ in 0..sample_count { + samples.push(StagedSample { + data_offset: offset, + data_size: frame_size_u32, + duration: params.fps_den(), + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(frame_size) + .ok_or(MuxError::LayoutOverflow("rawvideo sample offset"))?; + } + + let include_qt_raw_yuv_defaults = rawvideo_uses_qt_yuv_defaults(layout); + let sample_entry_box = build_uncv_sample_entry_box( + width_u16, + height_u16, + layout, + include_qt_raw_yuv_defaults, + include_qt_raw_yuv_defaults, + )?; + Ok(ParsedRawVideoTrack { + width: width_u16, + height: height_u16, + timescale: params.fps_num(), + sample_entry_box, + samples, + }) +} + +fn rawvideo_uses_qt_yuv_defaults(layout: UncvPixelLayout) -> bool { + matches!( + layout, + UncvPixelLayout::Yuv420p8 + | UncvPixelLayout::Uyvy422p8 + | UncvPixelLayout::Yuyv422p8 + | UncvPixelLayout::Yvyu422p8 + | UncvPixelLayout::Uyvy422p10 + | UncvPixelLayout::Vyu444Packed8 + | UncvPixelLayout::Uyva444Packed8 + | UncvPixelLayout::Yuv444Packed10 + | UncvPixelLayout::V210 + ) +} + +fn layout_from_raw_video_pixel_format(pixel_format: MuxRawVideoPixelFormat) -> UncvPixelLayout { + match pixel_format { + MuxRawVideoPixelFormat::Yuv420p8 => UncvPixelLayout::Yuv420p8, + MuxRawVideoPixelFormat::Yvu420p8 => UncvPixelLayout::Yvu420p8, + MuxRawVideoPixelFormat::Yuv420p10 => UncvPixelLayout::Yuv420p10, + MuxRawVideoPixelFormat::Yuv422p8 => UncvPixelLayout::Yuv422p8, + MuxRawVideoPixelFormat::Yuv422p10 => UncvPixelLayout::Yuv422p10, + MuxRawVideoPixelFormat::Yuv444p8 => UncvPixelLayout::Yuv444p8, + MuxRawVideoPixelFormat::Yuv444p10 => UncvPixelLayout::Yuv444p10, + MuxRawVideoPixelFormat::Yuva420p8 => UncvPixelLayout::Yuva420p8, + MuxRawVideoPixelFormat::Yuvd420p8 => UncvPixelLayout::Yuvd420p8, + MuxRawVideoPixelFormat::Yuva444p8 => UncvPixelLayout::Yuva444p8, + MuxRawVideoPixelFormat::Nv12p8 => UncvPixelLayout::Nv12p8, + MuxRawVideoPixelFormat::Nv21p8 => UncvPixelLayout::Nv21p8, + MuxRawVideoPixelFormat::Nv12p10 => UncvPixelLayout::Nv12p10, + MuxRawVideoPixelFormat::Nv21p10 => UncvPixelLayout::Nv21p10, + MuxRawVideoPixelFormat::Uyvy422p8 => UncvPixelLayout::Uyvy422p8, + MuxRawVideoPixelFormat::Vyuy422p8 => UncvPixelLayout::Vyuy422p8, + MuxRawVideoPixelFormat::Yuyv422p8 => UncvPixelLayout::Yuyv422p8, + MuxRawVideoPixelFormat::Yvyu422p8 => UncvPixelLayout::Yvyu422p8, + MuxRawVideoPixelFormat::Uyvy422p10 => UncvPixelLayout::Uyvy422p10, + MuxRawVideoPixelFormat::Vyuy422p10 => UncvPixelLayout::Vyuy422p10, + MuxRawVideoPixelFormat::Yuyv422p10 => UncvPixelLayout::Yuyv422p10, + MuxRawVideoPixelFormat::Yvyu422p10 => UncvPixelLayout::Yvyu422p10, + MuxRawVideoPixelFormat::Yuv444Packed8 => UncvPixelLayout::Yuv444Packed8, + MuxRawVideoPixelFormat::Vyu444Packed8 => UncvPixelLayout::Vyu444Packed8, + MuxRawVideoPixelFormat::Yuva444Packed8 => UncvPixelLayout::Yuva444Packed8, + MuxRawVideoPixelFormat::Uyva444Packed8 => UncvPixelLayout::Uyva444Packed8, + MuxRawVideoPixelFormat::Yuv444Packed10 => UncvPixelLayout::Yuv444Packed10, + MuxRawVideoPixelFormat::V210 => UncvPixelLayout::V210, + MuxRawVideoPixelFormat::Grey8 => UncvPixelLayout::Grey8, + MuxRawVideoPixelFormat::AlphaGrey8 => UncvPixelLayout::AlphaGrey8, + MuxRawVideoPixelFormat::GreyAlpha8 => UncvPixelLayout::GreyAlpha8, + MuxRawVideoPixelFormat::Rgb332 => UncvPixelLayout::Rgb332, + MuxRawVideoPixelFormat::Rgb444 => UncvPixelLayout::Rgb444, + MuxRawVideoPixelFormat::Rgb555 => UncvPixelLayout::Rgb555, + MuxRawVideoPixelFormat::Rgb565 => UncvPixelLayout::Rgb565, + MuxRawVideoPixelFormat::Rgb24 => UncvPixelLayout::Rgb24, + MuxRawVideoPixelFormat::Bgr24 => UncvPixelLayout::Bgr24, + MuxRawVideoPixelFormat::Rgbx32 => UncvPixelLayout::Rgbx32, + MuxRawVideoPixelFormat::Bgrx32 => UncvPixelLayout::Bgrx32, + MuxRawVideoPixelFormat::Xrgb32 => UncvPixelLayout::Xrgb32, + MuxRawVideoPixelFormat::Xbgr32 => UncvPixelLayout::Xbgr32, + MuxRawVideoPixelFormat::Argb32 => UncvPixelLayout::Argb32, + MuxRawVideoPixelFormat::Rgba32 => UncvPixelLayout::Rgba32, + MuxRawVideoPixelFormat::Bgra32 => UncvPixelLayout::Bgra32, + MuxRawVideoPixelFormat::Abgr32 => UncvPixelLayout::Abgr32, + MuxRawVideoPixelFormat::Rgbd32 => UncvPixelLayout::Rgbd32, + MuxRawVideoPixelFormat::Rgbds32 => UncvPixelLayout::Rgbds32, + } +} + +fn validate_y4m_dimensions( + spec: &str, + width: u32, + height: u32, + layout: UncvPixelLayout, +) -> Result<(), MuxError> { + match layout { + UncvPixelLayout::Yuv420p8 if !width.is_multiple_of(2) || !height.is_multiple_of(2) => { + return Err(invalid_y4m( + spec, + "Y4M 4:2:0 carriage requires even width and even height", + )); + } + UncvPixelLayout::Yuv422p8 if !width.is_multiple_of(2) => { + return Err(invalid_y4m( + spec, + "Y4M 4:2:2 carriage requires an even width", + )); + } + _ => {} + } + Ok(()) +} + +fn validate_raw_video_dimensions( + _spec: &str, + _width: u32, + _height: u32, + _layout: UncvPixelLayout, +) -> Result<(), MuxError> { + Ok(()) +} + +fn y4m_frame_size(width: u32, height: u32, layout: UncvPixelLayout) -> Result { + let width = u64::from(width); + let height = u64::from(height); + let luma = checked_mul(width, height, "rawvideo luma plane size")?; + match layout { + UncvPixelLayout::Grey8 | UncvPixelLayout::Rgb332 => Ok(luma), + UncvPixelLayout::AlphaGrey8 + | UncvPixelLayout::GreyAlpha8 + | UncvPixelLayout::Rgb444 + | UncvPixelLayout::Rgb555 + | UncvPixelLayout::Rgb565 + | UncvPixelLayout::Uyvy422p8 + | UncvPixelLayout::Vyuy422p8 + | UncvPixelLayout::Yuyv422p8 + | UncvPixelLayout::Yvyu422p8 => checked_mul(luma, 2, "rawvideo frame size"), + UncvPixelLayout::Rgb24 + | UncvPixelLayout::Bgr24 + | UncvPixelLayout::Yuv444p8 + | UncvPixelLayout::Vyu444Packed8 + | UncvPixelLayout::Yuv444Packed8 => checked_mul(luma, 3, "rawvideo frame size"), + UncvPixelLayout::Rgbx32 + | UncvPixelLayout::Bgrx32 + | UncvPixelLayout::Xrgb32 + | UncvPixelLayout::Xbgr32 + | UncvPixelLayout::Argb32 + | UncvPixelLayout::Rgba32 + | UncvPixelLayout::Bgra32 + | UncvPixelLayout::Abgr32 + | UncvPixelLayout::Rgbd32 + | UncvPixelLayout::Rgbds32 + | UncvPixelLayout::Yuva444p8 + | UncvPixelLayout::Yuva444Packed8 + | UncvPixelLayout::Uyva444Packed8 + | UncvPixelLayout::Yuv444Packed10 + | UncvPixelLayout::Uyvy422p10 + | UncvPixelLayout::Vyuy422p10 + | UncvPixelLayout::Yuyv422p10 + | UncvPixelLayout::Yvyu422p10 => checked_mul(luma, 4, "rawvideo frame size"), + UncvPixelLayout::Yuv420p8 | UncvPixelLayout::Yvu420p8 => { + let uv_height = height.div_ceil(2); + let stride_uv = width.div_ceil(2); + checked_add( + luma, + checked_mul( + checked_mul(stride_uv, uv_height, "rawvideo chroma plane size")?, + 2, + "rawvideo 4:2:0 chroma size", + )?, + "rawvideo frame size", + ) + } + UncvPixelLayout::Yuva420p8 | UncvPixelLayout::Yuvd420p8 => { + let uv_height = height.div_ceil(2); + let stride_uv = width.div_ceil(2); + checked_add( + checked_mul(luma, 2, "rawvideo 4:2:0 alpha or depth luma size")?, + checked_mul( + checked_mul(stride_uv, uv_height, "rawvideo chroma plane size")?, + 2, + "rawvideo 4:2:0 chroma size", + )?, + "rawvideo frame size", + ) + } + UncvPixelLayout::Yuv420p10 => { + let stride = checked_mul(width, 2, "rawvideo 10-bit luma stride")?; + let uv_height = height.div_ceil(2); + let stride_uv = stride.div_ceil(2); + checked_add( + checked_mul(stride, height, "rawvideo 10-bit luma size")?, + checked_mul( + checked_mul(stride_uv, uv_height, "rawvideo 10-bit chroma plane size")?, + 2, + "rawvideo 10-bit chroma size", + )?, + "rawvideo frame size", + ) + } + UncvPixelLayout::Yuv422p8 => { + let stride_uv = width.div_ceil(2); + checked_add( + luma, + checked_mul( + checked_mul(stride_uv, height, "rawvideo 4:2:2 chroma plane size")?, + 2, + "rawvideo 4:2:2 chroma size", + )?, + "rawvideo frame size", + ) + } + UncvPixelLayout::Yuv422p10 => { + let stride = checked_mul(width, 2, "rawvideo 10-bit 4:2:2 luma stride")?; + let stride_uv = stride.div_ceil(2); + checked_add( + checked_mul(stride, height, "rawvideo 10-bit 4:2:2 luma size")?, + checked_mul( + checked_mul(stride_uv, height, "rawvideo 10-bit 4:2:2 chroma plane size")?, + 2, + "rawvideo 10-bit 4:2:2 chroma size", + )?, + "rawvideo frame size", + ) + } + UncvPixelLayout::Yuv444p10 => checked_mul( + checked_mul(width, 2, "rawvideo 10-bit 4:4:4 stride")?, + checked_mul(height, 3, "rawvideo 10-bit 4:4:4 plane factor")?, + "rawvideo frame size", + ), + UncvPixelLayout::Nv12p8 | UncvPixelLayout::Nv21p8 => checked_mul( + checked_mul(width, height, "rawvideo NV luma size")?, + 3, + "rawvideo NV size numerator", + ) + .map(|size| size / 2), + UncvPixelLayout::Nv12p10 | UncvPixelLayout::Nv21p10 => checked_mul( + checked_mul( + checked_mul(width, 2, "rawvideo 10-bit NV stride")?, + height, + "rawvideo 10-bit NV luma size", + )?, + 3, + "rawvideo 10-bit NV size numerator", + ) + .map(|size| size / 2), + UncvPixelLayout::V210 => { + let mut padded_width = width; + while !padded_width.is_multiple_of(48) { + padded_width = padded_width + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("rawvideo v210 padded width"))?; + } + let stride = checked_mul(padded_width, 16, "rawvideo v210 stride numerator")? / 6; + checked_mul(stride, height, "rawvideo v210 frame size") + } + } +} + +fn checked_mul(lhs: u64, rhs: u64, label: &'static str) -> Result { + lhs.checked_mul(rhs).ok_or(MuxError::LayoutOverflow(label)) +} + +fn checked_add(lhs: u64, rhs: u64, label: &'static str) -> Result { + lhs.checked_add(rhs).ok_or(MuxError::LayoutOverflow(label)) +} + +fn invalid_y4m(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} + +fn invalid_raw_video(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} diff --git a/src/mux/demux/saf.rs b/src/mux/demux/saf.rs new file mode 100644 index 0000000..3b04524 --- /dev/null +++ b/src/mux/demux/saf.rs @@ -0,0 +1,1065 @@ +//! SAF direct-ingest parsing on the current path-first mux lane. +//! +//! The current importer supports local SAF files that carry one declared audio, visual, or +//! scene-description stream whose payload AUs can be mapped truthfully onto authored MP4 sample +//! entries. Remote SAF URL declarations and custom MIME-style declarations stay outside the +//! current path-only contract. + +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +use crate::FourCc; +use crate::bitio::BitReader; +use crate::boxes::iso14496_14::{ + DECODER_CONFIG_DESCRIPTOR_TAG, DECODER_SPECIFIC_INFO_TAG, DecoderConfigDescriptor, Descriptor, + ES_DESCRIPTOR_TAG, EsDescriptor, Esds, SL_CONFIG_DESCRIPTOR_TAG, +}; + +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{ + CandidateSample, TrackCandidate, build_generic_audio_sample_entry_box, + build_generic_media_sample_entry_box, direct_ingest_handler_name, direct_ingest_mux_policy, + read_exact_at_sync, +}; +use super::super::{MuxError, MuxTrackKind}; +use super::jpeg::parse_jpeg_bytes; +use super::mp4v::{build_direct_mp4v_sample_entry_box, parse_mp4v_decoder_specific_info}; +use super::png::parse_png_bytes; + +const SAF_STREAM_TYPE_SCENE: u8 = 0x03; +const SAF_STREAM_TYPE_VISUAL: u8 = 0x04; +const SAF_STREAM_TYPE_AUDIO: u8 = 0x05; + +const SAF_OBJECT_TYPE_AAC: u8 = 0x40; +const SAF_OBJECT_TYPE_MP4V: u8 = 0x20; +const SAF_OBJECT_TYPE_JPEG: u8 = 0x6C; +const SAF_OBJECT_TYPE_PNG: u8 = 0x6D; +const SAF_OBJECT_TYPE_CUSTOM: u8 = 0xFF; +const SCENE_OBJECT_TYPES: [u8; 5] = [0x01, 0x02, 0x04, 0x09, 0x0A]; + +#[derive(Clone, Copy)] +struct PendingSafSample { + data_offset: u64, + data_size: u32, + cts: u32, + is_sync_sample: bool, +} + +enum SafTrackTemplate { + Aac { + sample_rate: u32, + channel_configuration: u16, + audio_specific_config: Vec, + }, + Mp4v { + width: u16, + height: u16, + decoder_specific_info: Vec, + }, + Jpeg, + Png, + Scene { + object_type_indication: u8, + decoder_specific_info: Vec, + }, +} + +struct DeclaredSafTrack { + stream_id: u16, + kind: MuxTrackKind, + handler_name: String, + codec_label: &'static str, + timescale: u32, + template: SafTrackTemplate, + samples: Vec, +} + +/// Scans one local SAF source synchronously and returns direct-ingest track candidates whose +/// samples keep pointing at the original file. +pub(in crate::mux) fn scan_saf_source_sync( + path: &Path, + spec: &str, + source_index: usize, +) -> Result, MuxError> { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let mut declared = scan_declared_saf_tracks_sync(&mut file, file_size, spec)?; + finalize_declared_saf_tracks_sync(&mut file, spec, source_index, &mut declared) +} + +/// Async companion to [`scan_saf_source_sync`] on the additive Tokio surface. +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_saf_source_async( + path: &Path, + spec: &str, + source_index: usize, +) -> Result, MuxError> { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let mut declared = scan_declared_saf_tracks_async(&mut file, file_size, spec).await?; + finalize_declared_saf_tracks_async(&mut file, spec, source_index, &mut declared).await +} + +fn scan_declared_saf_tracks_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result, MuxError> { + let mut declared = Vec::::new(); + let mut offset = 0_u64; + while offset < file_size { + if file_size - offset < 10 { + return Err(invalid_saf( + spec, + "SAF input is truncated before one complete AU header", + )); + } + let header = read_saf_au_header_sync(file, &mut offset, spec)?; + match header.au_type { + 1 | 2 | 7 => { + if declared + .iter() + .any(|track| track.stream_id == header.stream_id) + { + skip_sync(file, &mut offset, u64::from(header.payload_size))?; + continue; + } + declared.push(read_saf_declaration_sync(file, &mut offset, spec, header)?); + } + 4 => { + let payload_offset = offset; + let Some(track) = declared + .iter_mut() + .find(|track| track.stream_id == header.stream_id) + else { + return Err(invalid_saf( + spec, + &format!( + "SAF stream {} carried media data before any supported declaration AU", + header.stream_id + ), + )); + }; + track.samples.push(PendingSafSample { + data_offset: payload_offset, + data_size: header.payload_size, + cts: header.cts, + is_sync_sample: header.is_rap, + }); + skip_sync(file, &mut offset, u64::from(header.payload_size))?; + } + _ => { + skip_sync(file, &mut offset, u64::from(header.payload_size))?; + } + } + } + Ok(declared) +} + +#[cfg(feature = "async")] +async fn scan_declared_saf_tracks_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result, MuxError> { + let mut declared = Vec::::new(); + let mut offset = 0_u64; + while offset < file_size { + if file_size - offset < 10 { + return Err(invalid_saf( + spec, + "SAF input is truncated before one complete AU header", + )); + } + let header = read_saf_au_header_async(file, &mut offset, spec).await?; + match header.au_type { + 1 | 2 | 7 => { + if declared + .iter() + .any(|track| track.stream_id == header.stream_id) + { + skip_async(file, &mut offset, u64::from(header.payload_size)).await?; + continue; + } + declared.push(read_saf_declaration_async(file, &mut offset, spec, header).await?); + } + 4 => { + let payload_offset = offset; + let Some(track) = declared + .iter_mut() + .find(|track| track.stream_id == header.stream_id) + else { + return Err(invalid_saf( + spec, + &format!( + "SAF stream {} carried media data before any supported declaration AU", + header.stream_id + ), + )); + }; + track.samples.push(PendingSafSample { + data_offset: payload_offset, + data_size: header.payload_size, + cts: header.cts, + is_sync_sample: header.is_rap, + }); + skip_async(file, &mut offset, u64::from(header.payload_size)).await?; + } + _ => { + skip_async(file, &mut offset, u64::from(header.payload_size)).await?; + } + } + } + Ok(declared) +} + +fn finalize_declared_saf_tracks_sync( + file: &mut File, + spec: &str, + source_index: usize, + declared: &mut [DeclaredSafTrack], +) -> Result, MuxError> { + let mut tracks = Vec::new(); + for track in declared + .iter_mut() + .filter(|track| !track.samples.is_empty()) + { + tracks.push(finalize_declared_saf_track_sync( + file, + spec, + source_index, + track, + )?); + } + if tracks.is_empty() { + return Err(invalid_saf( + spec, + "SAF input did not expose any declared stream carrying media AUs", + )); + } + Ok(tracks) +} + +#[cfg(feature = "async")] +async fn finalize_declared_saf_tracks_async( + file: &mut TokioFile, + spec: &str, + source_index: usize, + declared: &mut [DeclaredSafTrack], +) -> Result, MuxError> { + let mut tracks = Vec::new(); + for track in declared + .iter_mut() + .filter(|track| !track.samples.is_empty()) + { + tracks.push(finalize_declared_saf_track_async(file, spec, source_index, track).await?); + } + if tracks.is_empty() { + return Err(invalid_saf( + spec, + "SAF input did not expose any declared stream carrying media AUs", + )); + } + Ok(tracks) +} + +fn finalize_declared_saf_track_sync( + file: &mut File, + spec: &str, + source_index: usize, + track: &DeclaredSafTrack, +) -> Result { + let samples: Vec = candidate_samples_from_pending(spec, &track.samples)? + .into_iter() + .map(|mut sample| { + sample.source_index = source_index; + sample + }) + .collect(); + let (width, height, sample_entry_box) = match &track.template { + SafTrackTemplate::Aac { + sample_rate, + channel_configuration, + audio_specific_config, + } => ( + 0, + 0, + build_saf_aac_sample_entry_box( + audio_specific_config, + *sample_rate, + *channel_configuration, + )?, + ), + SafTrackTemplate::Mp4v { + width, + height, + decoder_specific_info, + } => ( + *width, + *height, + build_direct_mp4v_sample_entry_box( + *width, + *height, + decoder_specific_info, + track.timescale, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?, + ), + SafTrackTemplate::Jpeg => { + let payload = read_sample_payload_sync(file, spec, track.samples[0])?; + let parsed = parse_jpeg_bytes(spec, &payload)?; + (parsed.width, parsed.height, parsed.sample_entry_box) + } + SafTrackTemplate::Png => { + let payload = read_sample_payload_sync(file, spec, track.samples[0])?; + let parsed = parse_png_bytes(spec, &payload)?; + (parsed.width, parsed.height, parsed.sample_entry_box) + } + SafTrackTemplate::Scene { + object_type_indication, + decoder_specific_info, + } => ( + 0, + 0, + build_saf_generic_media_entry_box( + FourCc::from_bytes(*b"mp4s"), + *object_type_indication, + SAF_STREAM_TYPE_SCENE, + decoder_specific_info, + )?, + ), + }; + + Ok(TrackCandidate { + track_id: u32::from(track.stream_id), + kind: track.kind, + timescale: track.timescale, + language: *b"und", + handler_name: track.handler_name.clone(), + mux_policy: direct_ingest_mux_policy(track.codec_label, track.kind), + width, + height, + sample_entry_box, + source_edit_media_time: None, + samples, + }) +} + +#[cfg(feature = "async")] +async fn finalize_declared_saf_track_async( + file: &mut TokioFile, + spec: &str, + source_index: usize, + track: &DeclaredSafTrack, +) -> Result { + let samples = candidate_samples_from_pending(spec, &track.samples)?; + let (width, height, sample_entry_box) = match &track.template { + SafTrackTemplate::Aac { + sample_rate, + channel_configuration, + audio_specific_config, + } => ( + 0, + 0, + build_saf_aac_sample_entry_box( + audio_specific_config, + *sample_rate, + *channel_configuration, + )?, + ), + SafTrackTemplate::Mp4v { + width, + height, + decoder_specific_info, + } => ( + *width, + *height, + build_direct_mp4v_sample_entry_box( + *width, + *height, + decoder_specific_info, + track.timescale, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?, + ), + SafTrackTemplate::Jpeg => { + let payload = read_sample_payload_async(file, spec, track.samples[0]).await?; + let parsed = parse_jpeg_bytes(spec, &payload)?; + (parsed.width, parsed.height, parsed.sample_entry_box) + } + SafTrackTemplate::Png => { + let payload = read_sample_payload_async(file, spec, track.samples[0]).await?; + let parsed = parse_png_bytes(spec, &payload)?; + (parsed.width, parsed.height, parsed.sample_entry_box) + } + SafTrackTemplate::Scene { + object_type_indication, + decoder_specific_info, + } => ( + 0, + 0, + build_saf_generic_media_entry_box( + FourCc::from_bytes(*b"mp4s"), + *object_type_indication, + SAF_STREAM_TYPE_SCENE, + decoder_specific_info, + )?, + ), + }; + + Ok(TrackCandidate { + track_id: u32::from(track.stream_id), + kind: track.kind, + timescale: track.timescale, + language: *b"und", + handler_name: track.handler_name.clone(), + mux_policy: direct_ingest_mux_policy(track.codec_label, track.kind), + width, + height, + sample_entry_box, + source_edit_media_time: None, + samples: samples + .into_iter() + .map(|mut sample| { + sample.source_index = source_index; + sample + }) + .collect(), + }) +} + +fn candidate_samples_from_pending( + spec: &str, + samples: &[PendingSafSample], +) -> Result, MuxError> { + let mut result = Vec::with_capacity(samples.len()); + let mut last_delta = None::; + for (index, sample) in samples.iter().enumerate() { + let duration = if let Some(next) = samples.get(index + 1) { + if next.cts < sample.cts { + return Err(invalid_saf( + spec, + "SAF stream carried a decode-time regression between successive AUs", + )); + } + let delta = next.cts - sample.cts; + last_delta = Some(delta); + delta + } else { + last_delta.unwrap_or(1) + }; + result.push(CandidateSample { + source_index: 0, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration, + composition_time_offset: 0, + is_sync_sample: sample.is_sync_sample, + }); + } + Ok(result) +} + +fn read_sample_payload_sync( + file: &mut File, + spec: &str, + sample: PendingSafSample, +) -> Result, MuxError> { + let mut payload = vec![ + 0_u8; + usize::try_from(sample.data_size) + .map_err(|_| MuxError::LayoutOverflow("SAF sample payload size"))? + ]; + read_exact_at_sync( + file, + sample.data_offset, + &mut payload, + spec, + "SAF sample payload is truncated", + )?; + Ok(payload) +} + +#[cfg(feature = "async")] +async fn read_sample_payload_async( + file: &mut TokioFile, + spec: &str, + sample: PendingSafSample, +) -> Result, MuxError> { + let mut payload = vec![ + 0_u8; + usize::try_from(sample.data_size) + .map_err(|_| MuxError::LayoutOverflow("SAF sample payload size"))? + ]; + read_exact_at_async( + file, + sample.data_offset, + &mut payload, + spec, + "SAF sample payload is truncated", + ) + .await?; + Ok(payload) +} + +#[derive(Clone, Copy)] +struct SafAuHeader { + is_rap: bool, + cts: u32, + payload_size: u32, + au_type: u8, + stream_id: u16, +} + +fn read_saf_au_header_sync( + file: &mut File, + offset: &mut u64, + spec: &str, +) -> Result { + let mut outer = [0_u8; 8]; + file.read_exact(&mut outer) + .map_err(|error| saf_io_error(spec, "read SAF AU header", error))?; + *offset += 8; + let outer = u64::from_be_bytes(outer); + let payload_size = u32::from((outer & 0xFFFF) as u16); + if payload_size < 2 { + return Err(invalid_saf( + spec, + "SAF AU payload is shorter than the mandatory stream header", + )); + } + let mut inner = [0_u8; 2]; + file.read_exact(&mut inner) + .map_err(|error| saf_io_error(spec, "read SAF stream header", error))?; + *offset += 2; + let inner = u16::from_be_bytes(inner); + Ok(SafAuHeader { + is_rap: ((outer >> 63) & 1) != 0, + cts: ((outer >> 16) & 0x3FFF_FFFF) as u32, + payload_size: payload_size - 2, + au_type: u8::try_from((inner >> 12) & 0x0F).unwrap(), + stream_id: inner & 0x0FFF, + }) +} + +#[cfg(feature = "async")] +async fn read_saf_au_header_async( + file: &mut TokioFile, + offset: &mut u64, + spec: &str, +) -> Result { + let mut outer = [0_u8; 8]; + file.read_exact(&mut outer) + .await + .map_err(|error| saf_io_error(spec, "read SAF AU header", error))?; + *offset += 8; + let outer = u64::from_be_bytes(outer); + let payload_size = u32::from((outer & 0xFFFF) as u16); + if payload_size < 2 { + return Err(invalid_saf( + spec, + "SAF AU payload is shorter than the mandatory stream header", + )); + } + let mut inner = [0_u8; 2]; + file.read_exact(&mut inner) + .await + .map_err(|error| saf_io_error(spec, "read SAF stream header", error))?; + *offset += 2; + let inner = u16::from_be_bytes(inner); + Ok(SafAuHeader { + is_rap: ((outer >> 63) & 1) != 0, + cts: ((outer >> 16) & 0x3FFF_FFFF) as u32, + payload_size: payload_size - 2, + au_type: u8::try_from((inner >> 12) & 0x0F).unwrap(), + stream_id: inner & 0x0FFF, + }) +} + +fn read_saf_declaration_sync( + file: &mut File, + offset: &mut u64, + spec: &str, + header: SafAuHeader, +) -> Result { + if header.payload_size < 7 { + return Err(invalid_saf( + spec, + "SAF stream declaration is shorter than its fixed descriptor header", + )); + } + let mut fixed = [0_u8; 7]; + file.read_exact(&mut fixed) + .map_err(|error| saf_io_error(spec, "read SAF stream declaration", error))?; + *offset += 7; + let remaining = header.payload_size - 7; + let object_type_indication = fixed[0]; + let stream_type = fixed[1]; + let timescale = u32::from_be_bytes([0, fixed[2], fixed[3], fixed[4]]); + if timescale == 0 { + return Err(invalid_saf( + spec, + &format!("SAF stream {} declared a zero timescale", header.stream_id), + )); + } + + if object_type_indication == SAF_OBJECT_TYPE_CUSTOM && stream_type == SAF_OBJECT_TYPE_CUSTOM { + return Err(invalid_saf( + spec, + "SAF custom MIME declarations are outside the current authored import surface", + )); + } + + if header.au_type == 7 { + return Err(invalid_saf( + spec, + "SAF remote URL declarations are outside the current path-only import contract", + )); + } + + let mut decoder_specific_info = vec![ + 0_u8; + usize::try_from(remaining).map_err(|_| { + MuxError::LayoutOverflow("SAF decoder config size") + })? + ]; + if remaining != 0 { + file.read_exact(&mut decoder_specific_info) + .map_err(|error| saf_io_error(spec, "read SAF decoder config", error))?; + *offset += u64::from(remaining); + } + + build_declared_saf_track( + spec, + header.stream_id, + object_type_indication, + stream_type, + timescale, + decoder_specific_info, + ) +} + +#[cfg(feature = "async")] +async fn read_saf_declaration_async( + file: &mut TokioFile, + offset: &mut u64, + spec: &str, + header: SafAuHeader, +) -> Result { + if header.payload_size < 7 { + return Err(invalid_saf( + spec, + "SAF stream declaration is shorter than its fixed descriptor header", + )); + } + let mut fixed = [0_u8; 7]; + file.read_exact(&mut fixed) + .await + .map_err(|error| saf_io_error(spec, "read SAF stream declaration", error))?; + *offset += 7; + let remaining = header.payload_size - 7; + let object_type_indication = fixed[0]; + let stream_type = fixed[1]; + let timescale = u32::from_be_bytes([0, fixed[2], fixed[3], fixed[4]]); + if timescale == 0 { + return Err(invalid_saf( + spec, + &format!("SAF stream {} declared a zero timescale", header.stream_id), + )); + } + + if object_type_indication == SAF_OBJECT_TYPE_CUSTOM && stream_type == SAF_OBJECT_TYPE_CUSTOM { + return Err(invalid_saf( + spec, + "SAF custom MIME declarations are outside the current authored import surface", + )); + } + + if header.au_type == 7 { + return Err(invalid_saf( + spec, + "SAF remote URL declarations are outside the current path-only import contract", + )); + } + + let mut decoder_specific_info = vec![ + 0_u8; + usize::try_from(remaining).map_err(|_| { + MuxError::LayoutOverflow("SAF decoder config size") + })? + ]; + if remaining != 0 { + file.read_exact(&mut decoder_specific_info) + .await + .map_err(|error| saf_io_error(spec, "read SAF decoder config", error))?; + *offset += u64::from(remaining); + } + + build_declared_saf_track( + spec, + header.stream_id, + object_type_indication, + stream_type, + timescale, + decoder_specific_info, + ) +} + +fn build_declared_saf_track( + spec: &str, + stream_id: u16, + object_type_indication: u8, + stream_type: u8, + timescale: u32, + decoder_specific_info: Vec, +) -> Result { + let (kind, handler_name, codec_label, template) = match stream_type { + SAF_STREAM_TYPE_AUDIO => { + if object_type_indication != SAF_OBJECT_TYPE_AAC { + return Err(invalid_saf( + spec, + &format!( + "SAF stream {stream_id} declared unsupported authored audio object type 0x{object_type_indication:02x}" + ), + )); + } + let parsed = parse_aac_audio_specific_config(spec, &decoder_specific_info)?; + ( + MuxTrackKind::Audio, + direct_ingest_handler_name("aac"), + "aac", + SafTrackTemplate::Aac { + sample_rate: parsed.sample_rate, + channel_configuration: parsed.channel_configuration, + audio_specific_config: decoder_specific_info, + }, + ) + } + SAF_STREAM_TYPE_VISUAL => match object_type_indication { + SAF_OBJECT_TYPE_MP4V => { + let (width, height) = + parse_mp4v_decoder_specific_info(&decoder_specific_info, spec)?; + ( + MuxTrackKind::Video, + direct_ingest_handler_name("mp4v"), + "mp4v", + SafTrackTemplate::Mp4v { + width, + height, + decoder_specific_info, + }, + ) + } + SAF_OBJECT_TYPE_JPEG => ( + MuxTrackKind::Video, + direct_ingest_handler_name("jpeg"), + "jpeg", + SafTrackTemplate::Jpeg, + ), + SAF_OBJECT_TYPE_PNG => ( + MuxTrackKind::Video, + direct_ingest_handler_name("png"), + "png", + SafTrackTemplate::Png, + ), + _ => { + return Err(invalid_saf( + spec, + &format!( + "SAF stream {stream_id} declared unsupported authored visual object type 0x{object_type_indication:02x}" + ), + )); + } + }, + SAF_STREAM_TYPE_SCENE => { + if !SCENE_OBJECT_TYPES.contains(&object_type_indication) { + return Err(invalid_saf( + spec, + &format!( + "SAF stream {stream_id} declared unsupported scene object type 0x{object_type_indication:02x}" + ), + )); + } + ( + MuxTrackKind::Video, + "SceneHandler".to_string(), + "saf-scene", + SafTrackTemplate::Scene { + object_type_indication, + decoder_specific_info, + }, + ) + } + _ => { + return Err(invalid_saf( + spec, + &format!( + "SAF stream {stream_id} declared unsupported stream type 0x{stream_type:02x}" + ), + )); + } + }; + + Ok(DeclaredSafTrack { + stream_id, + kind, + handler_name, + codec_label, + timescale, + template, + samples: Vec::new(), + }) +} + +struct ParsedAacAudioSpecificConfig { + sample_rate: u32, + channel_configuration: u16, +} + +fn parse_aac_audio_specific_config( + spec: &str, + audio_specific_config: &[u8], +) -> Result { + let mut reader = BitReader::new(std::io::Cursor::new(audio_specific_config)); + let audio_object_type = read_audio_object_type(&mut reader, spec)?; + if audio_object_type == 0 { + return Err(invalid_saf( + spec, + "SAF AAC declaration carried an invalid audio object type of zero", + )); + } + let sampling_frequency_index = read_bits_u8(&mut reader, 4, spec, "SAF AAC sample rate")?; + let sample_rate = if sampling_frequency_index == 0x0F { + read_bits_u32(&mut reader, 24, spec, "SAF AAC explicit sample rate")? + } else { + aac_sample_rate(sampling_frequency_index).ok_or_else(|| { + invalid_saf( + spec, + &format!( + "SAF AAC declaration carried unsupported sample-rate index {sampling_frequency_index}" + ), + ) + })? + }; + let channel_configuration = u16::from(read_bits_u8( + &mut reader, + 4, + spec, + "SAF AAC channel configuration", + )?); + if channel_configuration == 0 { + return Err(invalid_saf( + spec, + "SAF AAC declarations that rely on program-config channel signaling are not supported on the current authored lane", + )); + } + Ok(ParsedAacAudioSpecificConfig { + sample_rate, + channel_configuration, + }) +} + +fn build_saf_aac_sample_entry_box( + audio_specific_config: &[u8], + sample_rate: u32, + channel_configuration: u16, +) -> Result, MuxError> { + let mut esds = Esds::default(); + esds.descriptors = vec![ + Descriptor { + tag: ES_DESCRIPTOR_TAG, + es_descriptor: Some(EsDescriptor::default()), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some(DecoderConfigDescriptor { + object_type_indication: SAF_OBJECT_TYPE_AAC, + stream_type: 5, + reserved: true, + ..DecoderConfigDescriptor::default() + }), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_SPECIFIC_INFO_TAG, + size: u32::try_from(audio_specific_config.len()) + .map_err(|_| MuxError::LayoutOverflow("SAF AAC decoder config size"))?, + data: audio_specific_config.to_vec(), + ..Descriptor::default() + }, + Descriptor { + tag: SL_CONFIG_DESCRIPTOR_TAG, + size: 1, + data: vec![0x02], + ..Descriptor::default() + }, + ]; + esds.normalize_descriptor_sizes_for_mux() + .map_err(|_| MuxError::LayoutOverflow("SAF AAC esds"))?; + let esds_box = super::super::mp4::encode_typed_box(&esds, &[])?; + build_generic_audio_sample_entry_box( + FourCc::from_bytes(*b"mp4a"), + sample_rate, + channel_configuration, + 16, + &[esds_box], + ) +} + +fn build_saf_generic_media_entry_box( + sample_entry_type: FourCc, + object_type_indication: u8, + stream_type: u8, + decoder_specific_info: &[u8], +) -> Result, MuxError> { + let mut esds = Esds::default(); + esds.descriptors = vec![ + Descriptor { + tag: ES_DESCRIPTOR_TAG, + es_descriptor: Some(EsDescriptor::default()), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some(DecoderConfigDescriptor { + object_type_indication, + stream_type, + reserved: true, + ..DecoderConfigDescriptor::default() + }), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_SPECIFIC_INFO_TAG, + size: u32::try_from(decoder_specific_info.len()) + .map_err(|_| MuxError::LayoutOverflow("SAF generic decoder config size"))?, + data: decoder_specific_info.to_vec(), + ..Descriptor::default() + }, + Descriptor { + tag: SL_CONFIG_DESCRIPTOR_TAG, + size: 1, + data: vec![0x02], + ..Descriptor::default() + }, + ]; + esds.normalize_descriptor_sizes_for_mux() + .map_err(|_| MuxError::LayoutOverflow("SAF generic esds"))?; + let esds_box = super::super::mp4::encode_typed_box(&esds, &[])?; + build_generic_media_sample_entry_box(sample_entry_type, &[esds_box]) +} + +fn read_audio_object_type(reader: &mut BitReader, spec: &str) -> Result +where + R: Read, +{ + let audio_object_type = read_bits_u8(reader, 5, spec, "SAF AAC audio object type")?; + if audio_object_type == 31 { + Ok(32 + read_bits_u8(reader, 6, spec, "SAF AAC extended audio object type")?) + } else { + Ok(audio_object_type) + } +} + +fn read_bits_u8( + reader: &mut BitReader, + width: usize, + spec: &str, + label: &str, +) -> Result +where + R: Read, +{ + let bits = reader.read_bits(width).map_err(|_| { + invalid_saf( + spec, + &format!("{label} is truncated before it exposes {width} bits"), + ) + })?; + let value = bits + .iter() + .fold(0_u16, |value, byte| (value << 8) | u16::from(*byte)); + u8::try_from(value).map_err(|_| invalid_saf(spec, &format!("{label} exceeds one byte"))) +} + +fn read_bits_u32( + reader: &mut BitReader, + width: usize, + spec: &str, + label: &str, +) -> Result +where + R: Read, +{ + let bits = reader.read_bits(width).map_err(|_| { + invalid_saf( + spec, + &format!("{label} is truncated before it exposes {width} bits"), + ) + })?; + Ok(bits + .iter() + .fold(0_u32, |value, byte| (value << 8) | u32::from(*byte))) +} + +const fn aac_sample_rate(index: u8) -> Option { + match index { + 0 => Some(96_000), + 1 => Some(88_200), + 2 => Some(64_000), + 3 => Some(48_000), + 4 => Some(44_100), + 5 => Some(32_000), + 6 => Some(24_000), + 7 => Some(22_050), + 8 => Some(16_000), + 9 => Some(12_000), + 10 => Some(11_025), + 11 => Some(8_000), + 12 => Some(7_350), + _ => None, + } +} + +fn skip_sync(file: &mut File, offset: &mut u64, size: u64) -> Result<(), MuxError> { + file.seek(SeekFrom::Current( + i64::try_from(size).map_err(|_| MuxError::LayoutOverflow("SAF skip size"))?, + )) + .map_err(|error| saf_io_error("SAF", "skip SAF payload", error))?; + *offset += size; + Ok(()) +} + +#[cfg(feature = "async")] +async fn skip_async(file: &mut TokioFile, offset: &mut u64, size: u64) -> Result<(), MuxError> { + file.seek(SeekFrom::Current( + i64::try_from(size).map_err(|_| MuxError::LayoutOverflow("SAF skip size"))?, + )) + .await + .map_err(|error| saf_io_error("SAF", "skip SAF payload", error))?; + *offset += size; + Ok(()) +} + +fn invalid_saf(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} + +fn saf_io_error(spec: &str, operation: &str, error: std::io::Error) -> MuxError { + invalid_saf(spec, &format!("failed to {operation}: {error}")) +} diff --git a/src/mux/demux/speex.rs b/src/mux/demux/speex.rs new file mode 100644 index 0000000..fc6159f --- /dev/null +++ b/src/mux/demux/speex.rs @@ -0,0 +1,420 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_spans_async; +use super::super::import::{ + SegmentedMuxSourceSegment, SegmentedMuxSourceSegmentData, SegmentedMuxSourceSpec, StagedSample, + build_btrt_from_sample_sizes, read_spans_sync, +}; +#[cfg(feature = "async")] +use super::ogg_common::read_ogg_page_header_async; +use super::ogg_common::{OggPacketBuilder, read_ogg_page_header_sync}; +use crate::FourCc; + +const SPEEX_ENTRY: FourCc = FourCc::from_bytes(*b"spex"); +const SPEEX_VENDOR: [u8; 4] = *b"mp4f"; + +pub(in crate::mux) struct ParsedOggSpeexTrack { + pub(in crate::mux) segmented_source: SegmentedMuxSourceSpec, + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +struct CompletedSpeexPageState { + packets: Vec, + eos: bool, +} + +struct SpeexConfig { + sample_rate: u32, +} + +pub(in crate::mux) fn scan_ogg_speex_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let mut offset = 0_u64; + let mut packet_builder = OggPacketBuilder::default(); + let mut config = None; + let mut saw_tags_packet = false; + let mut logical_size = 0_u64; + let mut transformed_segments = Vec::new(); + let mut samples = Vec::new(); + let mut decoded_samples = 0_u64; + + while offset < file_size { + let page = read_ogg_page_header_sync(&mut file, offset, spec)?; + if packet_builder.is_empty() && page.header_type & 0x01 != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Speex input started in the middle of a continued packet".to_string(), + }); + } + offset = page + .payload_offset + .checked_add(page.payload_size) + .ok_or(MuxError::LayoutOverflow("Ogg page range"))?; + let mut page_cursor = page.payload_offset; + let mut completed = Vec::new(); + for lacing in &page.lacing_values { + packet_builder.push_span(page_cursor, u32::from(*lacing))?; + page_cursor += u64::from(*lacing); + if *lacing < 255 { + let packet = packet_builder.finish(); + if packet.total_size != 0 { + completed.push(packet); + } + } + } + if completed.is_empty() { + continue; + } + process_speex_completed_page_sync( + &mut file, + spec, + &mut config, + &mut saw_tags_packet, + &mut logical_size, + &mut transformed_segments, + &mut samples, + &mut decoded_samples, + CompletedSpeexPageState { + packets: completed, + eos: page.header_type & 0x04 != 0, + }, + )?; + } + + finalize_speex_track( + path, + spec, + &mut packet_builder, + config, + logical_size, + transformed_segments, + samples, + ) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_ogg_speex_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let mut offset = 0_u64; + let mut packet_builder = OggPacketBuilder::default(); + let mut config = None; + let mut saw_tags_packet = false; + let mut logical_size = 0_u64; + let mut transformed_segments = Vec::new(); + let mut samples = Vec::new(); + let mut decoded_samples = 0_u64; + + while offset < file_size { + let page = read_ogg_page_header_async(&mut file, offset, spec).await?; + if packet_builder.is_empty() && page.header_type & 0x01 != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Speex input started in the middle of a continued packet".to_string(), + }); + } + offset = page + .payload_offset + .checked_add(page.payload_size) + .ok_or(MuxError::LayoutOverflow("Ogg page range"))?; + let mut page_cursor = page.payload_offset; + let mut completed = Vec::new(); + for lacing in &page.lacing_values { + packet_builder.push_span(page_cursor, u32::from(*lacing))?; + page_cursor += u64::from(*lacing); + if *lacing < 255 { + let packet = packet_builder.finish(); + if packet.total_size != 0 { + completed.push(packet); + } + } + } + if completed.is_empty() { + continue; + } + process_speex_completed_page_async( + &mut file, + spec, + &mut config, + &mut saw_tags_packet, + &mut logical_size, + &mut transformed_segments, + &mut samples, + &mut decoded_samples, + CompletedSpeexPageState { + packets: completed, + eos: page.header_type & 0x04 != 0, + }, + ) + .await?; + } + + finalize_speex_track( + path, + spec, + &mut packet_builder, + config, + logical_size, + transformed_segments, + samples, + ) +} + +#[allow(clippy::too_many_arguments)] +fn process_speex_completed_page_sync( + file: &mut File, + spec: &str, + config: &mut Option, + saw_tags_packet: &mut bool, + logical_size: &mut u64, + transformed_segments: &mut Vec, + samples: &mut Vec, + decoded_samples: &mut u64, + page: CompletedSpeexPageState, +) -> Result<(), MuxError> { + let mut audio_packets = Vec::new(); + for packet in page.packets { + let packet_bytes = read_spans_sync( + file, + &packet.spans, + packet.total_size, + spec, + "Ogg Speex packet is truncated", + )?; + if config.is_none() { + *config = Some(parse_speex_header(&packet_bytes, spec)?); + continue; + } + if !*saw_tags_packet && packet_bytes.starts_with(b"SpeexTags") { + *saw_tags_packet = true; + continue; + } + *saw_tags_packet = true; + audio_packets.push(packet); + } + if audio_packets.is_empty() { + return Ok(()); + } + append_speex_audio_packets( + decoded_samples, + logical_size, + transformed_segments, + samples, + audio_packets, + page.eos, + ) +} + +#[cfg(feature = "async")] +#[allow(clippy::too_many_arguments)] +async fn process_speex_completed_page_async( + file: &mut TokioFile, + spec: &str, + config: &mut Option, + saw_tags_packet: &mut bool, + logical_size: &mut u64, + transformed_segments: &mut Vec, + samples: &mut Vec, + decoded_samples: &mut u64, + page: CompletedSpeexPageState, +) -> Result<(), MuxError> { + let mut audio_packets = Vec::new(); + for packet in page.packets { + let packet_bytes: Vec = read_spans_async( + file, + &packet.spans, + packet.total_size, + spec, + "Ogg Speex packet is truncated", + ) + .await?; + if config.is_none() { + *config = Some(parse_speex_header(&packet_bytes, spec)?); + continue; + } + if !*saw_tags_packet && packet_bytes.starts_with(b"SpeexTags") { + *saw_tags_packet = true; + continue; + } + *saw_tags_packet = true; + audio_packets.push(packet); + } + if audio_packets.is_empty() { + return Ok(()); + } + append_speex_audio_packets( + decoded_samples, + logical_size, + transformed_segments, + samples, + audio_packets, + page.eos, + ) +} + +#[allow(clippy::too_many_arguments)] +fn append_speex_audio_packets( + decoded_samples: &mut u64, + logical_size: &mut u64, + transformed_segments: &mut Vec, + samples: &mut Vec, + audio_packets: Vec, + eos: bool, +) -> Result<(), MuxError> { + let last_index = audio_packets.len().saturating_sub(1); + for (index, packet) in audio_packets.into_iter().enumerate() { + let duration = if eos && index == last_index { + 0_u64 + } else { + 1_u64 + }; + let data_offset = *logical_size; + for span in &packet.spans { + transformed_segments.push(SegmentedMuxSourceSegment { + logical_offset: *logical_size, + data: SegmentedMuxSourceSegmentData::FileRange { + source_offset: span.source_offset, + size: span.size, + }, + }); + *logical_size = logical_size + .checked_add(u64::from(span.size)) + .ok_or(MuxError::LayoutOverflow("Ogg Speex logical source size"))?; + } + samples.push(StagedSample { + data_offset, + data_size: packet.total_size, + duration: u32::try_from(duration) + .map_err(|_| MuxError::LayoutOverflow("Ogg Speex packet duration"))?, + composition_time_offset: 0, + is_sync_sample: true, + }); + *decoded_samples = decoded_samples + .checked_add(duration) + .ok_or(MuxError::LayoutOverflow("Ogg Speex decoded sample count"))?; + } + Ok(()) +} + +fn finalize_speex_track( + path: &Path, + spec: &str, + packet_builder: &mut OggPacketBuilder, + config: Option, + logical_size: u64, + transformed_segments: Vec, + samples: Vec, +) -> Result { + if !packet_builder.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Speex input ended in the middle of a packet".to_string(), + }); + } + let config = config.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Speex input did not contain a Speex header packet".to_string(), + })?; + if samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Speex input did not contain any audio packets after headers".to_string(), + }); + } + Ok(ParsedOggSpeexTrack { + segmented_source: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: transformed_segments, + total_size: logical_size, + }, + sample_rate: config.sample_rate, + sample_entry_box: build_speex_sample_entry_box(&config, &samples)?, + samples, + }) +} + +fn build_speex_sample_entry_box( + config: &SpeexConfig, + samples: &[StagedSample], +) -> Result, MuxError> { + let btrt = build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + config.sample_rate, + )?; + let btrt_bytes = super::super::mp4::encode_typed_box(&btrt, &[])?; + build_speex_audio_sample_entry_box(config.sample_rate, &[btrt_bytes]) +} + +fn build_speex_audio_sample_entry_box( + sample_rate: u32, + child_boxes: &[Vec], +) -> Result, MuxError> { + let mut payload = Vec::with_capacity(28 + child_boxes.iter().map(Vec::len).sum::()); + payload.extend_from_slice(&[0; 6]); + payload.extend_from_slice(&1_u16.to_be_bytes()); + payload.extend_from_slice(&0_u16.to_be_bytes()); + payload.extend_from_slice(&0_u16.to_be_bytes()); + // Speex sample entries keep an authored vendor code in the version-0 audio entry header. + payload.extend_from_slice(SPEEX_VENDOR.as_slice()); + payload.extend_from_slice(&0_u16.to_be_bytes()); + payload.extend_from_slice(&16_u16.to_be_bytes()); + payload.extend_from_slice(&0_u16.to_be_bytes()); + payload.extend_from_slice(&0_u16.to_be_bytes()); + payload.extend_from_slice(&(sample_rate << 16).to_be_bytes()); + for child in child_boxes { + payload.extend_from_slice(child); + } + super::super::mp4::encode_raw_box(SPEEX_ENTRY, &payload) +} + +fn parse_speex_header(bytes: &[u8], spec: &str) -> Result { + if bytes.len() < 80 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Speex header is truncated before the fixed 80-byte header".to_string(), + }); + } + if &bytes[..5] != b"Speex" { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Speex header did not start with the `Speex` signature".to_string(), + }); + } + // Speex stores `version_id` before `header_size`; real files often use `version_id = 1`, + // so reading the wrong word here incorrectly rejects otherwise valid headers. + let header_size = u32::from_le_bytes(bytes[32..36].try_into().unwrap()); + if header_size < 80 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("Ogg Speex header declared an unsupported header size {header_size}"), + }); + } + let sample_rate = u32::from_le_bytes(bytes[36..40].try_into().unwrap()); + let channel_count = u32::from_le_bytes(bytes[48..52].try_into().unwrap()); + let frame_size = u32::from_le_bytes(bytes[56..60].try_into().unwrap()); + let frames_per_packet = u32::from_le_bytes(bytes[64..68].try_into().unwrap()); + if sample_rate == 0 || channel_count == 0 || frame_size == 0 || frames_per_packet == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Speex header carried zero-valued core audio fields".to_string(), + }); + } + Ok(SpeexConfig { sample_rate }) +} diff --git a/src/mux/demux/theora.rs b/src/mux/demux/theora.rs new file mode 100644 index 0000000..f241cb9 --- /dev/null +++ b/src/mux/demux/theora.rs @@ -0,0 +1,483 @@ +use std::collections::BTreeMap; +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::iso14496_12::{Pasp, SampleEntry, VisualSampleEntry}; +use crate::boxes::iso14496_14::{ + DECODER_CONFIG_DESCRIPTOR_TAG, DECODER_SPECIFIC_INFO_TAG, DecoderConfigDescriptor, Descriptor, + ES_DESCRIPTOR_TAG, EsDescriptor, Esds, SL_CONFIG_DESCRIPTOR_TAG, +}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_spans_async; +use super::super::import::{ + SegmentedMuxSourceSegment, SegmentedMuxSourceSegmentData, SegmentedMuxSourceSpec, StagedSample, + build_btrt_from_sample_sizes, read_spans_sync, +}; +#[cfg(feature = "async")] +use super::ogg_common::read_ogg_page_header_async; +use super::ogg_common::{OggPacketBuilder, read_ogg_page_header_sync}; + +const THEORA_ENTRY: FourCc = FourCc::from_bytes(*b"mp4v"); + +pub(in crate::mux) struct ParsedOggTheoraTrack { + pub(in crate::mux) segmented_source: SegmentedMuxSourceSpec, + pub(in crate::mux) width: u16, + pub(in crate::mux) height: u16, + pub(in crate::mux) timescale: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +struct TheoraConfig { + width: u16, + height: u16, + timescale: u32, + frame_duration: u32, + sar_num: u32, + sar_den: u32, +} + +pub(in crate::mux) fn scan_ogg_theora_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let mut offset = 0_u64; + let mut packet_builders = BTreeMap::::new(); + let mut target_serial = None::; + let mut header_packets = Vec::new(); + let mut config = None; + let mut comment_seen = false; + let mut setup_seen = false; + let mut logical_size = 0_u64; + let mut transformed_segments = Vec::new(); + let mut samples = Vec::new(); + + while offset < file_size { + let page = read_ogg_page_header_sync(&mut file, offset, spec)?; + offset = page + .payload_offset + .checked_add(page.payload_size) + .ok_or(MuxError::LayoutOverflow("Ogg page range"))?; + if target_serial.is_some_and(|serial_no| serial_no != page.serial_no) { + continue; + } + let packet_builder = packet_builders.entry(page.serial_no).or_default(); + if packet_builder.is_empty() && page.header_type & 0x01 != 0 { + if target_serial == Some(page.serial_no) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Theora input started in the middle of a continued packet" + .to_string(), + }); + } + continue; + } + let mut page_cursor = page.payload_offset; + let mut completed_packets = Vec::new(); + for lacing in &page.lacing_values { + packet_builder.push_span(page_cursor, u32::from(*lacing))?; + page_cursor += u64::from(*lacing); + if *lacing == 255 { + continue; + } + let packet = packet_builder.finish(); + if packet.total_size == 0 { + continue; + } + completed_packets.push(packet); + } + for packet in completed_packets { + let packet_bytes = read_spans_sync( + &mut file, + &packet.spans, + packet.total_size, + spec, + "Ogg Theora packet is truncated", + )?; + if target_serial.is_none() { + let Some(parsed_config) = + parse_theora_identification_header(&packet_bytes, spec).ok() + else { + continue; + }; + config = Some(parsed_config); + target_serial = Some(page.serial_no); + packet_builders.retain(|serial_no, _| *serial_no == page.serial_no); + header_packets.push(packet_bytes); + continue; + } + if !comment_seen { + validate_theora_header_packet(&packet_bytes, 0x81, spec, "comment")?; + comment_seen = true; + header_packets.push(packet_bytes); + continue; + } + if !setup_seen { + validate_theora_header_packet(&packet_bytes, 0x82, spec, "setup")?; + setup_seen = true; + header_packets.push(packet_bytes); + continue; + } + if packet_bytes[0] & 0x80 != 0 { + continue; + } + let is_sync_sample = packet_bytes[0] & 0x40 == 0; + let data_offset = logical_size; + for span in &packet.spans { + transformed_segments.push(SegmentedMuxSourceSegment { + logical_offset: logical_size, + data: SegmentedMuxSourceSegmentData::FileRange { + source_offset: span.source_offset, + size: span.size, + }, + }); + logical_size = logical_size + .checked_add(u64::from(span.size)) + .ok_or(MuxError::LayoutOverflow("Ogg Theora logical source size"))?; + } + samples.push(StagedSample { + data_offset, + data_size: packet.total_size, + duration: config.as_ref().unwrap().frame_duration, + composition_time_offset: 0, + is_sync_sample, + }); + } + } + + let mut fallback_packet_builder = OggPacketBuilder::default(); + let packet_builder = target_serial + .and_then(|serial_no| packet_builders.get_mut(&serial_no)) + .unwrap_or(&mut fallback_packet_builder); + + finalize_theora_track( + path, + spec, + packet_builder, + config, + header_packets, + logical_size, + transformed_segments, + samples, + setup_seen, + ) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_ogg_theora_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let mut offset = 0_u64; + let mut packet_builders = BTreeMap::::new(); + let mut target_serial = None::; + let mut header_packets = Vec::new(); + let mut config = None; + let mut comment_seen = false; + let mut setup_seen = false; + let mut logical_size = 0_u64; + let mut transformed_segments = Vec::new(); + let mut samples = Vec::new(); + + while offset < file_size { + let page = read_ogg_page_header_async(&mut file, offset, spec).await?; + offset = page + .payload_offset + .checked_add(page.payload_size) + .ok_or(MuxError::LayoutOverflow("Ogg page range"))?; + if target_serial.is_some_and(|serial_no| serial_no != page.serial_no) { + continue; + } + let packet_builder = packet_builders.entry(page.serial_no).or_default(); + if packet_builder.is_empty() && page.header_type & 0x01 != 0 { + if target_serial == Some(page.serial_no) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Theora input started in the middle of a continued packet" + .to_string(), + }); + } + continue; + } + let mut page_cursor = page.payload_offset; + let mut completed_packets = Vec::new(); + for lacing in &page.lacing_values { + packet_builder.push_span(page_cursor, u32::from(*lacing))?; + page_cursor += u64::from(*lacing); + if *lacing == 255 { + continue; + } + let packet = packet_builder.finish(); + if packet.total_size == 0 { + continue; + } + completed_packets.push(packet); + } + for packet in completed_packets { + let packet_bytes: Vec = read_spans_async( + &mut file, + &packet.spans, + packet.total_size, + spec, + "Ogg Theora packet is truncated", + ) + .await?; + if target_serial.is_none() { + let Some(parsed_config) = + parse_theora_identification_header(&packet_bytes, spec).ok() + else { + continue; + }; + config = Some(parsed_config); + target_serial = Some(page.serial_no); + packet_builders.retain(|serial_no, _| *serial_no == page.serial_no); + header_packets.push(packet_bytes); + continue; + } + if !comment_seen { + validate_theora_header_packet(&packet_bytes, 0x81, spec, "comment")?; + comment_seen = true; + header_packets.push(packet_bytes); + continue; + } + if !setup_seen { + validate_theora_header_packet(&packet_bytes, 0x82, spec, "setup")?; + setup_seen = true; + header_packets.push(packet_bytes); + continue; + } + if packet_bytes[0] & 0x80 != 0 { + continue; + } + let is_sync_sample = packet_bytes[0] & 0x40 == 0; + let data_offset = logical_size; + for span in &packet.spans { + transformed_segments.push(SegmentedMuxSourceSegment { + logical_offset: logical_size, + data: SegmentedMuxSourceSegmentData::FileRange { + source_offset: span.source_offset, + size: span.size, + }, + }); + logical_size = logical_size + .checked_add(u64::from(span.size)) + .ok_or(MuxError::LayoutOverflow("Ogg Theora logical source size"))?; + } + samples.push(StagedSample { + data_offset, + data_size: packet.total_size, + duration: config.as_ref().unwrap().frame_duration, + composition_time_offset: 0, + is_sync_sample, + }); + } + } + + let mut fallback_packet_builder = OggPacketBuilder::default(); + let packet_builder = target_serial + .and_then(|serial_no| packet_builders.get_mut(&serial_no)) + .unwrap_or(&mut fallback_packet_builder); + + finalize_theora_track( + path, + spec, + packet_builder, + config, + header_packets, + logical_size, + transformed_segments, + samples, + setup_seen, + ) +} + +#[allow(clippy::too_many_arguments)] +fn finalize_theora_track( + path: &Path, + spec: &str, + packet_builder: &mut OggPacketBuilder, + config: Option, + header_packets: Vec>, + logical_size: u64, + transformed_segments: Vec, + samples: Vec, + setup_seen: bool, +) -> Result { + if !packet_builder.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Theora input ended in the middle of a packet".to_string(), + }); + } + let config = config.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Theora input did not contain an identification header".to_string(), + })?; + if !setup_seen || header_packets.len() != 3 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Theora input did not contain the full three-header setup".to_string(), + }); + } + if samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Theora input did not contain any frame packets after headers".to_string(), + }); + } + + let mut dsi = Vec::new(); + for packet in &header_packets { + let packet_len = u16::try_from(packet.len()) + .map_err(|_| MuxError::LayoutOverflow("Theora header packet length"))?; + dsi.extend_from_slice(&packet_len.to_be_bytes()); + dsi.extend_from_slice(packet); + } + + Ok(ParsedOggTheoraTrack { + segmented_source: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: transformed_segments, + total_size: logical_size, + }, + width: config.width, + height: config.height, + timescale: config.timescale, + sample_entry_box: build_theora_sample_entry_box( + &config, + &dsi, + build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + config.timescale, + )?, + )?, + samples, + }) +} + +fn parse_theora_identification_header(bytes: &[u8], spec: &str) -> Result { + validate_theora_header_packet(bytes, 0x80, spec, "identification")?; + if bytes.len() < 42 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Theora identification header is truncated".to_string(), + }); + } + let width = u16::from_be_bytes(bytes[10..12].try_into().unwrap()) << 4; + let height = u16::from_be_bytes(bytes[12..14].try_into().unwrap()) << 4; + let timescale = u32::from_be_bytes(bytes[22..26].try_into().unwrap()); + let frame_duration = u32::from_be_bytes(bytes[26..30].try_into().unwrap()); + let sar_num = (u32::from(bytes[30]) << 16) | (u32::from(bytes[31]) << 8) | u32::from(bytes[32]); + let sar_den = (u32::from(bytes[33]) << 16) | (u32::from(bytes[34]) << 8) | u32::from(bytes[35]); + if width == 0 || height == 0 || timescale == 0 || frame_duration == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Theora identification header carried zero-valued core fields".to_string(), + }); + } + Ok(TheoraConfig { + width, + height, + timescale, + frame_duration, + sar_num, + sar_den, + }) +} + +fn validate_theora_header_packet( + packet: &[u8], + expected_type: u8, + spec: &str, + name: &str, +) -> Result<(), MuxError> { + if packet.len() < 7 || packet[0] != expected_type || &packet[1..7] != b"theora" { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("Ogg Theora {name} header was missing the expected Theora signature"), + }); + } + Ok(()) +} + +fn build_theora_sample_entry_box( + config: &TheoraConfig, + decoder_specific_info: &[u8], + decoder_bitrates: crate::boxes::iso14496_12::Btrt, +) -> Result, MuxError> { + let mut esds = Esds::default(); + esds.descriptors = vec![ + Descriptor { + tag: ES_DESCRIPTOR_TAG, + es_descriptor: Some(EsDescriptor::default()), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some(DecoderConfigDescriptor { + object_type_indication: 0xDF, + stream_type: 4, + reserved: true, + buffer_size_db: decoder_bitrates.buffer_size_db, + max_bitrate: decoder_bitrates.max_bitrate, + avg_bitrate: decoder_bitrates.avg_bitrate, + ..DecoderConfigDescriptor::default() + }), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_SPECIFIC_INFO_TAG, + size: u32::try_from(decoder_specific_info.len()) + .map_err(|_| MuxError::LayoutOverflow("Theora decoder config size"))?, + data: decoder_specific_info.to_vec(), + ..Descriptor::default() + }, + Descriptor { + tag: SL_CONFIG_DESCRIPTOR_TAG, + size: 1, + data: vec![0x02], + ..Descriptor::default() + }, + ]; + esds.normalize_descriptor_sizes_for_mux() + .map_err(|_| MuxError::LayoutOverflow("Theora esds"))?; + let mut child_boxes = vec![super::super::mp4::encode_typed_box(&esds, &[])?]; + if config.sar_num != 0 && config.sar_den != 0 { + child_boxes.push(super::super::mp4::encode_typed_box( + &Pasp { + h_spacing: config.sar_num, + v_spacing: config.sar_den, + }, + &[], + )?); + } + super::super::mp4::encode_typed_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: THEORA_ENTRY, + data_reference_index: 1, + }, + width: config.width, + height: config.height, + horizresolution: 72_u32 << 16, + vertresolution: 72_u32 << 16, + frame_count: 1, + depth: 0x0018, + pre_defined3: -1, + ..VisualSampleEntry::default() + }, + &child_boxes.concat(), + ) +} diff --git a/src/mux/demux/truehd.rs b/src/mux/demux/truehd.rs new file mode 100644 index 0000000..102a087 --- /dev/null +++ b/src/mux/demux/truehd.rs @@ -0,0 +1,794 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::dolby::Dmlp; +use crate::boxes::iso14496_12::{AudioSampleEntry, Btrt, SampleEntry}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{SegmentedMuxSourceSegment, StagedSample, read_exact_at_sync}; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::read_segmented_bytes_sync; + +const SAMPLE_ENTRY_MLPA: FourCc = FourCc::from_bytes(*b"mlpa"); +const TRUEHD_SYNC: u32 = 0xF872_6FBA; +const TRUEHD_SIGNATURE: u16 = 0xB752; +const TRUEHD_MIN_HEADER_BYTES: usize = 20; +const AC3_MIN_HEADER_BYTES: usize = 8; +const AC3_FRAME_SIZE_WORDS: [[u16; 3]; 38] = [ + [64, 69, 96], + [64, 70, 96], + [80, 87, 120], + [80, 88, 120], + [96, 104, 144], + [96, 105, 144], + [112, 121, 168], + [112, 122, 168], + [128, 139, 192], + [128, 140, 192], + [160, 174, 240], + [160, 175, 240], + [192, 208, 288], + [192, 209, 288], + [224, 243, 336], + [224, 244, 336], + [256, 278, 384], + [256, 279, 384], + [320, 348, 480], + [320, 349, 480], + [384, 417, 576], + [384, 418, 576], + [448, 487, 672], + [448, 488, 672], + [512, 557, 768], + [512, 558, 768], + [640, 696, 960], + [640, 697, 960], + [768, 835, 1_152], + [768, 836, 1_152], + [896, 975, 1_344], + [896, 976, 1_344], + [1_024, 1_114, 1_536], + [1_024, 1_115, 1_536], + [1_152, 1_253, 1_728], + [1_152, 1_254, 1_728], + [1_280, 1_393, 1_920], + [1_280, 1_394, 1_920], +]; + +pub(in crate::mux) struct ParsedTrueHdTrack { + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) descriptor: TrueHdDescriptor, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::mux) struct TrueHdDescriptor { + sample_rate: u32, + channel_count: u16, + format_info: u32, + peak_data_rate: u16, + pub(in crate::mux) sample_duration: u32, +} + +enum ParsedTrueHdUnit { + AuxiliaryAc3 { + frame_size: u32, + }, + TrueHdFrame { + descriptor: TrueHdDescriptor, + frame_size: u32, + }, +} + +pub(in crate::mux) fn scan_truehd_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + parse_truehd_stream_sync(&mut file, file_size, spec) +} + +pub(in crate::mux) fn scan_truehd_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + parse_truehd_segmented_stream_sync(file, segments, total_size, spec) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_truehd_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + parse_truehd_stream_async(&mut file, file_size, spec).await +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_truehd_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + parse_truehd_segmented_stream_async(file, segments, total_size, spec).await +} + +fn parse_truehd_stream_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut descriptor = None::; + + while offset < file_size { + match parse_truehd_unit_sync(file, file_size, offset, spec)? { + ParsedTrueHdUnit::AuxiliaryAc3 { frame_size } => { + offset = + offset + .checked_add(u64::from(frame_size)) + .ok_or(MuxError::LayoutOverflow( + "TrueHD auxiliary AC-3 frame offset", + ))?; + } + ParsedTrueHdUnit::TrueHdFrame { + descriptor: parsed_descriptor, + frame_size, + } => { + if offset + .checked_add(u64::from(frame_size)) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated TrueHD frame at byte offset {offset}"), + }); + } + if let Some(current) = descriptor { + if current != parsed_descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "TrueHD frames changed decoder configuration mid-stream" + .to_string(), + }); + } + } else { + descriptor = Some(parsed_descriptor); + } + samples.push(StagedSample { + data_offset: offset, + data_size: frame_size, + duration: parsed_descriptor.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(u64::from(frame_size)) + .ok_or(MuxError::LayoutOverflow("TrueHD frame offset"))?; + } + } + } + + finalize_truehd_track(spec, descriptor, samples) +} + +fn parse_truehd_segmented_stream_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut descriptor = None::; + + while offset < total_size { + match parse_truehd_unit_segmented_sync(file, segments, total_size, offset, spec)? { + ParsedTrueHdUnit::AuxiliaryAc3 { frame_size } => { + offset = + offset + .checked_add(u64::from(frame_size)) + .ok_or(MuxError::LayoutOverflow( + "TrueHD auxiliary AC-3 logical frame offset", + ))?; + } + ParsedTrueHdUnit::TrueHdFrame { + descriptor: parsed_descriptor, + frame_size, + } => { + if offset + .checked_add(u64::from(frame_size)) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated TrueHD frame at logical byte offset {offset}"), + }); + } + if let Some(current) = descriptor { + if current != parsed_descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "TrueHD frames changed decoder configuration mid-stream" + .to_string(), + }); + } + } else { + descriptor = Some(parsed_descriptor); + } + samples.push(StagedSample { + data_offset: offset, + data_size: frame_size, + duration: parsed_descriptor.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(u64::from(frame_size)) + .ok_or(MuxError::LayoutOverflow("TrueHD logical frame offset"))?; + } + } + } + + finalize_truehd_track(spec, descriptor, samples) +} + +#[cfg(feature = "async")] +async fn parse_truehd_stream_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut descriptor = None::; + + while offset < file_size { + match parse_truehd_unit_async(file, file_size, offset, spec).await? { + ParsedTrueHdUnit::AuxiliaryAc3 { frame_size } => { + offset = + offset + .checked_add(u64::from(frame_size)) + .ok_or(MuxError::LayoutOverflow( + "TrueHD auxiliary AC-3 frame offset", + ))?; + } + ParsedTrueHdUnit::TrueHdFrame { + descriptor: parsed_descriptor, + frame_size, + } => { + if offset + .checked_add(u64::from(frame_size)) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated TrueHD frame at byte offset {offset}"), + }); + } + if let Some(current) = descriptor { + if current != parsed_descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "TrueHD frames changed decoder configuration mid-stream" + .to_string(), + }); + } + } else { + descriptor = Some(parsed_descriptor); + } + samples.push(StagedSample { + data_offset: offset, + data_size: frame_size, + duration: parsed_descriptor.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(u64::from(frame_size)) + .ok_or(MuxError::LayoutOverflow("TrueHD frame offset"))?; + } + } + } + + finalize_truehd_track(spec, descriptor, samples) +} + +#[cfg(feature = "async")] +async fn parse_truehd_segmented_stream_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut offset = 0_u64; + let mut samples = Vec::new(); + let mut descriptor = None::; + + while offset < total_size { + match parse_truehd_unit_segmented_async(file, segments, total_size, offset, spec).await? { + ParsedTrueHdUnit::AuxiliaryAc3 { frame_size } => { + offset = + offset + .checked_add(u64::from(frame_size)) + .ok_or(MuxError::LayoutOverflow( + "TrueHD auxiliary AC-3 logical frame offset", + ))?; + } + ParsedTrueHdUnit::TrueHdFrame { + descriptor: parsed_descriptor, + frame_size, + } => { + if offset + .checked_add(u64::from(frame_size)) + .is_none_or(|end| end > total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated TrueHD frame at logical byte offset {offset}"), + }); + } + if let Some(current) = descriptor { + if current != parsed_descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "TrueHD frames changed decoder configuration mid-stream" + .to_string(), + }); + } + } else { + descriptor = Some(parsed_descriptor); + } + samples.push(StagedSample { + data_offset: offset, + data_size: frame_size, + duration: parsed_descriptor.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = offset + .checked_add(u64::from(frame_size)) + .ok_or(MuxError::LayoutOverflow("TrueHD logical frame offset"))?; + } + } + } + + finalize_truehd_track(spec, descriptor, samples) +} + +fn finalize_truehd_track( + spec: &str, + descriptor: Option, + samples: Vec, +) -> Result { + let descriptor = descriptor.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "TrueHD input contained no TrueHD frames".to_string(), + })?; + + Ok(ParsedTrueHdTrack { + sample_rate: descriptor.sample_rate, + descriptor, + sample_entry_box: build_truehd_sample_entry_box(descriptor)?, + samples, + }) +} + +fn parse_truehd_unit_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, +) -> Result { + let remaining = file_size.saturating_sub(offset); + if remaining < u64::try_from(AC3_MIN_HEADER_BYTES).unwrap() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated TrueHD frame header".to_string(), + }); + } + let header_len = + usize::try_from(remaining.min(u64::try_from(TRUEHD_MIN_HEADER_BYTES).unwrap())).unwrap(); + let mut header = vec![0_u8; header_len]; + read_exact_at_sync( + file, + offset, + &mut header, + spec, + "truncated TrueHD frame header", + )?; + parse_truehd_unit_header(&header, remaining, offset, spec) +} + +fn parse_truehd_unit_segmented_sync( + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + offset: u64, + spec: &str, +) -> Result { + let remaining = total_size.saturating_sub(offset); + if remaining < u64::try_from(AC3_MIN_HEADER_BYTES).unwrap() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated TrueHD frame header".to_string(), + }); + } + let header_len = + usize::try_from(remaining.min(u64::try_from(TRUEHD_MIN_HEADER_BYTES).unwrap())).unwrap(); + let mut header = vec![0_u8; header_len]; + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated TrueHD frame header", + )?; + parse_truehd_unit_header(&header, remaining, offset, spec) +} + +#[cfg(feature = "async")] +async fn parse_truehd_unit_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, +) -> Result { + let remaining = file_size.saturating_sub(offset); + if remaining < u64::try_from(AC3_MIN_HEADER_BYTES).unwrap() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated TrueHD frame header".to_string(), + }); + } + let header_len = + usize::try_from(remaining.min(u64::try_from(TRUEHD_MIN_HEADER_BYTES).unwrap())).unwrap(); + let mut header = vec![0_u8; header_len]; + read_exact_at_async( + file, + offset, + &mut header, + spec, + "truncated TrueHD frame header", + ) + .await?; + parse_truehd_unit_header(&header, remaining, offset, spec) +} + +#[cfg(feature = "async")] +async fn parse_truehd_unit_segmented_async( + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + offset: u64, + spec: &str, +) -> Result { + let remaining = total_size.saturating_sub(offset); + if remaining < u64::try_from(AC3_MIN_HEADER_BYTES).unwrap() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated TrueHD frame header".to_string(), + }); + } + let header_len = + usize::try_from(remaining.min(u64::try_from(TRUEHD_MIN_HEADER_BYTES).unwrap())).unwrap(); + let mut header = vec![0_u8; header_len]; + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut header, + spec, + "truncated TrueHD frame header", + ) + .await?; + parse_truehd_unit_header(&header, remaining, offset, spec) +} + +fn parse_truehd_unit_header( + header: &[u8], + remaining: u64, + offset: u64, + spec: &str, +) -> Result { + if header.starts_with(&[0x0B, 0x77]) { + let frame_size = parse_auxiliary_ac3_frame_size(header, offset, spec)?; + if u64::from(frame_size) > remaining { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated auxiliary AC-3 frame at byte offset {offset}"), + }); + } + return Ok(ParsedTrueHdUnit::AuxiliaryAc3 { frame_size }); + } + if header.len() < TRUEHD_MIN_HEADER_BYTES { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated TrueHD frame header".to_string(), + }); + } + let frame_size = parse_truehd_frame_size(header, offset, spec)?; + if u64::from(frame_size) > remaining { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated TrueHD frame at byte offset {offset}"), + }); + } + let descriptor = parse_truehd_descriptor(header, offset, spec)?; + Ok(ParsedTrueHdUnit::TrueHdFrame { + descriptor, + frame_size, + }) +} + +fn parse_truehd_frame_size(header: &[u8], offset: u64, spec: &str) -> Result { + let packed = u16::from_be_bytes([header[0], header[1]]); + let frame_size = u32::from(packed & 0x0FFF) * 2; + if frame_size < u32::try_from(TRUEHD_MIN_HEADER_BYTES).unwrap() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("invalid TrueHD frame size at byte offset {offset}"), + }); + } + Ok(frame_size) +} + +fn parse_truehd_descriptor( + header: &[u8], + offset: u64, + spec: &str, +) -> Result { + let sync = u32::from_be_bytes(header[4..8].try_into().unwrap()); + if sync != TRUEHD_SYNC { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing TrueHD sync marker at byte offset {offset}"), + }); + } + let format_info = u32::from_be_bytes(header[8..12].try_into().unwrap()); + let sample_rate = truehd_sample_rate(((format_info >> 28) & 0x0F) as u8).ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "unsupported TrueHD sample-rate code {}", + (format_info >> 28) & 0x0F + ), + } + })?; + let signature = u16::from_be_bytes(header[12..14].try_into().unwrap()); + if signature != TRUEHD_SIGNATURE { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing TrueHD format signature at byte offset {offset}"), + }); + } + let sample_duration = + truehd_frame_duration(sample_rate).ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported TrueHD sample rate {sample_rate}"), + })?; + let channel_count = + truehd_channel_count(format_info).ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported TrueHD channel layout at byte offset {offset}"), + })?; + let peak_data_rate = (u16::from_be_bytes(header[2..4].try_into().unwrap()) >> 1) & 0x7FFF; + Ok(TrueHdDescriptor { + sample_rate, + channel_count, + format_info, + peak_data_rate, + sample_duration, + }) +} + +fn truehd_sample_rate(code: u8) -> Option { + match code { + 0 => Some(48_000), + 1 => Some(96_000), + 2 => Some(192_000), + 8 => Some(44_100), + 9 => Some(88_200), + 10 => Some(176_400), + _ => None, + } +} + +fn truehd_frame_duration(sample_rate: u32) -> Option { + match sample_rate { + 48_000 | 96_000 | 192_000 => Some(sample_rate / 1_200), + 44_100 | 88_200 | 176_400 => Some(sample_rate * 2 / 2_205), + _ => None, + } +} + +fn truehd_channel_count(format_info: u32) -> Option { + let ch_2_modif = ((format_info >> 22) & 0x03) as u8; + let ch_6_assign = ((format_info >> 15) & 0x1F) as u8; + let ch_8_assign = (format_info & 0x1FFF) as u16; + + let mut channel_count = if ch_2_modif == 1 { 1 } else { 2 }; + + if ch_6_assign != 0 { + channel_count = 0; + if ch_6_assign & 0x01 != 0 { + channel_count += 2; + } + if ch_6_assign & 0x02 != 0 { + channel_count += 1; + } + if ch_6_assign & 0x04 != 0 { + channel_count += 1; + } + if ch_6_assign & 0x08 != 0 { + channel_count += 2; + } + if ch_6_assign & 0x10 != 0 { + channel_count += 2; + } + } + + if ch_8_assign != 0 { + channel_count = 0; + if ch_8_assign & (1 << 0) != 0 { + channel_count += 2; + } + if ch_8_assign & (1 << 1) != 0 { + channel_count += 1; + } + if ch_8_assign & (1 << 2) != 0 { + channel_count += 1; + } + if ch_8_assign & (1 << 3) != 0 { + channel_count += 2; + } + if ch_8_assign & (1 << 4) != 0 { + channel_count += 2; + } + if ch_8_assign & (1 << 5) != 0 { + channel_count += 2; + } + if ch_8_assign & (1 << 6) != 0 { + channel_count += 2; + } + if ch_8_assign & (1 << 7) != 0 { + channel_count += 1; + } + if ch_8_assign & (1 << 8) != 0 { + channel_count += 1; + } + if ch_8_assign & (1 << 9) != 0 { + channel_count += 2; + } + if ch_8_assign & (1 << 10) != 0 { + channel_count += 2; + } + if ch_8_assign & (1 << 11) != 0 { + channel_count += 1; + } + if ch_8_assign & (1 << 12) != 0 { + channel_count += 1; + } + } + + (channel_count != 0).then_some(channel_count) +} + +pub(in crate::mux) fn build_truehd_sample_entry_box_with_btrt( + descriptor: TrueHdDescriptor, + btrt: Btrt, +) -> Result, MuxError> { + let dmlp = super::super::mp4::encode_typed_box( + &Dmlp { + format_info: descriptor.format_info, + peak_data_rate: descriptor.peak_data_rate, + }, + &[], + )?; + let btrt = super::super::mp4::encode_typed_box(&btrt, &[])?; + super::super::mp4::encode_typed_box( + &AudioSampleEntry { + sample_entry: SampleEntry { + box_type: SAMPLE_ENTRY_MLPA, + data_reference_index: 1, + }, + channel_count: descriptor.channel_count, + sample_size: 16, + sample_rate: descriptor.sample_rate, + ..AudioSampleEntry::default() + }, + &[dmlp, btrt].concat(), + ) +} + +pub(in crate::mux) fn build_truehd_sample_entry_box_with_btrt_buffer_size( + descriptor: TrueHdDescriptor, + buffer_size_db: u32, +) -> Result, MuxError> { + let nominal_bitrate = descriptor + .sample_rate + .checked_mul(u32::from(descriptor.channel_count)) + .and_then(|value| value.checked_mul(4)) + .ok_or(MuxError::LayoutOverflow("TrueHD nominal bitrate"))?; + build_truehd_sample_entry_box_with_btrt( + descriptor, + Btrt { + buffer_size_db, + max_bitrate: nominal_bitrate, + avg_bitrate: nominal_bitrate, + }, + ) +} + +fn build_truehd_sample_entry_box(descriptor: TrueHdDescriptor) -> Result, MuxError> { + build_truehd_sample_entry_box_with_btrt_buffer_size(descriptor, descriptor.sample_duration) +} + +fn parse_auxiliary_ac3_frame_size(header: &[u8], offset: u64, spec: &str) -> Result { + if header.len() < AC3_MIN_HEADER_BYTES { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated auxiliary AC-3 frame header".to_string(), + }); + } + if header[0] != 0x0B || header[1] != 0x77 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing auxiliary AC-3 sync word at byte offset {offset}"), + }); + } + let fscod = header[4] >> 6; + if fscod == 0x03 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "reserved auxiliary AC-3 sample-rate code".to_string(), + }); + } + let frmsizecod = header[4] & 0x3F; + let frame_size = ac3_frame_size_bytes(fscod, frmsizecod).ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("unsupported auxiliary AC-3 frame-size code {frmsizecod}"), + } + })?; + let bsid = (header[5] >> 3) & 0x1F; + if bsid > 10 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "embedded E-AC-3 frames are not currently supported in TrueHD input" + .to_string(), + }); + } + Ok(frame_size) +} + +fn ac3_frame_size_bytes(fscod: u8, frmsizecod: u8) -> Option { + if frmsizecod > 37 { + return None; + } + let frame_words = *AC3_FRAME_SIZE_WORDS.get(usize::from(frmsizecod))?; + let sample_rate_index = match fscod { + 0 => 0, + 1 => 1, + 2 => 2, + _ => return None, + }; + Some(u32::from(frame_words[sample_rate_index]) * 2) +} diff --git a/src/mux/demux/ts.rs b/src/mux/demux/ts.rs new file mode 100644 index 0000000..854b787 --- /dev/null +++ b/src/mux/demux/ts.rs @@ -0,0 +1,4721 @@ +use std::collections::BTreeMap; +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use super::super::MuxError; +use super::super::MuxTrackKind; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{ + CandidateSample, CompositeTrackCandidate, SegmentedMuxSourceSegment, SegmentedMuxSourceSpec, + StagedSample, TrackCandidate, build_btrt_from_sample_sizes, + build_generic_media_sample_entry_box, direct_ingest_handler_name, direct_ingest_mux_policy, + read_exact_at_sync, with_force_empty_sync_sample_table, +}; +#[cfg(feature = "async")] +use super::aac::scan_adts_segmented_async; +use super::aac::scan_adts_segmented_sync; +#[cfg(feature = "async")] +use super::ac3::scan_ac3_segmented_async; +use super::ac3::{build_ac3_sample_entry_box_with_btrt, scan_ac3_segmented_sync}; +#[cfg(feature = "async")] +use super::ac4::scan_ac4_segmented_async; +use super::ac4::scan_ac4_segmented_sync; +#[cfg(feature = "async")] +use super::av1::scan_transport_av1_segmented_async; +use super::av1::scan_transport_av1_segmented_sync; +#[cfg(feature = "async")] +use super::avs3::scan_transport_avs3_segmented_async; +use super::avs3::scan_transport_avs3_segmented_sync; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::{append_file_range_segment, read_segmented_bytes_sync}; +#[cfg(feature = "async")] +use super::dts::scan_dts_segmented_async; +use super::dts::{retune_carried_dts_sample_entry_box, scan_dts_segmented_sync}; +#[cfg(feature = "async")] +use super::eac3::scan_eac3_segmented_async; +use super::eac3::{build_eac3_sample_entry_box_with_btrt, scan_eac3_segmented_sync}; +#[cfg(feature = "async")] +use super::h264::stage_annex_b_h264_segmented_async; +use super::h264::{ + authored_h264_media_duration, + build_h264_sample_entry_from_avc_config_with_box_type_and_options, + retune_carried_h264_sample_entry_box, stage_annex_b_h264_segmented_sync, +}; +#[cfg(feature = "async")] +use super::h265::stage_annex_b_h265_segmented_async; +use super::h265::stage_annex_b_h265_segmented_sync; +#[cfg(feature = "async")] +use super::latm::scan_latm_segmented_async; +use super::latm::scan_latm_segmented_sync; +#[cfg(feature = "async")] +use super::mhas::scan_mhas_segmented_async; +use super::mhas::{build_mhas_sample_entry_box_with_btrt, scan_mhas_segmented_sync}; +use super::mp3::{build_mp3_sample_entry_box, parse_mp3_frame_header}; +#[cfg(feature = "async")] +use super::mp4v::scan_mp4v_segmented_async; +use super::mp4v::{ + build_direct_mp4v_sample_entry_box_with_total_duration, scan_mp4v_segmented_sync, +}; +#[cfg(feature = "async")] +use super::mpeg2v::scan_mpeg2v_segmented_async; +use super::mpeg2v::{build_transport_mpeg2v_sample_entry_box, scan_mpeg2v_segmented_sync}; +#[cfg(feature = "async")] +use super::truehd::scan_truehd_segmented_async; +use super::truehd::{build_truehd_sample_entry_box_with_btrt, scan_truehd_segmented_sync}; +#[cfg(feature = "async")] +use super::vvc::stage_annex_b_vvc_segmented_async; +use super::vvc::stage_annex_b_vvc_segmented_sync; +use crate::FourCc; +use crate::boxes::iso14496_12::{AVCDecoderConfiguration, DvsC}; + +const TS_PACKET_SIZE: usize = 188; +const PAT_PID: u16 = 0x0000; +const STREAM_TYPE_MPEG1_AUDIO: u8 = 0x03; +const STREAM_TYPE_MPEG2_AUDIO: u8 = 0x04; +const STREAM_TYPE_PRIVATE_DATA: u8 = 0x06; +const STREAM_TYPE_AAC_AUDIO: u8 = 0x0F; +const STREAM_TYPE_MPEG4_VIDEO: u8 = 0x10; +const STREAM_TYPE_LATM_AUDIO: u8 = 0x11; +const STREAM_TYPE_MHAS_MAIN: u8 = 0x2D; +const STREAM_TYPE_MHAS_AUX: u8 = 0x2E; +const STREAM_TYPE_H264_VIDEO: u8 = 0x1B; +const STREAM_TYPE_H265_VIDEO: u8 = 0x24; +const STREAM_TYPE_VVC_VIDEO: u8 = 0x33; +const STREAM_TYPE_VVC_VIDEO_TEMPORAL: u8 = 0x34; +const STREAM_TYPE_AC3_AUDIO: u8 = 0x81; +const STREAM_TYPE_DTS_AUDIO: u8 = 0x82; +const STREAM_TYPE_TRUEHD_AUDIO: u8 = 0x83; +const STREAM_TYPE_EAC3_AUDIO: u8 = 0x84; +const STREAM_TYPE_AVS3_VIDEO: u8 = 0xD4; +const PMT_DESCRIPTOR_DVB_TELETEXT: u8 = 0x56; +const PMT_DESCRIPTOR_DVB_SUBTITLE: u8 = 0x59; +const PMT_DESCRIPTOR_PRIVATE_DATA_SPECIFIER: u8 = 0x5F; +const PMT_DESCRIPTOR_REGISTRATION: u8 = 0x05; +const PMT_DESCRIPTOR_AV1_VIDEO: u8 = 0x80; +const PES_STREAM_ID_PRIVATE_STREAM_1: u8 = 0xBD; +const DIRECT_SUBTITLE_TIMESCALE: u32 = 1_000; +const DIRECT_SUBTITLE_SAMPLE_DURATION: u32 = 1_000; +const TRANSPORT_VIDEO_TIMESCALE: u32 = 90_000; +const TRANSPORT_AC3_ANCHOR_JITTER_TOLERANCE_90K: u32 = 16; +const TRANSPORT_INITIAL_PCR_WRAP_BACKOFF_27M: u64 = 4_800; +const TRANSPORT_FLAT_AUDIO_INTERLEAVE_TARGET_90K: u64 = 45_000; +const TRANSPORT_MP4V_FALLBACK_SAMPLE_DURATION: u32 = 3_000; +const REGISTRATION_AVSV: [u8; 4] = *b"AVSV"; +const REGISTRATION_AV01: [u8; 4] = *b"AV01"; +const REGISTRATION_AC4: [u8; 4] = *b"AC-4"; +const REGISTRATION_DTS1: [u8; 4] = *b"DTS1"; +const REGISTRATION_DTS2: [u8; 4] = *b"DTS2"; +const REGISTRATION_DTS3: [u8; 4] = *b"DTS3"; +const PRIVATE_DATA_SPECIFIER_AOMS: [u8; 4] = *b"AOMS"; +const TRANSPORT_MAX_PCR_27M: u64 = 2_576_980_377_811; +const TRANSPORT_MAX_PCR_90K: u64 = 8_589_934_592; + +#[derive(Clone, Copy)] +enum TransportTrackKind { + Mp3, + Aac, + Latm, + Mhas, + Ac3, + Truehd, + Eac3, + Ac4, + Dts, + Mpeg2v, + Av1, + Avs3, + Mp4v, + H264, + H265, + Vvc, + DvbSubtitle, + DvbTeletext, +} + +#[derive(Clone, Copy)] +struct DvbSubtitleConfig { + language: [u8; 3], + composition_page_id: u16, + ancillary_page_id: u16, + subtitle_type: u8, +} + +struct TransportTrackBuilder { + pid: u16, + kind: TransportTrackKind, + segments: Vec, + total_size: u64, + sample_offsets: Vec, + pts_anchors: Vec, + language: [u8; 3], + dvb_subtitle: Option, + av1_descriptor: Option<[u8; 4]>, + avs3_config: Option>, +} + +#[derive(Clone, Copy)] +struct TransportTimestampAnchor { + sample_offset: u64, + pts_90k: u64, +} + +struct TransportProgramClockState { + pcr_pid: Option, + before_last_pcr_value: Option, + last_pcr_value: Option, + pcr_base_offset_27m: i64, + last_continuity_counter: Option, +} + +pub(in crate::mux) struct TransportStreamScanResult { + pub(in crate::mux) composite_tracks: Vec, + pub(in crate::mux) flat_chunk_sample_counts_by_track_id: BTreeMap>, +} + +struct FinalizedTransportTrack { + composite_track: CompositeTrackCandidate, + flat_chunk_sample_counts: Option>, +} + +impl FinalizedTransportTrack { + fn without_flat_chunk_sample_counts(composite_track: CompositeTrackCandidate) -> Self { + Self { + composite_track, + flat_chunk_sample_counts: None, + } + } +} + +struct ParsedTransportPesHeader { + payload_offset: usize, + pts_90k: Option, +} + +fn new_transport_track_builder(pid: u16, kind: TransportTrackKind) -> TransportTrackBuilder { + TransportTrackBuilder { + pid, + kind, + segments: Vec::new(), + total_size: 0, + sample_offsets: Vec::new(), + pts_anchors: Vec::new(), + language: *b"und", + dvb_subtitle: None, + av1_descriptor: None, + avs3_config: None, + } +} + +fn translate_transport_timestamp_90k( + state: &TransportProgramClockState, + pts_90k: u64, +) -> Result { + let mut translated = i128::from(pts_90k); + if let Some(last_pcr_value) = state.last_pcr_value { + if last_pcr_value > (9 * TRANSPORT_MAX_PCR_27M) / 10 && pts_90k < TRANSPORT_MAX_PCR_90K / 10 + { + translated = translated + .checked_add(i128::from(TRANSPORT_MAX_PCR_90K)) + .ok_or(MuxError::LayoutOverflow( + "transport-stream timestamp loop translation", + ))?; + } + if TRANSPORT_MAX_PCR_90K < 20_000 + pts_90k && last_pcr_value < 27_000_000 { + translated = translated + .checked_add(i128::from(state.pcr_base_offset_27m / 300)) + .and_then(|value| value.checked_sub(i128::from(TRANSPORT_MAX_PCR_90K))) + .ok_or(MuxError::LayoutOverflow( + "transport-stream timestamp translated offset", + ))?; + } else { + translated = translated + .checked_add(i128::from(state.pcr_base_offset_27m / 300)) + .ok_or(MuxError::LayoutOverflow( + "transport-stream timestamp translated offset", + ))?; + } + } + if translated < 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: "transport stream".to_string(), + message: "translated transport timestamp became negative".to_string(), + }); + } + u64::try_from(translated) + .map_err(|_| MuxError::LayoutOverflow("transport-stream timestamp translation")) +} + +fn update_transport_program_clock( + state: &mut TransportProgramClockState, + pid: u16, + adaptation_control: u8, + continuity_counter: u8, + discontinuity_signaled: bool, + pcr_27m: u64, +) { + if state.pcr_pid != Some(pid) { + return; + } + let continuity_break = match (state.last_continuity_counter, state.before_last_pcr_value) { + (Some(previous_cc), Some(_)) if adaptation_control == 0x02 => { + continuity_counter != previous_cc + } + (Some(previous_cc), Some(_)) => continuity_counter != ((previous_cc + 1) & 0x0F), + _ => false, + }; + state.last_continuity_counter = Some(continuity_counter); + + let prev_diff_27m = match (state.before_last_pcr_value, state.last_pcr_value) { + (Some(before_last), Some(last)) => i64::try_from(last) + .ok() + .and_then(|last| { + i64::try_from(before_last) + .ok() + .map(|before_last| last - before_last) + }) + .unwrap_or(0), + _ => 0, + }; + let previous_last_pcr = state.last_pcr_value; + state.before_last_pcr_value = previous_last_pcr; + state.last_pcr_value = Some(pcr_27m.max(1)); + + let Some(before_last_pcr) = previous_last_pcr else { + if pcr_27m > (9 * TRANSPORT_MAX_PCR_27M) / 10 { + let synthetic_previous = if TRANSPORT_MAX_PCR_27M.saturating_sub(pcr_27m) + > TRANSPORT_INITIAL_PCR_WRAP_BACKOFF_27M + { + TRANSPORT_MAX_PCR_27M - TRANSPORT_INITIAL_PCR_WRAP_BACKOFF_27M + } else { + TRANSPORT_MAX_PCR_27M + }; + state.before_last_pcr_value = Some(synthetic_previous); + } + return; + }; + let diff_27m = i64::try_from(pcr_27m) + .ok() + .and_then(|current| { + i64::try_from(before_last_pcr) + .ok() + .map(|previous| current - previous) + }) + .unwrap_or(0); + let prev_diff_in_us = prev_diff_27m / 27; + let diff_in_us = diff_27m / 27; + let mut adjust_pcr = false; + + if discontinuity_signaled || continuity_break { + let delta_from_previous = (diff_in_us - prev_diff_in_us).abs(); + if (-200_000..0).contains(&diff_in_us) || (diff_in_us > 0 && delta_from_previous < 200_000) + { + } else { + adjust_pcr = true; + } + } else if diff_27m.unsigned_abs() > 270_000_000_u64 { + if pcr_27m < before_last_pcr && (TRANSPORT_MAX_PCR_27M - before_last_pcr) < 5_400_000 { + state.pcr_base_offset_27m = state + .pcr_base_offset_27m + .saturating_add(i64::try_from(TRANSPORT_MAX_PCR_27M).unwrap()); + return; + } + if (-200_000..0).contains(&diff_in_us) || pcr_27m < before_last_pcr { + adjust_pcr = true; + } + } + + if adjust_pcr { + let expected_next_pcr = i128::from(before_last_pcr) + .checked_add(i128::from(prev_diff_in_us) * 27) + .unwrap_or(i128::from(before_last_pcr)); + let delta = expected_next_pcr - i128::from(pcr_27m); + state.pcr_base_offset_27m = + state + .pcr_base_offset_27m + .saturating_add(i64::try_from(delta).unwrap_or_else(|_| { + if delta.is_negative() { + i64::MIN + } else { + i64::MAX + } + })); + } +} + +fn transport_track_uses_full_au(kind: TransportTrackKind) -> bool { + matches!( + kind, + TransportTrackKind::Av1 + | TransportTrackKind::Avs3 + | TransportTrackKind::DvbSubtitle + | TransportTrackKind::DvbTeletext + ) +} + +fn transport_track_uses_program_clock_translation(kind: TransportTrackKind) -> bool { + matches!( + kind, + TransportTrackKind::Mp3 + | TransportTrackKind::Aac + | TransportTrackKind::Latm + | TransportTrackKind::Mhas + | TransportTrackKind::Ac3 + | TransportTrackKind::Truehd + | TransportTrackKind::Eac3 + | TransportTrackKind::Ac4 + | TransportTrackKind::Dts + ) +} + +pub(in crate::mux) fn scan_transport_stream_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + validate_transport_stream_sync(&mut file, file_size, spec)?; + + let mut pmt_pid = None::; + let mut builders = BTreeMap::::new(); + let mut program_clock = TransportProgramClockState { + pcr_pid: None, + before_last_pcr_value: None, + last_pcr_value: None, + pcr_base_offset_27m: 0, + last_continuity_counter: None, + }; + let mut offset = 0_u64; + while offset + u64::try_from(TS_PACKET_SIZE).unwrap() <= file_size { + let mut packet = [0_u8; TS_PACKET_SIZE]; + read_exact_at_sync( + &mut file, + offset, + &mut packet, + spec, + "truncated MPEG transport stream packet", + )?; + parse_transport_packet_sync( + spec, + &packet, + offset, + &mut pmt_pid, + &mut builders, + &mut program_clock, + )?; + offset += u64::try_from(TS_PACKET_SIZE).unwrap(); + } + + finalize_transport_tracks_sync(path, spec, &mut file, builders) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_transport_stream_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + validate_transport_stream_async(&mut file, file_size, spec).await?; + + let mut pmt_pid = None::; + let mut builders = BTreeMap::::new(); + let mut program_clock = TransportProgramClockState { + pcr_pid: None, + before_last_pcr_value: None, + last_pcr_value: None, + pcr_base_offset_27m: 0, + last_continuity_counter: None, + }; + let mut offset = 0_u64; + while offset + u64::try_from(TS_PACKET_SIZE).unwrap() <= file_size { + let mut packet = [0_u8; TS_PACKET_SIZE]; + read_exact_at_async( + &mut file, + offset, + &mut packet, + spec, + "truncated MPEG transport stream packet", + ) + .await?; + parse_transport_packet_sync( + spec, + &packet, + offset, + &mut pmt_pid, + &mut builders, + &mut program_clock, + )?; + offset += u64::try_from(TS_PACKET_SIZE).unwrap(); + } + + finalize_transport_tracks_async(path, spec, &mut file, builders).await +} + +fn validate_transport_stream_sync( + file: &mut File, + file_size: u64, + spec: &str, +) -> Result<(), MuxError> { + if file_size < u64::try_from(TS_PACKET_SIZE * 2).unwrap() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport stream input is too short to validate packet sync".to_string(), + }); + } + let mut prefix = [0_u8; TS_PACKET_SIZE * 2]; + read_exact_at_sync( + file, + 0, + &mut prefix, + spec, + "transport stream input is truncated before the first two packets", + )?; + if prefix[0] != 0x47 || prefix[TS_PACKET_SIZE] != 0x47 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "input does not carry MPEG transport stream packet sync bytes".to_string(), + }); + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn validate_transport_stream_async( + file: &mut TokioFile, + file_size: u64, + spec: &str, +) -> Result<(), MuxError> { + if file_size < u64::try_from(TS_PACKET_SIZE * 2).unwrap() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport stream input is too short to validate packet sync".to_string(), + }); + } + let mut prefix = [0_u8; TS_PACKET_SIZE * 2]; + read_exact_at_async( + file, + 0, + &mut prefix, + spec, + "transport stream input is truncated before the first two packets", + ) + .await?; + if prefix[0] != 0x47 || prefix[TS_PACKET_SIZE] != 0x47 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "input does not carry MPEG transport stream packet sync bytes".to_string(), + }); + } + Ok(()) +} + +fn parse_transport_packet_sync( + spec: &str, + packet: &[u8; TS_PACKET_SIZE], + packet_offset: u64, + pmt_pid: &mut Option, + builders: &mut BTreeMap, + program_clock: &mut TransportProgramClockState, +) -> Result<(), MuxError> { + if packet[0] != 0x47 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("missing transport-stream sync byte at packet offset {packet_offset}"), + }); + } + if packet[1] & 0x80 != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream packets with transport errors are not supported".to_string(), + }); + } + if packet[3] & 0xC0 != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "scrambled transport-stream packets are not supported".to_string(), + }); + } + let payload_unit_start = packet[1] & 0x40 != 0; + let pid = (u16::from(packet[1] & 0x1F) << 8) | u16::from(packet[2]); + let adaptation_control = (packet[3] >> 4) & 0x03; + if adaptation_control == 0x00 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream packets with reserved adaptation-control state are not supported" + .to_string(), + }); + } + if adaptation_control == 0x02 { + if let Some((discontinuity_signaled, pcr_27m)) = parse_transport_packet_pcr(spec, packet)? { + update_transport_program_clock( + program_clock, + pid, + adaptation_control, + packet[3] & 0x0F, + discontinuity_signaled, + pcr_27m, + ); + } + return Ok(()); + } + let mut payload_offset = 4usize; + if adaptation_control == 0x03 { + if let Some((discontinuity_signaled, pcr_27m)) = parse_transport_packet_pcr(spec, packet)? { + update_transport_program_clock( + program_clock, + pid, + adaptation_control, + packet[3] & 0x0F, + discontinuity_signaled, + pcr_27m, + ); + } + let adaptation_length = usize::from(packet[4]); + payload_offset = + payload_offset + .checked_add(1 + adaptation_length) + .ok_or(MuxError::LayoutOverflow( + "transport-stream adaptation field", + ))?; + if payload_offset > TS_PACKET_SIZE { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream adaptation field overflowed the packet payload" + .to_string(), + }); + } + } + if payload_offset >= TS_PACKET_SIZE { + return Ok(()); + } + let payload = &packet[payload_offset..]; + + if pid == PAT_PID { + if payload_unit_start && let Some(found_pmt_pid) = parse_pat_section(spec, payload)? { + *pmt_pid = Some(found_pmt_pid); + } + return Ok(()); + } + if Some(pid) == *pmt_pid { + if payload_unit_start { + parse_pmt_section(spec, payload, builders, program_clock)?; + } + return Ok(()); + } + let Some(builder) = builders.get_mut(&pid) else { + return Ok(()); + }; + if payload_unit_start { + let pes_header = parse_ts_pes_header(spec, payload, builder.kind)?; + if let Some(pts_90k) = pes_header.pts_90k { + builder.pts_anchors.push(TransportTimestampAnchor { + sample_offset: builder.total_size, + pts_90k: if transport_track_uses_program_clock_translation(builder.kind) { + translate_transport_timestamp_90k(program_clock, pts_90k)? + } else { + pts_90k + }, + }); + } + if transport_track_uses_full_au(builder.kind) { + builder.sample_offsets.push(builder.total_size); + } + let pes_payload = &payload[pes_header.payload_offset..]; + if !pes_payload.is_empty() { + append_file_range_segment( + &mut builder.segments, + &mut builder.total_size, + packet_offset + u64::try_from(payload_offset + pes_header.payload_offset).unwrap(), + u32::try_from(pes_payload.len()) + .map_err(|_| MuxError::LayoutOverflow("transport-stream PES payload"))?, + ); + } + } else if !payload.is_empty() { + append_file_range_segment( + &mut builder.segments, + &mut builder.total_size, + packet_offset + u64::try_from(payload_offset).unwrap(), + u32::try_from(payload.len()) + .map_err(|_| MuxError::LayoutOverflow("transport-stream packet payload"))?, + ); + } + Ok(()) +} + +fn parse_pat_section(spec: &str, payload: &[u8]) -> Result, MuxError> { + if payload.is_empty() { + return Ok(None); + } + let pointer_field = usize::from(payload[0]); + let start = 1usize + .checked_add(pointer_field) + .ok_or(MuxError::LayoutOverflow("PAT pointer field"))?; + if payload.len() < start + 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated PAT section".to_string(), + }); + } + if payload[start] != 0x00 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "unsupported PAT table id".to_string(), + }); + } + let section_length = + usize::from(u16::from_be_bytes([payload[start + 1], payload[start + 2]]) & 0x0FFF); + if payload.len() < start + 3 + section_length { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated PAT payload".to_string(), + }); + } + validate_transport_section_crc(spec, "PAT", &payload[start..start + 3 + section_length])?; + let mut entry_offset = start + 8; + let section_end = start + 3 + section_length - 4; + let mut found = None::; + while entry_offset + 4 <= section_end { + let program_number = u16::from_be_bytes([payload[entry_offset], payload[entry_offset + 1]]); + let pid = (u16::from(payload[entry_offset + 2] & 0x1F) << 8) + | u16::from(payload[entry_offset + 3]); + if program_number != 0 && found.is_none() { + found = Some(pid); + } + entry_offset += 4; + } + Ok(found) +} + +fn parse_transport_packet_pcr( + spec: &str, + packet: &[u8; TS_PACKET_SIZE], +) -> Result, MuxError> { + let adaptation_control = (packet[3] >> 4) & 0x03; + if adaptation_control != 0x02 && adaptation_control != 0x03 { + return Ok(None); + } + let adaptation_length = usize::from(packet[4]); + if adaptation_length == 0 { + return Ok(None); + } + if 5 + adaptation_length > TS_PACKET_SIZE { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream adaptation field overflowed the packet payload".to_string(), + }); + } + let adaptation_flags = packet[5]; + if adaptation_flags & 0x10 == 0 { + return Ok(None); + } + if adaptation_length < 7 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream adaptation field carried a truncated PCR payload" + .to_string(), + }); + } + let pcr_bytes = &packet[6..12]; + let pcr_base = (u64::from(pcr_bytes[0]) << 25) + | (u64::from(pcr_bytes[1]) << 17) + | (u64::from(pcr_bytes[2]) << 9) + | (u64::from(pcr_bytes[3]) << 1) + | u64::from(pcr_bytes[4] >> 7); + let pcr_ext = (u16::from(pcr_bytes[4] & 0x01) << 8) | u16::from(pcr_bytes[5]); + Ok(Some(( + adaptation_flags & 0x80 != 0, + pcr_base + .checked_mul(300) + .and_then(|value| value.checked_add(u64::from(pcr_ext))) + .ok_or(MuxError::LayoutOverflow("transport-stream PCR timestamp"))?, + ))) +} + +fn parse_pmt_section( + spec: &str, + payload: &[u8], + builders: &mut BTreeMap, + program_clock: &mut TransportProgramClockState, +) -> Result<(), MuxError> { + if payload.is_empty() { + return Ok(()); + } + let pointer_field = usize::from(payload[0]); + let start = 1usize + .checked_add(pointer_field) + .ok_or(MuxError::LayoutOverflow("PMT pointer field"))?; + if payload.len() < start + 12 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated PMT section".to_string(), + }); + } + if payload[start] != 0x02 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "unsupported PMT table id".to_string(), + }); + } + let section_length = + usize::from(u16::from_be_bytes([payload[start + 1], payload[start + 2]]) & 0x0FFF); + if payload.len() < start + 3 + section_length { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated PMT payload".to_string(), + }); + } + validate_transport_section_crc(spec, "PMT", &payload[start..start + 3 + section_length])?; + let pcr_pid = (u16::from(payload[start + 8] & 0x1F) << 8) | u16::from(payload[start + 9]); + program_clock.pcr_pid = Some(pcr_pid); + let program_info_length = + usize::from(u16::from_be_bytes([payload[start + 10], payload[start + 11]]) & 0x0FFF); + let mut entry_offset = start + 12 + program_info_length; + let section_end = start + 3 + section_length - 4; + while entry_offset + 5 <= section_end { + let stream_type = payload[entry_offset]; + let elementary_pid = (u16::from(payload[entry_offset + 1] & 0x1F) << 8) + | u16::from(payload[entry_offset + 2]); + let es_info_length = usize::from( + u16::from_be_bytes([payload[entry_offset + 3], payload[entry_offset + 4]]) & 0x0FFF, + ); + let es_info_start = entry_offset + .checked_add(5) + .ok_or(MuxError::LayoutOverflow("PMT elementary-stream info start"))?; + let es_info_end = es_info_start + .checked_add(es_info_length) + .ok_or(MuxError::LayoutOverflow("PMT elementary-stream info end"))?; + if es_info_end > section_end { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated PMT elementary-stream descriptor payload".to_string(), + }); + } + let es_info = &payload[es_info_start..es_info_end]; + match stream_type { + STREAM_TYPE_MPEG1_AUDIO | STREAM_TYPE_MPEG2_AUDIO => { + builders.entry(elementary_pid).or_insert_with(|| { + new_transport_track_builder(elementary_pid, TransportTrackKind::Mp3) + }); + } + STREAM_TYPE_AAC_AUDIO => { + builders.entry(elementary_pid).or_insert_with(|| { + new_transport_track_builder(elementary_pid, TransportTrackKind::Aac) + }); + } + STREAM_TYPE_LATM_AUDIO => { + builders.entry(elementary_pid).or_insert_with(|| { + new_transport_track_builder(elementary_pid, TransportTrackKind::Latm) + }); + } + STREAM_TYPE_MHAS_MAIN | STREAM_TYPE_MHAS_AUX => { + builders.entry(elementary_pid).or_insert_with(|| { + new_transport_track_builder(elementary_pid, TransportTrackKind::Mhas) + }); + } + STREAM_TYPE_AC3_AUDIO => { + builders.entry(elementary_pid).or_insert_with(|| { + new_transport_track_builder(elementary_pid, TransportTrackKind::Ac3) + }); + } + STREAM_TYPE_DTS_AUDIO => { + builders.entry(elementary_pid).or_insert_with(|| { + new_transport_track_builder(elementary_pid, TransportTrackKind::Dts) + }); + } + STREAM_TYPE_TRUEHD_AUDIO => { + builders.entry(elementary_pid).or_insert_with(|| { + new_transport_track_builder(elementary_pid, TransportTrackKind::Truehd) + }); + } + STREAM_TYPE_EAC3_AUDIO => { + builders.entry(elementary_pid).or_insert_with(|| { + new_transport_track_builder(elementary_pid, TransportTrackKind::Eac3) + }); + } + 0x02 => { + builders.entry(elementary_pid).or_insert_with(|| { + new_transport_track_builder(elementary_pid, TransportTrackKind::Mpeg2v) + }); + } + STREAM_TYPE_MPEG4_VIDEO => { + builders.entry(elementary_pid).or_insert_with(|| { + new_transport_track_builder(elementary_pid, TransportTrackKind::Mp4v) + }); + } + STREAM_TYPE_H264_VIDEO => { + builders.entry(elementary_pid).or_insert_with(|| { + new_transport_track_builder(elementary_pid, TransportTrackKind::H264) + }); + } + STREAM_TYPE_H265_VIDEO => { + builders.entry(elementary_pid).or_insert_with(|| { + new_transport_track_builder(elementary_pid, TransportTrackKind::H265) + }); + } + STREAM_TYPE_VVC_VIDEO | STREAM_TYPE_VVC_VIDEO_TEMPORAL => { + builders.entry(elementary_pid).or_insert_with(|| { + new_transport_track_builder(elementary_pid, TransportTrackKind::Vvc) + }); + } + STREAM_TYPE_PRIVATE_DATA => { + if let Some(av1_descriptor) = parse_transport_av1_video_descriptor(spec, es_info)? { + builders.entry(elementary_pid).or_insert_with(|| { + let mut builder = + new_transport_track_builder(elementary_pid, TransportTrackKind::Av1); + builder.av1_descriptor = Some(av1_descriptor); + builder + }); + } else if let Some(track) = parse_transport_private_data_track(spec, es_info)? { + builders.entry(elementary_pid).or_insert_with(|| { + let mut builder = new_transport_track_builder(elementary_pid, track.kind); + builder.language = track.language; + builder.dvb_subtitle = track.dvb_subtitle; + builder + }); + } + } + STREAM_TYPE_AVS3_VIDEO => { + let avs3_config = parse_transport_avs3_video_descriptor(spec, es_info)?; + builders.entry(elementary_pid).or_insert_with(|| { + let mut builder = + new_transport_track_builder(elementary_pid, TransportTrackKind::Avs3); + builder.avs3_config = Some(avs3_config); + builder + }); + } + other => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "transport-stream stream type 0x{other:02X} is not supported on the native direct-ingest path yet" + ), + }); + } + } + entry_offset = es_info_end; + } + Ok(()) +} + +fn validate_transport_section_crc( + spec: &str, + table_name: &str, + section: &[u8], +) -> Result<(), MuxError> { + if section.len() < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated {table_name} CRC32 field"), + }); + } + let crc_offset = section.len() - 4; + let expected_crc = + u32::from_be_bytes(section[crc_offset..].try_into().expect("4-byte CRC field")); + let actual_crc = mpeg2ts_section_crc32(§ion[..crc_offset]); + if actual_crc != expected_crc { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("{table_name} section failed CRC32 validation"), + }); + } + Ok(()) +} + +fn mpeg2ts_section_crc32(data: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFF_u32; + for byte in data { + crc ^= u32::from(*byte) << 24; + for _ in 0..8 { + crc = if crc & 0x8000_0000 != 0 { + (crc << 1) ^ 0x04C1_1DB7 + } else { + crc << 1 + }; + } + } + crc +} + +fn parse_transport_av1_video_descriptor( + spec: &str, + es_info: &[u8], +) -> Result, MuxError> { + let mut descriptor_offset = 0usize; + let mut saw_registration = false; + let mut saw_private_data_specifier = false; + let mut av1_descriptor = None::<[u8; 4]>; + + while descriptor_offset < es_info.len() { + if es_info.len() - descriptor_offset < 2 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated PMT descriptor header".to_string(), + }); + } + let descriptor_tag = es_info[descriptor_offset]; + let descriptor_length = usize::from(es_info[descriptor_offset + 1]); + let descriptor_end = descriptor_offset + .checked_add(2 + descriptor_length) + .ok_or(MuxError::LayoutOverflow("PMT descriptor length"))?; + if descriptor_end > es_info.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated PMT descriptor payload".to_string(), + }); + } + let descriptor_payload = &es_info[descriptor_offset + 2..descriptor_end]; + match descriptor_tag { + PMT_DESCRIPTOR_REGISTRATION if descriptor_payload == REGISTRATION_AV01 => { + if saw_registration { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "multiple transport-stream AV1 registration descriptors are not supported on the native direct-ingest path yet" + .to_string(), + }); + } + saw_registration = true; + } + PMT_DESCRIPTOR_PRIVATE_DATA_SPECIFIER + if descriptor_payload == PRIVATE_DATA_SPECIFIER_AOMS => + { + if saw_private_data_specifier { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "multiple transport-stream AV1 private-data specifier descriptors are not supported on the native direct-ingest path yet" + .to_string(), + }); + } + saw_private_data_specifier = true; + } + PMT_DESCRIPTOR_AV1_VIDEO => { + if descriptor_length != 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AV1 carried descriptor 0x80 with an invalid payload size" + .to_string(), + }); + } + if av1_descriptor + .replace([ + descriptor_payload[0], + descriptor_payload[1], + descriptor_payload[2], + descriptor_payload[3], + ]) + .is_some() + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "multiple transport-stream AV1 descriptor 0x80 payloads are not supported on the native direct-ingest path yet" + .to_string(), + }); + } + } + _ => {} + } + descriptor_offset = descriptor_end; + } + + match (saw_registration, saw_private_data_specifier, av1_descriptor) { + (false, false, None) => Ok(None), + (true, true, Some(descriptor)) => Ok(Some(descriptor)), + _ => Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AV1 private-data carriage must carry AV01 registration, AOMS private-data specifier, and descriptor 0x80 on the native direct-ingest path" + .to_string(), + }), + } +} + +fn parse_transport_avs3_video_descriptor(spec: &str, es_info: &[u8]) -> Result, MuxError> { + let mut descriptor_offset = 0usize; + let mut found = None::>; + while descriptor_offset < es_info.len() { + if es_info.len() - descriptor_offset < 2 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated PMT descriptor header".to_string(), + }); + } + let descriptor_tag = es_info[descriptor_offset]; + let descriptor_length = usize::from(es_info[descriptor_offset + 1]); + let descriptor_end = descriptor_offset + .checked_add(2 + descriptor_length) + .ok_or(MuxError::LayoutOverflow("PMT descriptor length"))?; + if descriptor_end > es_info.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated PMT descriptor payload".to_string(), + }); + } + if descriptor_tag == PMT_DESCRIPTOR_REGISTRATION { + let descriptor_payload = &es_info[descriptor_offset + 2..descriptor_end]; + if descriptor_payload.starts_with(®ISTRATION_AVSV) { + if descriptor_payload.len() < 14 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AVS3 registration descriptor did not carry the full decoder configuration payload" + .to_string(), + }); + } + let config = descriptor_payload[4..14].to_vec(); + if found.replace(config).is_some() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "multiple AVS3 registration descriptors are not supported on the native direct-ingest path yet" + .to_string(), + }); + } + } + } + descriptor_offset = descriptor_end; + } + found.ok_or(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AVS3 video carriage is missing its AVSV registration descriptor payload" + .to_string(), + }) +} + +#[derive(Clone, Copy)] +struct TransportPrivateDataTrack { + kind: TransportTrackKind, + language: [u8; 3], + dvb_subtitle: Option, +} + +fn parse_transport_private_data_track( + spec: &str, + es_info: &[u8], +) -> Result, MuxError> { + let mut descriptor_offset = 0usize; + let mut found = None::; + while descriptor_offset < es_info.len() { + if es_info.len() - descriptor_offset < 2 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated PMT descriptor header".to_string(), + }); + } + let descriptor_tag = es_info[descriptor_offset]; + let descriptor_length = usize::from(es_info[descriptor_offset + 1]); + let descriptor_end = descriptor_offset + .checked_add(2 + descriptor_length) + .ok_or(MuxError::LayoutOverflow("PMT descriptor length"))?; + if descriptor_end > es_info.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated PMT descriptor payload".to_string(), + }); + } + let descriptor_payload = &es_info[descriptor_offset + 2..descriptor_end]; + let parsed = match descriptor_tag { + PMT_DESCRIPTOR_REGISTRATION => { + parse_transport_registration_descriptor(descriptor_payload) + } + PMT_DESCRIPTOR_DVB_SUBTITLE => { + Some(parse_dvb_subtitle_descriptor(spec, descriptor_payload)?) + } + PMT_DESCRIPTOR_DVB_TELETEXT => { + Some(parse_dvb_teletext_descriptor(spec, descriptor_payload)?) + } + _ => None, + }; + if let Some(parsed) = parsed + && found.replace(parsed).is_some() + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "multiple transport-stream private-data descriptor track declarations are not supported on the native direct-ingest path yet" + .to_string(), + }); + } + descriptor_offset = descriptor_end; + } + Ok(found) +} + +fn parse_transport_registration_descriptor( + descriptor_payload: &[u8], +) -> Option { + let registration = descriptor_payload.get(..4)?; + if registration == REGISTRATION_DTS1 + || registration == REGISTRATION_DTS2 + || registration == REGISTRATION_DTS3 + { + return Some(TransportPrivateDataTrack { + kind: TransportTrackKind::Dts, + language: *b"und", + dvb_subtitle: None, + }); + } + if registration == REGISTRATION_AC4 { + return Some(TransportPrivateDataTrack { + kind: TransportTrackKind::Ac4, + language: *b"und", + dvb_subtitle: None, + }); + } + None +} + +fn parse_dvb_subtitle_descriptor( + spec: &str, + descriptor_payload: &[u8], +) -> Result { + if descriptor_payload.len() < 8 || !descriptor_payload.len().is_multiple_of(8) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream DVB subtitle descriptors must contain whole 8-byte service entries".to_string(), + }); + } + let entry = &descriptor_payload[..8]; + Ok(TransportPrivateDataTrack { + kind: TransportTrackKind::DvbSubtitle, + language: [entry[0], entry[1], entry[2]], + dvb_subtitle: Some(DvbSubtitleConfig { + language: [entry[0], entry[1], entry[2]], + subtitle_type: entry[3], + composition_page_id: u16::from_be_bytes([entry[4], entry[5]]), + ancillary_page_id: u16::from_be_bytes([entry[6], entry[7]]), + }), + }) +} + +fn parse_dvb_teletext_descriptor( + spec: &str, + descriptor_payload: &[u8], +) -> Result { + if descriptor_payload.len() < 5 || !descriptor_payload.len().is_multiple_of(5) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream DVB teletext descriptors must contain whole 5-byte service entries".to_string(), + }); + } + let entry = &descriptor_payload[..5]; + Ok(TransportPrivateDataTrack { + kind: TransportTrackKind::DvbTeletext, + language: [entry[0], entry[1], entry[2]], + dvb_subtitle: None, + }) +} + +fn parse_ts_pes_header( + spec: &str, + payload: &[u8], + kind: TransportTrackKind, +) -> Result { + if payload.len() < 9 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated transport-stream PES header".to_string(), + }); + } + if payload[..3] != [0x00, 0x00, 0x01] { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream payload-unit start did not begin with a PES start code" + .to_string(), + }); + } + match kind { + TransportTrackKind::Mp3 + | TransportTrackKind::Aac + | TransportTrackKind::Latm + | TransportTrackKind::Mhas + if !(0xC0..=0xDF).contains(&payload[3]) => + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream PES stream id is not a supported MPEG audio stream" + .to_string(), + }); + } + TransportTrackKind::Ac3 + | TransportTrackKind::Truehd + | TransportTrackKind::Eac3 + | TransportTrackKind::Ac4 + | TransportTrackKind::Dts + if payload[3] != PES_STREAM_ID_PRIVATE_STREAM_1 => + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream PES stream id is not a supported private audio stream" + .to_string(), + }); + } + TransportTrackKind::Mpeg2v + | TransportTrackKind::Av1 + | TransportTrackKind::Avs3 + | TransportTrackKind::Mp4v + if !(0xE0..=0xEF).contains(&payload[3]) => + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream PES stream id is not a supported MPEG video stream" + .to_string(), + }); + } + TransportTrackKind::H264 | TransportTrackKind::H265 | TransportTrackKind::Vvc + if !(0xE0..=0xEF).contains(&payload[3]) => + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream PES stream id is not a supported video stream" + .to_string(), + }); + } + TransportTrackKind::DvbSubtitle | TransportTrackKind::DvbTeletext + if payload[3] != PES_STREAM_ID_PRIVATE_STREAM_1 => + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream PES stream id is not a supported private subtitle or teletext stream" + .to_string(), + }); + } + _ => {} + } + if payload[6] & 0xC0 != 0x80 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "unsupported transport-stream PES header flags".to_string(), + }); + } + let pts_dts_flags = payload[7] & 0xC0; + let header_data_length = usize::from(payload[8]); + let payload_offset = 9usize + .checked_add(header_data_length) + .ok_or(MuxError::LayoutOverflow("transport-stream PES header"))?; + if payload_offset > payload.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated transport-stream PES optional header".to_string(), + }); + } + let pts_90k = match pts_dts_flags { + 0x00 => None, + 0x80 | 0xC0 => { + if header_data_length < 5 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream PES header declared timestamps without enough header bytes" + .to_string(), + }); + } + Some(parse_ts_pes_timestamp(spec, &payload[9..14])?) + } + _ => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream PES header used a reserved PTS/DTS flag state" + .to_string(), + }); + } + }; + Ok(ParsedTransportPesHeader { + payload_offset, + pts_90k, + }) +} + +fn parse_ts_pes_timestamp(spec: &str, encoded: &[u8]) -> Result { + if encoded.len() < 5 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream PES timestamp is truncated".to_string(), + }); + } + if encoded[0] & 0x01 == 0 || encoded[2] & 0x01 == 0 || encoded[4] & 0x01 == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream PES timestamp marker bits were invalid".to_string(), + }); + } + Ok((u64::from((encoded[0] >> 1) & 0x07) << 30) + | (u64::from(encoded[1]) << 22) + | (u64::from((encoded[2] >> 1) & 0x7F) << 15) + | (u64::from(encoded[3]) << 7) + | u64::from((encoded[4] >> 1) & 0x7F)) +} + +fn rescale_transport_audio_time( + value: i64, + source_timescale: u32, + spec: &str, + label: &str, +) -> Result { + if source_timescale == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("{label} used an invalid zero timescale"), + }); + } + + let sign = value.signum(); + let magnitude = value.unsigned_abs(); + let scaled = magnitude + .checked_mul(u64::from(TRANSPORT_VIDEO_TIMESCALE)) + .ok_or(MuxError::LayoutOverflow( + "transport-stream audio time rescale", + ))?; + let divisor = u64::from(source_timescale); + let normalized = scaled + .checked_add(divisor / 2) + .ok_or(MuxError::LayoutOverflow( + "transport-stream audio time rounding", + ))? + / divisor; + let normalized = i64::try_from(normalized) + .map_err(|_| MuxError::LayoutOverflow("transport-stream audio time rescale"))?; + Ok(normalized * sign) +} + +fn build_transport_timestamped_audio_samples( + spec: &str, + label: &str, + samples: Vec, + source_timescale: u32, + pts_anchors: &[TransportTimestampAnchor], +) -> Result<(u32, Vec), MuxError> { + fn rescaled_transport_audio_sample( + spec: &str, + label: &str, + sample: &StagedSample, + duration: u32, + source_timescale: u32, + ) -> Result { + Ok(CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration, + composition_time_offset: i32::try_from(rescale_transport_audio_time( + i64::from(sample.composition_time_offset), + source_timescale, + spec, + label, + )?) + .map_err(|_| { + MuxError::LayoutOverflow("transport-stream timestamped audio composition offset") + })?, + is_sync_sample: sample.is_sync_sample, + }) + } + + fn floored_transport_audio_samples( + spec: &str, + label: &str, + samples: &[StagedSample], + source_timescale: u32, + ) -> Result, MuxError> { + samples + .iter() + .map(|sample| { + let duration = u64::from(sample.duration) + .checked_mul(u64::from(TRANSPORT_VIDEO_TIMESCALE)) + .ok_or(MuxError::LayoutOverflow( + "transport-stream timestamped audio duration rescale", + ))? + / u64::from(source_timescale); + let duration = u32::try_from(duration).map_err(|_| { + MuxError::LayoutOverflow("transport-stream timestamped audio duration") + })?; + rescaled_transport_audio_sample(spec, label, sample, duration, source_timescale) + }) + .collect() + } + + fn wrapped_transport_audio_anchor_delta( + current_pts: u64, + next_pts: u64, + ) -> Result { + let delta = (i128::from(next_pts) - i128::from(current_pts)).rem_euclid(1_i128 << 32); + u32::try_from(delta) + .map_err(|_| MuxError::LayoutOverflow("transport-stream timestamped audio duration")) + } + + if pts_anchors.is_empty() { + return Ok(( + TRANSPORT_VIDEO_TIMESCALE, + floored_transport_audio_samples(spec, label, &samples, source_timescale)?, + )); + } + + let mut anchors_by_offset = BTreeMap::::new(); + for anchor in pts_anchors { + match anchors_by_offset.insert(anchor.sample_offset, anchor.pts_90k) { + Some(existing) if existing != anchor.pts_90k => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{label} carried multiple conflicting PES timestamps for the same sample boundary" + ), + }); + } + _ => {} + } + } + if samples.is_empty() || !anchors_by_offset.contains_key(&samples[0].data_offset) { + return Ok(( + TRANSPORT_VIDEO_TIMESCALE, + floored_transport_audio_samples(spec, label, &samples, source_timescale)?, + )); + } + + let intrinsic = floored_transport_audio_samples(spec, label, &samples, source_timescale)?; + let mut anchored_sample_indexes = Vec::with_capacity(anchors_by_offset.len()); + for (anchor_offset, pts_90k) in anchors_by_offset { + let sample_index = samples + .partition_point(|sample| sample.data_offset < anchor_offset) + .min(samples.len() - 1); + anchored_sample_indexes.push((sample_index, pts_90k)); + } + anchored_sample_indexes.dedup_by_key(|(index, _)| *index); + if anchored_sample_indexes.len() >= 2 { + let mut durations = intrinsic + .iter() + .map(|sample| sample.duration) + .collect::>(); + for anchor_window in anchored_sample_indexes.windows(2) { + let (start_index, current_pts) = anchor_window[0]; + let (end_index, next_pts) = anchor_window[1]; + if end_index <= start_index { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{label} carried non-monotonic PES anchor ordering across sample boundaries" + ), + }); + } + let anchored_span_duration = + wrapped_transport_audio_anchor_delta(current_pts, next_pts)?; + let prefix_duration = durations[start_index..end_index - 1] + .iter() + .fold(0_u64, |total, duration| total + u64::from(*duration)); + let residual_duration = u64::from(anchored_span_duration) + .checked_sub(prefix_duration) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{label} carried PES anchors that resolve to a smaller duration than the intrinsic sample cadence" + ), + })?; + durations[end_index - 1] = u32::try_from(residual_duration).map_err(|_| { + MuxError::LayoutOverflow("transport-stream timestamped audio anchored duration") + })?; + } + let rescaled = samples + .iter() + .zip(durations) + .map(|(sample, duration)| { + rescaled_transport_audio_sample(spec, label, sample, duration, source_timescale) + }) + .collect::, MuxError>>()?; + return Ok((TRANSPORT_VIDEO_TIMESCALE, rescaled)); + } + + Ok((TRANSPORT_VIDEO_TIMESCALE, intrinsic)) +} + +fn transport_anchor_sample_indexes( + spec: &str, + label: &str, + samples: &[StagedSample], + pts_anchors: &[TransportTimestampAnchor], +) -> Result>, MuxError> { + if pts_anchors.is_empty() || samples.is_empty() { + return Ok(None); + } + + let mut anchors_by_offset = BTreeMap::::new(); + for anchor in pts_anchors { + match anchors_by_offset.insert(anchor.sample_offset, anchor.pts_90k) { + Some(existing) if existing != anchor.pts_90k => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{label} carried multiple conflicting PES timestamps for the same sample boundary" + ), + }); + } + _ => {} + } + } + if !anchors_by_offset.contains_key(&samples[0].data_offset) { + return Ok(None); + } + + let mut anchored_sample_indexes = Vec::with_capacity(anchors_by_offset.len()); + for (anchor_offset, pts_90k) in anchors_by_offset { + let sample_index = samples + .partition_point(|sample| sample.data_offset < anchor_offset) + .min(samples.len() - 1); + anchored_sample_indexes.push((sample_index, pts_90k)); + } + anchored_sample_indexes.dedup_by_key(|(index, _)| *index); + Ok(Some(anchored_sample_indexes)) +} + +type TransportAnchoredAc3Samples = (u32, Vec, Option>); + +fn build_transport_ac3_packet_anchored_samples( + spec: &str, + samples: Vec, + source_timescale: u32, + pts_anchors: &[TransportTimestampAnchor], +) -> Result { + let Some(anchored_sample_indexes) = transport_anchor_sample_indexes( + spec, + "transport-stream AC-3 audio", + &samples, + pts_anchors, + )? + else { + return Ok(( + TRANSPORT_VIDEO_TIMESCALE, + samples + .iter() + .map(|sample| { + let duration = u64::from(sample.duration) + .checked_mul(u64::from(TRANSPORT_VIDEO_TIMESCALE)) + .ok_or(MuxError::LayoutOverflow( + "transport-stream AC-3 duration rescale", + ))? + / u64::from(source_timescale); + Ok(CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: u32::try_from(duration).map_err(|_| { + MuxError::LayoutOverflow("transport-stream AC-3 duration") + })?, + composition_time_offset: 0, + is_sync_sample: sample.is_sync_sample, + }) + }) + .collect::, MuxError>>()?, + None, + )); + }; + + let intrinsic_duration = u32::try_from( + u64::from(1_536_u32) + .checked_mul(u64::from(TRANSPORT_VIDEO_TIMESCALE)) + .ok_or(MuxError::LayoutOverflow( + "transport-stream AC-3 duration rescale", + ))? + / u64::from(source_timescale), + ) + .map_err(|_| MuxError::LayoutOverflow("transport-stream AC-3 duration"))?; + let mut durations = vec![intrinsic_duration; samples.len()]; + let mut deferred_span_adjustment = 0_i64; + for anchor_window in anchored_sample_indexes.windows(2) { + let (start_index, current_pts) = anchor_window[0]; + let (end_index, next_pts) = anchor_window[1]; + if end_index <= start_index { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AC-3 carried non-monotonic PES anchor ordering across sample boundaries" + .to_string(), + }); + } + let anchored_span_duration = u32::try_from( + (i128::from(next_pts) - i128::from(current_pts)).rem_euclid(1_i128 << 32), + ) + .map_err(|_| MuxError::LayoutOverflow("transport-stream AC-3 anchored duration"))?; + let sample_span = end_index - start_index; + let expected_floor_span_duration = u32::try_from( + u64::from(1_536_u32) + .checked_mul(u64::try_from(sample_span).unwrap()) + .and_then(|value| value.checked_mul(u64::from(TRANSPORT_VIDEO_TIMESCALE))) + .ok_or(MuxError::LayoutOverflow( + "transport-stream AC-3 anchored span duration", + ))? + / u64::from(source_timescale), + ) + .map_err(|_| MuxError::LayoutOverflow("transport-stream AC-3 anchored duration"))?; + let span_adjustment = + i64::from(anchored_span_duration) - i64::from(expected_floor_span_duration); + if anchored_span_duration.abs_diff(expected_floor_span_duration) + <= TRANSPORT_AC3_ANCHOR_JITTER_TOLERANCE_90K + { + deferred_span_adjustment = deferred_span_adjustment + .checked_add(span_adjustment) + .ok_or(MuxError::LayoutOverflow( + "transport-stream AC-3 anchored residual carry", + ))?; + continue; + } + let prefix_duration = u64::from(intrinsic_duration) + .checked_mul(u64::try_from(sample_span - 1).unwrap()) + .ok_or(MuxError::LayoutOverflow( + "transport-stream AC-3 anchored span duration", + ))?; + let residual_duration = i64::try_from( + u64::from(anchored_span_duration) + .checked_sub(prefix_duration) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AC-3 carried PES anchors that resolve to a smaller duration than the intrinsic sample cadence" + .to_string(), + })?, + ) + .map_err(|_| MuxError::LayoutOverflow("transport-stream AC-3 duration"))? + .checked_add(deferred_span_adjustment) + .ok_or(MuxError::LayoutOverflow( + "transport-stream AC-3 anchored residual carry", + ))?; + if residual_duration <= 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AC-3 carried PES anchors that resolve to a smaller duration than the intrinsic sample cadence" + .to_string(), + }); + } + durations[end_index - 1] = u32::try_from(residual_duration) + .map_err(|_| MuxError::LayoutOverflow("transport-stream AC-3 duration"))?; + deferred_span_adjustment = 0; + } + + let flat_chunk_sample_counts = build_transport_ac3_flat_chunk_sample_counts(spec, &durations)?; + + Ok(( + TRANSPORT_VIDEO_TIMESCALE, + samples + .iter() + .zip(durations) + .map(|(sample, duration)| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration, + composition_time_offset: 0, + is_sync_sample: sample.is_sync_sample, + }) + .collect(), + Some(flat_chunk_sample_counts), + )) +} + +fn build_transport_ac3_flat_chunk_sample_counts( + spec: &str, + sample_durations: &[u32], +) -> Result, MuxError> { + let mut counts = Vec::new(); + let mut current_count = 0_u32; + let mut current_duration = 0_u64; + let mut sample_index = 0usize; + + while sample_index < sample_durations.len() { + let duration = u64::from(sample_durations[sample_index]); + if current_count != 0 + && current_duration + .checked_add(duration) + .ok_or(MuxError::LayoutOverflow( + "transport-stream AC-3 chunk duration", + ))? + > TRANSPORT_FLAT_AUDIO_INTERLEAVE_TARGET_90K + { + if duration > TRANSPORT_FLAT_AUDIO_INTERLEAVE_TARGET_90K { + current_count = current_count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow( + "transport-stream AC-3 chunk sample count", + ))?; + counts.push(current_count); + current_count = 0; + current_duration = 0; + sample_index += 1; + if sample_index < sample_durations.len() { + counts.push(1); + sample_index += 1; + } + continue; + } + + counts.push(current_count); + current_count = 0; + current_duration = 0; + continue; + } + + current_count = current_count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow( + "transport-stream AC-3 chunk sample count", + ))?; + current_duration = + current_duration + .checked_add(duration) + .ok_or(MuxError::LayoutOverflow( + "transport-stream AC-3 chunk duration", + ))?; + sample_index += 1; + } + + if current_count != 0 { + counts.push(current_count); + } + if counts.is_empty() { + return Err(MuxError::InvalidChunkPlan { + track_id: 0, + message: format!("{spec} produced no flat transport-stream AC-3 chunk boundaries"), + }); + } + Ok(counts) +} + +fn finalize_transport_tracks_sync( + path: &Path, + spec: &str, + file: &mut File, + builders: BTreeMap, +) -> Result { + if builders.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport stream input did not contain any supported native direct-ingest streams" + .to_string(), + }); + } + let mut tracks = Vec::new(); + let mut flat_chunk_sample_counts_by_track_id = BTreeMap::new(); + let mut skipped_empty_text_tracks = false; + for (track_index, builder) in builders.into_values().enumerate() { + if matches!( + builder.kind, + TransportTrackKind::DvbSubtitle | TransportTrackKind::DvbTeletext + ) && builder.sample_offsets.is_empty() + { + skipped_empty_text_tracks = true; + continue; + } + let finalized = match builder.kind { + TransportTrackKind::Mp3 => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_mp3_track_sync(path, spec, file, track_index, builder)?, + ), + TransportTrackKind::Aac => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_aac_track_sync(path, spec, file, track_index, builder)?, + ), + TransportTrackKind::Latm => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_latm_track_sync(path, spec, file, track_index, builder)?, + ), + TransportTrackKind::Mhas => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_mhas_track_sync(path, spec, file, track_index, builder)?, + ), + TransportTrackKind::Ac3 => { + finalize_transport_ac3_track_sync(path, spec, file, track_index, builder)? + } + TransportTrackKind::Truehd => { + FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_truehd_track_sync(path, spec, file, track_index, builder)?, + ) + } + TransportTrackKind::Eac3 => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_eac3_track_sync(path, spec, file, track_index, builder)?, + ), + TransportTrackKind::Ac4 => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_ac4_track_sync(path, spec, file, track_index, builder)?, + ), + TransportTrackKind::Dts => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_dts_track_sync(path, spec, file, track_index, builder)?, + ), + TransportTrackKind::Mpeg2v => { + FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_mpeg2v_track_sync(path, spec, file, track_index, builder)?, + ) + } + TransportTrackKind::Av1 => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_av1_track_sync(path, spec, file, track_index, builder)?, + ), + TransportTrackKind::Avs3 => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_avs3_track_sync(path, spec, file, track_index, builder)?, + ), + TransportTrackKind::Mp4v => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_mp4v_track_sync(path, spec, file, track_index, builder)?, + ), + TransportTrackKind::H264 => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_h264_track_sync(path, spec, file, track_index, builder)?, + ), + TransportTrackKind::H265 => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_h265_track_sync(path, spec, file, track_index, builder)?, + ), + TransportTrackKind::Vvc => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_vvc_track_sync(path, spec, file, track_index, builder)?, + ), + TransportTrackKind::DvbSubtitle => { + FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_dvb_subtitle_track_sync(path, spec, track_index, builder)?, + ) + } + TransportTrackKind::DvbTeletext => { + FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_dvb_teletext_track_sync(path, spec, track_index, builder)?, + ) + } + }; + if let Some(chunk_sample_counts) = finalized.flat_chunk_sample_counts { + flat_chunk_sample_counts_by_track_id.insert( + finalized.composite_track.track.track_id, + chunk_sample_counts, + ); + } + tracks.push(finalized.composite_track); + } + if tracks.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: if skipped_empty_text_tracks { + "transport stream input did not contain any subtitle or teletext PES payload units" + .to_string() + } else { + "transport stream input did not contain any supported native direct-ingest streams" + .to_string() + }, + }); + } + Ok(TransportStreamScanResult { + composite_tracks: tracks, + flat_chunk_sample_counts_by_track_id, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_tracks_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + builders: BTreeMap, +) -> Result { + if builders.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport stream input did not contain any supported native direct-ingest streams" + .to_string(), + }); + } + let mut tracks = Vec::new(); + let mut flat_chunk_sample_counts_by_track_id = BTreeMap::new(); + let mut skipped_empty_text_tracks = false; + for (track_index, builder) in builders.into_values().enumerate() { + if matches!( + builder.kind, + TransportTrackKind::DvbSubtitle | TransportTrackKind::DvbTeletext + ) && builder.sample_offsets.is_empty() + { + skipped_empty_text_tracks = true; + continue; + } + let finalized = match builder.kind { + TransportTrackKind::Mp3 => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_mp3_track_async(path, spec, file, track_index, builder).await?, + ), + TransportTrackKind::Aac => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_aac_track_async(path, spec, file, track_index, builder).await?, + ), + TransportTrackKind::Latm => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_latm_track_async(path, spec, file, track_index, builder).await?, + ), + TransportTrackKind::Mhas => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_mhas_track_async(path, spec, file, track_index, builder).await?, + ), + TransportTrackKind::Ac3 => { + finalize_transport_ac3_track_async(path, spec, file, track_index, builder).await? + } + TransportTrackKind::Truehd => { + FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_truehd_track_async(path, spec, file, track_index, builder) + .await?, + ) + } + TransportTrackKind::Eac3 => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_eac3_track_async(path, spec, file, track_index, builder).await?, + ), + TransportTrackKind::Ac4 => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_ac4_track_async(path, spec, file, track_index, builder).await?, + ), + TransportTrackKind::Dts => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_dts_track_async(path, spec, file, track_index, builder).await?, + ), + TransportTrackKind::Mpeg2v => { + FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_mpeg2v_track_async(path, spec, file, track_index, builder) + .await?, + ) + } + TransportTrackKind::Av1 => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_av1_track_async(path, spec, file, track_index, builder).await?, + ), + TransportTrackKind::Avs3 => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_avs3_track_async(path, spec, file, track_index, builder).await?, + ), + TransportTrackKind::Mp4v => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_mp4v_track_async(path, spec, file, track_index, builder).await?, + ), + TransportTrackKind::H264 => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_h264_track_async(path, spec, file, track_index, builder).await?, + ), + TransportTrackKind::H265 => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_h265_track_async(path, spec, file, track_index, builder).await?, + ), + TransportTrackKind::Vvc => FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_vvc_track_async(path, spec, file, track_index, builder).await?, + ), + TransportTrackKind::DvbSubtitle => { + FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_dvb_subtitle_track_async(path, spec, track_index, builder) + .await?, + ) + } + TransportTrackKind::DvbTeletext => { + FinalizedTransportTrack::without_flat_chunk_sample_counts( + finalize_transport_dvb_teletext_track_async(path, spec, track_index, builder) + .await?, + ) + } + }; + if let Some(chunk_sample_counts) = finalized.flat_chunk_sample_counts { + flat_chunk_sample_counts_by_track_id.insert( + finalized.composite_track.track.track_id, + chunk_sample_counts, + ); + } + tracks.push(finalized.composite_track); + } + if tracks.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: if skipped_empty_text_tracks { + "transport stream input did not contain any subtitle or teletext PES payload units" + .to_string() + } else { + "transport stream input did not contain any supported native direct-ingest streams" + .to_string() + }, + }); + } + Ok(TransportStreamScanResult { + composite_tracks: tracks, + flat_chunk_sample_counts_by_track_id, + }) +} + +fn finalize_transport_mp3_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let mut offset = 0_u64; + let mut expected = None::<(u32, u16, u32)>; + let mut samples = Vec::new(); + while offset < builder.total_size { + if builder.total_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated MPEG audio frame header inside transport-stream payload" + .to_string(), + }); + } + let mut header = [0_u8; 4]; + read_segmented_bytes_sync( + file, + &builder.segments, + builder.total_size, + offset, + &mut header, + spec, + "truncated MPEG audio frame header inside transport-stream payload", + )?; + let parsed = parse_mp3_frame_header(&header, offset, spec)?; + if offset + .checked_add(u64::from(parsed.frame_length)) + .is_none_or(|end| end > builder.total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "truncated MPEG audio frame at logical transport-stream offset {offset}" + ), + }); + } + let descriptor = ( + parsed.sample_rate, + parsed.channel_count, + parsed.sample_duration, + ); + if let Some(expected) = expected { + if expected != descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream MPEG audio frames changed sample rate or channel layout mid-stream" + .to_string(), + }); + } + } else { + expected = Some(descriptor); + } + samples.push(StagedSample { + data_offset: offset, + data_size: parsed.frame_length, + duration: parsed.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = + offset + .checked_add(u64::from(parsed.frame_length)) + .ok_or(MuxError::LayoutOverflow( + "transport-stream MPEG audio offset", + ))?; + } + let (sample_rate, channel_count, _) = + expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport stream input did not contain any MPEG audio frames".to_string(), + })?; + let sample_entry_box = build_mp3_sample_entry_box( + sample_rate, + channel_count, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?; + let (timescale, samples) = build_transport_timestamped_audio_samples( + spec, + "transport-stream MPEG audio", + samples, + sample_rate, + &builder.pts_anchors, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mp3"), + mux_policy: direct_ingest_mux_policy("mp3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn finalize_transport_aac_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = scan_adts_segmented_sync(file, &builder.segments, builder.total_size, spec)?; + let (timescale, samples) = build_transport_timestamped_audio_samples( + spec, + "transport-stream AAC audio", + parsed.samples, + parsed.sample_rate, + &builder.pts_anchors, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("aac"), + mux_policy: direct_ingest_mux_policy("aac", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn finalize_transport_latm_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = scan_latm_segmented_sync(file, &builder.segments, builder.total_size, path, spec)?; + let super::latm::ParsedLatmTrack { + sample_rate, + sample_entry_box, + segmented_source, + samples: staged_samples, + } = parsed; + let (timescale, samples) = build_transport_timestamped_audio_samples( + spec, + "transport-stream LATM audio", + staged_samples, + sample_rate, + &builder.pts_anchors, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("latm"), + mux_policy: direct_ingest_mux_policy("latm", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: segmented_source, + }) +} + +fn finalize_transport_mhas_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = scan_mhas_segmented_sync(file, &builder.segments, builder.total_size, spec)?; + let super::mhas::ParsedMhasTrack { + sample_rate, + sample_entry_box: direct_sample_entry_box, + samples: staged_samples, + .. + } = parsed; + let (timescale, samples) = build_transport_timestamped_audio_samples( + spec, + "transport-stream MHAS audio", + staged_samples, + sample_rate, + &builder.pts_anchors, + )?; + let sample_entry_box = if timescale == sample_rate { + direct_sample_entry_box + } else { + let btrt = build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + timescale, + )?; + build_mhas_sample_entry_box_with_btrt(sample_rate, btrt)? + }; + let sync_sample_table_mode = if samples.iter().all(|sample| sample.is_sync_sample) { + super::super::SyncSampleTableMode::ForceFirstOnly + } else { + super::super::SyncSampleTableMode::Auto + }; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mhas"), + mux_policy: direct_ingest_mux_policy("mhas", MuxTrackKind::Audio) + .with_sync_sample_table_mode(sync_sample_table_mode), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn finalize_transport_mp4v_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = scan_mp4v_segmented_sync(file, &builder.segments, builder.total_size, spec)?; + let transport_samples = rescale_transport_mp4v_samples( + parsed.samples, + parsed.timescale, + &builder.pts_anchors, + spec, + )?; + let total_duration_override = transport_mp4v_total_duration_override(&transport_samples); + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Video, + timescale: TRANSPORT_VIDEO_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("mp4v"), + mux_policy: direct_ingest_mux_policy("mp4v", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: build_direct_mp4v_sample_entry_box_with_total_duration( + parsed.width, + parsed.height, + &parsed.decoder_specific_info, + TRANSPORT_VIDEO_TIMESCALE, + transport_samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + total_duration_override, + )?, + source_edit_media_time: None, + samples: transport_samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn finalize_transport_mpeg2v_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = scan_mpeg2v_segmented_sync(file, &builder.segments, builder.total_size, spec)?; + let (transport_samples, source_edit_media_time) = build_transport_mpeg2v_samples( + spec, + parsed.samples, + parsed.timescale, + &builder.pts_anchors, + )?; + let sample_entry_box = build_transport_mpeg2v_sample_entry_box( + parsed.width, + parsed.height, + &parsed.decoder_specific_info, + parsed.object_type_indication, + TRANSPORT_VIDEO_TIMESCALE, + transport_samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + parsed.pixel_aspect_ratio, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Video, + timescale: TRANSPORT_VIDEO_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("mpeg2v"), + mux_policy: direct_ingest_mux_policy("mpeg2v", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box, + source_edit_media_time, + samples: transport_samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn finalize_transport_av1_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let av1_descriptor = builder + .av1_descriptor + .ok_or(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream AV1 track builder is missing its carried descriptor payload" + .to_string(), + })?; + let parsed = scan_transport_av1_segmented_sync( + path, + file, + &builder.segments, + builder.total_size, + &builder.sample_offsets, + av1_descriptor, + spec, + )?; + let samples = build_transport_av1_samples(spec, parsed.samples, &builder.pts_anchors)?; + let source_spec = match parsed.source { + super::av1::ParsedAv1TrackSource::Segmented(segmented) => segmented, + super::av1::ParsedAv1TrackSource::File => return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AV1 direct ingest did not produce a segmented transformed source" + .to_string(), + }), + }; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Video, + timescale: TRANSPORT_VIDEO_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("av1"), + mux_policy: direct_ingest_mux_policy("av1", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec, + }) +} + +fn finalize_transport_avs3_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let avs3_config = builder + .avs3_config + .as_deref() + .ok_or(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AVS3 track builder is missing its carried decoder configuration" + .to_string(), + })?; + let parsed = scan_transport_avs3_segmented_sync( + file, + &builder.segments, + builder.total_size, + &builder.sample_offsets, + avs3_config, + spec, + )?; + let transport_samples = rescale_transport_avs3_samples(parsed.samples, parsed.timescale, spec)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Video, + timescale: TRANSPORT_VIDEO_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("avs3"), + mux_policy: with_force_empty_sync_sample_table(direct_ingest_mux_policy( + "avs3", + MuxTrackKind::Video, + )), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples: transport_samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn finalize_transport_ac3_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = scan_ac3_segmented_sync(file, &builder.segments, builder.total_size, spec)?; + let (timescale, samples, flat_chunk_sample_counts) = + build_transport_ac3_packet_anchored_samples( + spec, + parsed.samples, + parsed.sample_rate, + &builder.pts_anchors, + )?; + let sample_entry_box = build_ac3_sample_entry_box_with_btrt( + &parsed.decoder_config, + timescale, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?; + Ok(FinalizedTransportTrack { + composite_track: CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("ac3"), + mux_policy: direct_ingest_mux_policy("ac3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }, + flat_chunk_sample_counts, + }) +} + +fn finalize_transport_truehd_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = scan_truehd_segmented_sync(file, &builder.segments, builder.total_size, spec)?; + let (timescale, samples) = build_transport_timestamped_audio_samples( + spec, + "transport-stream TrueHD audio", + parsed.samples, + parsed.sample_rate, + &builder.pts_anchors, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("truehd"), + mux_policy: direct_ingest_mux_policy("truehd", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: build_truehd_sample_entry_box_with_btrt( + parsed.descriptor, + build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + timescale, + )?, + )?, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn finalize_transport_eac3_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = scan_eac3_segmented_sync(file, &builder.segments, builder.total_size, spec)?; + let (timescale, samples) = build_transport_timestamped_audio_samples( + spec, + "transport-stream E-AC-3 audio", + parsed.samples, + parsed.sample_rate, + &builder.pts_anchors, + )?; + let rebuilt_sample_entry_box = build_eac3_sample_entry_box_with_btrt( + &parsed.decoder_config, + build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + timescale, + )?, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("eac3"), + mux_policy: direct_ingest_mux_policy("eac3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: rebuilt_sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn finalize_transport_ac4_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = scan_ac4_segmented_sync(file, &builder.segments, builder.total_size, spec)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale: parsed.media_time_scale, + language: *b"und", + handler_name: direct_ingest_handler_name("ac4"), + mux_policy: direct_ingest_mux_policy("ac4", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples: parsed + .samples + .into_iter() + .map(|sample| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect(), + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn finalize_transport_dts_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = scan_dts_segmented_sync(file, &builder.segments, builder.total_size, spec)?; + let sample_entry_box = retune_carried_dts_sample_entry_box(&parsed.sample_entry_box)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale: parsed.media_timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("dts"), + mux_policy: direct_ingest_mux_policy("dts", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples: parsed + .samples + .into_iter() + .map(|sample| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect(), + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn finalize_transport_h264_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + stage_annex_b_h264_segmented_sync(path, file, &builder.segments, builder.total_size, spec)?; + let mut samples = + rescale_transport_h26x_samples(parsed.samples, parsed.timescale, spec, "H.264")?; + let mut source_edit_media_time = rescale_transport_h26x_edit_media_time( + parsed.source_edit_media_time, + parsed.timescale, + spec, + "H.264", + )?; + if source_edit_media_time.unwrap_or(0) != 0 + || samples + .iter() + .any(|sample| sample.composition_time_offset != 0) + { + align_transport_h264_presentation_time( + &mut samples, + &mut source_edit_media_time, + &builder.pts_anchors, + )?; + normalize_transport_h264_wraparound_samples(&mut samples, &mut source_edit_media_time)?; + } else { + source_edit_media_time = None; + } + let sample_entry_box = retune_carried_h264_sample_entry_box( + &parsed.sample_entry_box, + TRANSPORT_VIDEO_TIMESCALE, + Some(authored_h264_media_duration(samples.iter().map( + |sample| (sample.duration, sample.composition_time_offset), + ))?), + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + true, + transport_h264_sample_entry_has_colr(&parsed.sample_entry_box)?, + )?; + let sample_entry_box = ensure_transport_h264_colorized_sample_entry(&sample_entry_box)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Video, + timescale: TRANSPORT_VIDEO_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("h264"), + mux_policy: direct_ingest_mux_policy("h264", MuxTrackKind::Video), + width: parsed.track_width, + height: parsed.track_height, + sample_entry_box, + source_edit_media_time, + samples, + }, + source_spec: parsed.segmented_source, + }) +} + +fn finalize_transport_h265_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + stage_annex_b_h265_segmented_sync(path, file, &builder.segments, builder.total_size, spec)?; + let samples = rescale_transport_h26x_samples(parsed.samples, parsed.timescale, spec, "H.265")?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Video, + timescale: TRANSPORT_VIDEO_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("h265"), + mux_policy: direct_ingest_mux_policy("h265", MuxTrackKind::Video), + width: parsed.track_width, + height: parsed.track_height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: rescale_transport_h26x_edit_media_time( + parsed.source_edit_media_time, + parsed.timescale, + spec, + "H.265", + )?, + samples, + }, + source_spec: parsed.segmented_source, + }) +} + +fn finalize_transport_vvc_track_sync( + path: &Path, + spec: &str, + file: &mut File, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + stage_annex_b_vvc_segmented_sync(path, file, &builder.segments, builder.total_size, spec)?; + let samples = build_transport_vvc_samples(spec, parsed.samples, &builder.pts_anchors)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Video, + timescale: TRANSPORT_VIDEO_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("vvc"), + mux_policy: direct_ingest_mux_policy("vvc", MuxTrackKind::Video), + width: parsed.track_width, + height: parsed.track_height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: parsed.segmented_source, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_mp3_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let mut offset = 0_u64; + let mut expected = None::<(u32, u16, u32)>; + let mut samples = Vec::new(); + while offset < builder.total_size { + if builder.total_size - offset < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated MPEG audio frame header inside transport-stream payload" + .to_string(), + }); + } + let mut header = [0_u8; 4]; + read_segmented_bytes_async( + file, + &builder.segments, + builder.total_size, + offset, + &mut header, + spec, + "truncated MPEG audio frame header inside transport-stream payload", + ) + .await?; + let parsed = parse_mp3_frame_header(&header, offset, spec)?; + if offset + .checked_add(u64::from(parsed.frame_length)) + .is_none_or(|end| end > builder.total_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "truncated MPEG audio frame at logical transport-stream offset {offset}" + ), + }); + } + let descriptor = ( + parsed.sample_rate, + parsed.channel_count, + parsed.sample_duration, + ); + if let Some(expected) = expected { + if expected != descriptor { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream MPEG audio frames changed sample rate or channel layout mid-stream" + .to_string(), + }); + } + } else { + expected = Some(descriptor); + } + samples.push(StagedSample { + data_offset: offset, + data_size: parsed.frame_length, + duration: parsed.sample_duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + offset = + offset + .checked_add(u64::from(parsed.frame_length)) + .ok_or(MuxError::LayoutOverflow( + "transport-stream MPEG audio offset", + ))?; + } + let (sample_rate, channel_count, _) = + expected.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport stream input did not contain any MPEG audio frames".to_string(), + })?; + let sample_entry_box = build_mp3_sample_entry_box( + sample_rate, + channel_count, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?; + let (timescale, samples) = build_transport_timestamped_audio_samples( + spec, + "transport-stream MPEG audio", + samples, + sample_rate, + &builder.pts_anchors, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mp3"), + mux_policy: direct_ingest_mux_policy("mp3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_aac_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + scan_adts_segmented_async(file, &builder.segments, builder.total_size, spec).await?; + let (timescale, samples) = build_transport_timestamped_audio_samples( + spec, + "transport-stream AAC audio", + parsed.samples, + parsed.sample_rate, + &builder.pts_anchors, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("aac"), + mux_policy: direct_ingest_mux_policy("aac", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_latm_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + scan_latm_segmented_async(file, &builder.segments, builder.total_size, path, spec).await?; + let super::latm::ParsedLatmTrack { + sample_rate, + sample_entry_box, + segmented_source, + samples: staged_samples, + } = parsed; + let (timescale, samples) = build_transport_timestamped_audio_samples( + spec, + "transport-stream LATM audio", + staged_samples, + sample_rate, + &builder.pts_anchors, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("latm"), + mux_policy: direct_ingest_mux_policy("latm", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: segmented_source, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_mhas_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + scan_mhas_segmented_async(file, &builder.segments, builder.total_size, spec).await?; + let super::mhas::ParsedMhasTrack { + sample_rate, + sample_entry_box: direct_sample_entry_box, + samples: staged_samples, + .. + } = parsed; + let (timescale, samples) = build_transport_timestamped_audio_samples( + spec, + "transport-stream MHAS audio", + staged_samples, + sample_rate, + &builder.pts_anchors, + )?; + let sample_entry_box = if timescale == sample_rate { + direct_sample_entry_box + } else { + let btrt = build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + timescale, + )?; + build_mhas_sample_entry_box_with_btrt(sample_rate, btrt)? + }; + let sync_sample_table_mode = if samples.iter().all(|sample| sample.is_sync_sample) { + super::super::SyncSampleTableMode::ForceFirstOnly + } else { + super::super::SyncSampleTableMode::Auto + }; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mhas"), + mux_policy: direct_ingest_mux_policy("mhas", MuxTrackKind::Audio) + .with_sync_sample_table_mode(sync_sample_table_mode), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_mp4v_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + scan_mp4v_segmented_async(file, &builder.segments, builder.total_size, spec).await?; + let transport_samples = rescale_transport_mp4v_samples( + parsed.samples, + parsed.timescale, + &builder.pts_anchors, + spec, + )?; + let total_duration_override = transport_mp4v_total_duration_override(&transport_samples); + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Video, + timescale: TRANSPORT_VIDEO_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("mp4v"), + mux_policy: direct_ingest_mux_policy("mp4v", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: build_direct_mp4v_sample_entry_box_with_total_duration( + parsed.width, + parsed.height, + &parsed.decoder_specific_info, + TRANSPORT_VIDEO_TIMESCALE, + transport_samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + total_duration_override, + )?, + source_edit_media_time: None, + samples: transport_samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_mpeg2v_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + scan_mpeg2v_segmented_async(file, &builder.segments, builder.total_size, spec).await?; + let (transport_samples, source_edit_media_time) = build_transport_mpeg2v_samples( + spec, + parsed.samples, + parsed.timescale, + &builder.pts_anchors, + )?; + let sample_entry_box = build_transport_mpeg2v_sample_entry_box( + parsed.width, + parsed.height, + &parsed.decoder_specific_info, + parsed.object_type_indication, + TRANSPORT_VIDEO_TIMESCALE, + transport_samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + parsed.pixel_aspect_ratio, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Video, + timescale: TRANSPORT_VIDEO_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("mpeg2v"), + mux_policy: direct_ingest_mux_policy("mpeg2v", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box, + source_edit_media_time, + samples: transport_samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_av1_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let av1_descriptor = builder + .av1_descriptor + .ok_or(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream AV1 track builder is missing its carried descriptor payload" + .to_string(), + })?; + let parsed = scan_transport_av1_segmented_async( + path, + file, + &builder.segments, + builder.total_size, + &builder.sample_offsets, + av1_descriptor, + spec, + ) + .await?; + let samples = build_transport_av1_samples(spec, parsed.samples, &builder.pts_anchors)?; + let source_spec = match parsed.source { + super::av1::ParsedAv1TrackSource::Segmented(segmented) => segmented, + super::av1::ParsedAv1TrackSource::File => return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AV1 direct ingest did not produce a segmented transformed source" + .to_string(), + }), + }; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Video, + timescale: TRANSPORT_VIDEO_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("av1"), + mux_policy: direct_ingest_mux_policy("av1", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_avs3_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let avs3_config = builder + .avs3_config + .as_deref() + .ok_or(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AVS3 track builder is missing its carried decoder configuration" + .to_string(), + })?; + let parsed = scan_transport_avs3_segmented_async( + file, + &builder.segments, + builder.total_size, + &builder.sample_offsets, + avs3_config, + spec, + ) + .await?; + let transport_samples = rescale_transport_avs3_samples(parsed.samples, parsed.timescale, spec)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Video, + timescale: TRANSPORT_VIDEO_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("avs3"), + mux_policy: with_force_empty_sync_sample_table(direct_ingest_mux_policy( + "avs3", + MuxTrackKind::Video, + )), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples: transport_samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn rescale_transport_mpeg2v_samples( + samples: Vec, + source_timescale: u32, + spec: &str, +) -> Result, MuxError> { + samples + .into_iter() + .map(|sample| { + Ok(CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: rescale_transport_mpeg2v_time( + i64::from(sample.duration), + source_timescale, + spec, + )? as u32, + composition_time_offset: rescale_transport_mpeg2v_time( + i64::from(sample.composition_time_offset), + source_timescale, + spec, + )? as i32, + is_sync_sample: sample.is_sync_sample, + }) + }) + .collect() +} + +fn build_transport_mpeg2v_samples( + spec: &str, + samples: Vec, + source_timescale: u32, + pts_anchors: &[TransportTimestampAnchor], +) -> Result<(Vec, Option), MuxError> { + if pts_anchors.is_empty() { + return Ok(( + rescale_transport_mpeg2v_samples(samples, source_timescale, spec)?, + None, + )); + } + + if pts_anchors.len() == 1 && samples.len() > 1 { + let duration = TRANSPORT_VIDEO_TIMESCALE / 30; + let transport_samples = samples + .into_iter() + .map(|sample| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration, + composition_time_offset: 0, + is_sync_sample: sample.is_sync_sample, + }) + .collect(); + return Ok((transport_samples, None)); + } + + let mut transport_samples = Vec::with_capacity(samples.len()); + let mut constant_composition_offset = None::; + for sample in samples { + let duration = u32::try_from(rescale_transport_mpeg2v_time( + i64::from(sample.duration), + source_timescale, + spec, + )?) + .map_err(|_| MuxError::LayoutOverflow("transport-stream MPEG-2 video duration"))?; + let composition_time_offset = match constant_composition_offset { + Some(value) => value, + None => { + let value = i32::try_from(duration).map_err(|_| { + MuxError::LayoutOverflow("transport-stream MPEG-2 video composition offset") + })?; + constant_composition_offset = Some(value); + value + } + }; + transport_samples.push(CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration, + composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }); + } + let source_edit_media_time = + constant_composition_offset.map(|offset| u64::try_from(offset).unwrap_or(u64::MAX)); + Ok((transport_samples, source_edit_media_time)) +} + +fn rescale_transport_h26x_samples( + samples: Vec, + source_timescale: u32, + spec: &str, + codec_name: &str, +) -> Result, MuxError> { + samples + .into_iter() + .map(|sample| { + Ok(CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: u32::try_from(rescale_transport_h26x_time( + i64::from(sample.duration), + source_timescale, + spec, + codec_name, + )?) + .map_err(|_| MuxError::LayoutOverflow("transport-stream H26x duration rescale"))?, + composition_time_offset: i32::try_from(rescale_transport_h26x_time( + i64::from(sample.composition_time_offset), + source_timescale, + spec, + codec_name, + )?) + .map_err(|_| { + MuxError::LayoutOverflow("transport-stream H26x composition offset rescale") + })?, + is_sync_sample: sample.is_sync_sample, + }) + }) + .collect() +} + +fn rescale_transport_h26x_edit_media_time( + source_edit_media_time: Option, + source_timescale: u32, + spec: &str, + codec_name: &str, +) -> Result, MuxError> { + source_edit_media_time + .map(|value| { + u64::try_from(rescale_transport_h26x_time( + i64::try_from(value) + .map_err(|_| MuxError::LayoutOverflow("transport-stream edit-media time"))?, + source_timescale, + spec, + codec_name, + )?) + .map_err(|_| MuxError::LayoutOverflow("transport-stream edit-media time")) + }) + .transpose() +} + +fn align_transport_h264_presentation_time( + samples: &mut [CandidateSample], + source_edit_media_time: &mut Option, + pts_anchors: &[TransportTimestampAnchor], +) -> Result<(), MuxError> { + let Some(first_sample) = samples.first() else { + return Ok(()); + }; + let Some(first_anchor_pts) = pts_anchors + .iter() + .find(|anchor| anchor.sample_offset == first_sample.data_offset) + .map(|anchor| anchor.pts_90k) + else { + return Ok(()); + }; + let current_edit_media_time = i64::try_from(source_edit_media_time.unwrap_or(0)) + .map_err(|_| MuxError::LayoutOverflow("transport-stream H.264 edit-media time"))?; + let target_edit_media_time = i64::try_from(first_anchor_pts) + .map_err(|_| MuxError::LayoutOverflow("transport-stream H.264 edit-media time"))?; + let delta = wrapped_transport_h264_edit_delta(target_edit_media_time, current_edit_media_time)?; + let delta = i32::try_from(delta) + .map_err(|_| MuxError::LayoutOverflow("transport-stream H.264 composition realignment"))?; + for sample in samples.iter_mut() { + sample.composition_time_offset = + sample + .composition_time_offset + .checked_add(delta) + .ok_or(MuxError::LayoutOverflow( + "transport-stream H.264 composition realignment", + ))?; + } + let negative_shift = samples + .iter() + .map(|sample| sample.composition_time_offset) + .min() + .unwrap_or(0) + .min(0) + .unsigned_abs(); + if negative_shift != 0 { + let shift = i32::try_from(negative_shift) + .map_err(|_| MuxError::LayoutOverflow("transport-stream H.264 composition shift"))?; + for sample in samples.iter_mut() { + sample.composition_time_offset = + sample.composition_time_offset.checked_add(shift).ok_or( + MuxError::LayoutOverflow("transport-stream H.264 composition shift"), + )?; + } + } + *source_edit_media_time = Some( + first_anchor_pts + .checked_add(u64::from(negative_shift)) + .ok_or(MuxError::LayoutOverflow( + "transport-stream H.264 edit-media time", + ))?, + ); + Ok(()) +} + +fn normalize_transport_h264_wraparound_samples( + samples: &mut Vec, + source_edit_media_time: &mut Option, +) -> Result<(), MuxError> { + let Some(current_edit_media_time) = *source_edit_media_time else { + return Ok(()); + }; + let Some(first_duration) = samples.first().map(|sample| sample.duration) else { + return Ok(()); + }; + if first_duration == 0 { + return Ok(()); + } + let frame_duration = u64::from(first_duration); + if current_edit_media_time < TRANSPORT_MAX_PCR_90K.saturating_sub(frame_duration) { + return Ok(()); + } + + let shift = i32::try_from(first_duration) + .map_err(|_| MuxError::LayoutOverflow("transport-stream H.264 wrap composition shift"))?; + for sample in samples.iter_mut() { + sample.composition_time_offset = + sample + .composition_time_offset + .checked_add(shift) + .ok_or(MuxError::LayoutOverflow( + "transport-stream H.264 wrap composition shift", + ))?; + } + + let normalized_edit_media_time = current_edit_media_time % TRANSPORT_MAX_PCR_90K; + *source_edit_media_time = Some( + normalized_edit_media_time + .checked_add(frame_duration) + .ok_or(MuxError::LayoutOverflow( + "transport-stream H.264 wrap edit-media time", + ))?, + ); + + let mut collapse_index = None; + for index in 1..samples.len().saturating_sub(1) { + let previous = &samples[index - 1]; + let current = &samples[index]; + let next = &samples[index + 1]; + if !current.is_sync_sample + && next.is_sync_sample + && previous.duration == first_duration + && current.duration == first_duration + && current.composition_time_offset == next.composition_time_offset + { + collapse_index = Some(index); + } + } + if let Some(index) = collapse_index { + let extra_duration = samples[index].duration; + samples[index - 1].duration = samples[index - 1] + .duration + .checked_add(extra_duration) + .ok_or(MuxError::LayoutOverflow( + "transport-stream H.264 wrap collapsed sample duration", + ))?; + samples.remove(index); + } + Ok(()) +} + +fn ensure_transport_h264_colorized_sample_entry( + sample_entry_box: &[u8], +) -> Result, MuxError> { + let child_boxes = super::super::mp4::visual_sample_entry_immediate_children(sample_entry_box)?; + let sample_entry_type = FourCc::from_bytes( + sample_entry_box + .get(4..8) + .ok_or(MuxError::LayoutOverflow( + "transport-stream H.264 sample-entry type", + ))? + .try_into() + .map_err(|_| MuxError::LayoutOverflow("transport-stream H.264 sample-entry type"))?, + ); + let mut avcc_box = None::>; + let mut btrt_box = None::>; + let mut colr_box = None::>; + let mut preserved_other_boxes = Vec::new(); + for child_box in child_boxes { + let child_type = FourCc::from_bytes( + child_box + .get(4..8) + .ok_or(MuxError::LayoutOverflow( + "transport-stream H.264 sample-entry child type", + ))? + .try_into() + .map_err(|_| { + MuxError::LayoutOverflow("transport-stream H.264 sample-entry child type") + })?, + ); + match child_type { + value if value == FourCc::from_bytes(*b"avcC") => avcc_box = Some(child_box), + value if value == FourCc::from_bytes(*b"btrt") => btrt_box = Some(child_box), + value if value == FourCc::from_bytes(*b"colr") => colr_box = Some(child_box), + _ => preserved_other_boxes.push(child_box), + } + } + let avcc_box = avcc_box.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: "ts".to_string(), + message: + "transport-stream H.264 sample entry did not contain an `avcC` decoder configuration box" + .to_string(), + })?; + let avcc = super::super::mp4::decode_typed_box::(&avcc_box)?; + let (rebuilt_sample_entry_box, _, _) = + build_h264_sample_entry_from_avc_config_with_box_type_and_options( + &avcc, + sample_entry_type, + "track", + false, + )?; + let mut rebuilt_children = + super::super::mp4::visual_sample_entry_immediate_children(&rebuilt_sample_entry_box)?; + if let Some(colr_box) = colr_box { + rebuilt_children.push(colr_box); + } + rebuilt_children.extend(preserved_other_boxes); + if let Some(btrt_box) = btrt_box { + rebuilt_children.push(btrt_box); + } + super::super::mp4::replace_visual_sample_entry_immediate_children( + &rebuilt_sample_entry_box, + &rebuilt_children, + ) +} + +fn transport_h264_sample_entry_has_colr(sample_entry_box: &[u8]) -> Result { + Ok( + super::super::mp4::visual_sample_entry_immediate_children(sample_entry_box)? + .iter() + .any(|child_box| child_box.get(4..8) == Some(&b"colr"[..])), + ) +} + +fn wrapped_transport_h264_edit_delta( + target_edit_media_time: i64, + current_edit_media_time: i64, +) -> Result { + let direct = i128::from(target_edit_media_time) - i128::from(current_edit_media_time); + let wrap = i128::from(TRANSPORT_MAX_PCR_90K); + let best = [direct, direct - wrap, direct + wrap] + .into_iter() + .min_by_key(|candidate| candidate.abs()) + .ok_or(MuxError::LayoutOverflow( + "transport-stream H.264 edit-media time realignment", + ))?; + i64::try_from(best) + .map_err(|_| MuxError::LayoutOverflow("transport-stream H.264 edit-media time realignment")) +} + +fn rescale_transport_avs3_samples( + samples: Vec, + source_timescale: u32, + spec: &str, +) -> Result, MuxError> { + samples + .into_iter() + .map(|sample| { + Ok(CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: rescale_transport_avs3_time( + i64::from(sample.duration), + source_timescale, + spec, + )? as u32, + composition_time_offset: rescale_transport_avs3_time( + i64::from(sample.composition_time_offset), + source_timescale, + spec, + )? as i32, + is_sync_sample: sample.is_sync_sample, + }) + }) + .collect() +} + +fn rescale_transport_mpeg2v_time( + value: i64, + source_timescale: u32, + spec: &str, +) -> Result { + if source_timescale == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream MPEG-2 video used an invalid zero timescale".to_string(), + }); + } + + let sign = value.signum(); + let magnitude = value.unsigned_abs(); + let scaled = magnitude + .checked_mul(u64::from(TRANSPORT_VIDEO_TIMESCALE)) + .ok_or(MuxError::LayoutOverflow( + "transport-stream MPEG-2 video time rescale", + ))?; + if scaled % u64::from(source_timescale) != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream MPEG-2 video cadence does not rescale cleanly onto the 90_000 media clock".to_string(), + }); + } + let normalized = scaled / u64::from(source_timescale); + let normalized = i64::try_from(normalized) + .map_err(|_| MuxError::LayoutOverflow("transport-stream MPEG-2 video time rescale"))?; + Ok(normalized * sign) +} + +fn rescale_transport_h26x_time( + value: i64, + source_timescale: u32, + spec: &str, + codec_name: &str, +) -> Result { + if source_timescale == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("transport-stream {codec_name} used an invalid zero timescale"), + }); + } + + let sign = value.signum(); + let magnitude = value.unsigned_abs(); + let scaled = magnitude + .checked_mul(u64::from(TRANSPORT_VIDEO_TIMESCALE)) + .ok_or(MuxError::LayoutOverflow( + "transport-stream H26x time rescale", + ))?; + if scaled % u64::from(source_timescale) != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "transport-stream {codec_name} cadence does not rescale cleanly onto the 90_000 transport clock" + ), + }); + } + let normalized = scaled / u64::from(source_timescale); + let normalized = i64::try_from(normalized) + .map_err(|_| MuxError::LayoutOverflow("transport-stream H26x time rescale"))?; + Ok(normalized * sign) +} + +fn rescale_transport_avs3_time( + value: i64, + source_timescale: u32, + spec: &str, +) -> Result { + if source_timescale == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream AVS3 video used an invalid zero timescale".to_string(), + }); + } + + let sign = value.signum(); + let magnitude = value.unsigned_abs(); + let scaled = magnitude + .checked_mul(u64::from(TRANSPORT_VIDEO_TIMESCALE)) + .ok_or(MuxError::LayoutOverflow( + "transport-stream AVS3 video time rescale", + ))?; + if scaled % u64::from(source_timescale) != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream AVS3 video cadence does not rescale cleanly onto the 90_000 media clock".to_string(), + }); + } + let normalized = scaled / u64::from(source_timescale); + let normalized = i64::try_from(normalized) + .map_err(|_| MuxError::LayoutOverflow("transport-stream AVS3 video time rescale"))?; + Ok(normalized * sign) +} + +fn build_transport_av1_samples( + spec: &str, + samples: Vec, + pts_anchors: &[TransportTimestampAnchor], +) -> Result, MuxError> { + if pts_anchors.is_empty() { + return Ok(samples + .into_iter() + .map(|sample| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: 0, + composition_time_offset: 0, + is_sync_sample: sample.is_sync_sample, + }) + .collect()); + } + if pts_anchors.len() != samples.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AV1 PES timestamp anchors did not line up with access-unit boundaries" + .to_string(), + }); + } + + samples + .into_iter() + .enumerate() + .map(|(index, sample)| { + let duration = if let Some(next_anchor) = pts_anchors.get(index + 1) { + if next_anchor.pts_90k <= pts_anchors[index].pts_90k { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream AV1 carried non-monotonic PES timestamps across sample boundaries" + .to_string(), + }); + } + u32::try_from(next_anchor.pts_90k - pts_anchors[index].pts_90k) + .map_err(|_| MuxError::LayoutOverflow("transport-stream AV1 duration"))? + } else { + 0 + }; + Ok(CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration, + composition_time_offset: 0, + is_sync_sample: sample.is_sync_sample, + }) + }) + .collect() +} + +fn build_transport_vvc_samples( + spec: &str, + samples: Vec, + pts_anchors: &[TransportTimestampAnchor], +) -> Result, MuxError> { + fn zero_duration_transport_vvc_samples(samples: &[StagedSample]) -> Vec { + samples + .iter() + .map(|sample| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: 0, + composition_time_offset: 0, + is_sync_sample: sample.is_sync_sample, + }) + .collect() + } + + if pts_anchors.is_empty() { + return Ok(zero_duration_transport_vvc_samples(&samples)); + } + + let mut anchors_by_offset = BTreeMap::::new(); + for anchor in pts_anchors { + match anchors_by_offset.insert(anchor.sample_offset, anchor.pts_90k) { + Some(existing) if existing != anchor.pts_90k => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream VVC video carried multiple conflicting PES timestamps for the same sample boundary" + .to_string(), + }); + } + _ => {} + } + } + + if samples.is_empty() || !anchors_by_offset.contains_key(&samples[0].data_offset) { + return Ok(zero_duration_transport_vvc_samples(&samples)); + } + + if samples + .iter() + .all(|sample| anchors_by_offset.contains_key(&sample.data_offset)) + { + return samples + .iter() + .enumerate() + .map(|(index, sample)| { + let duration = if let Some(next_sample) = samples.get(index + 1) { + let current_pts = anchors_by_offset[&sample.data_offset]; + let next_pts = anchors_by_offset[&next_sample.data_offset]; + if next_pts <= current_pts { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream VVC video carried non-monotonic PES timestamps across sample boundaries" + .to_string(), + }); + } + u32::try_from(next_pts - current_pts) + .map_err(|_| MuxError::LayoutOverflow("transport-stream VVC duration"))? + } else { + 0 + }; + Ok(CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration, + composition_time_offset: 0, + is_sync_sample: sample.is_sync_sample, + }) + }) + .collect(); + } + + if pts_anchors.len() == samples.len() { + return samples + .iter() + .enumerate() + .map(|(index, sample)| { + let duration = if let Some(next_anchor) = pts_anchors.get(index + 1) { + if next_anchor.pts_90k <= pts_anchors[index].pts_90k { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream VVC video carried non-monotonic PES timestamps across sample boundaries" + .to_string(), + }); + } + u32::try_from(next_anchor.pts_90k - pts_anchors[index].pts_90k) + .map_err(|_| MuxError::LayoutOverflow("transport-stream VVC duration"))? + } else { + 0 + }; + Ok(CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration, + composition_time_offset: 0, + is_sync_sample: sample.is_sync_sample, + }) + }) + .collect(); + } + + Ok(zero_duration_transport_vvc_samples(&samples)) +} + +fn rescale_transport_mp4v_samples( + samples: Vec, + _source_timescale: u32, + pts_anchors: &[TransportTimestampAnchor], + spec: &str, +) -> Result, MuxError> { + let fallback_base_duration = samples + .iter() + .find_map(|sample| (sample.duration != 0).then_some(sample.duration)) + .ok_or(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "transport-stream MPEG-4 Part 2 video did not expose a non-zero frame cadence" + .to_string(), + })?; + + fn fallback_transport_mp4v_sample( + spec: &str, + fallback_base_duration: u32, + sample: &StagedSample, + ) -> Result { + Ok(CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: u32::try_from(transport_mp4v_fallback_time( + i64::from(sample.duration), + fallback_base_duration, + spec, + )?) + .map_err(|_| MuxError::LayoutOverflow("transport-stream MPEG-4 Part 2 duration"))?, + composition_time_offset: i32::try_from(transport_mp4v_fallback_time( + i64::from(sample.composition_time_offset), + fallback_base_duration, + spec, + )?) + .map_err(|_| { + MuxError::LayoutOverflow("transport-stream MPEG-4 Part 2 composition offset") + })?, + is_sync_sample: sample.is_sync_sample, + }) + } + + fn fallback_transport_mp4v_samples( + spec: &str, + fallback_base_duration: u32, + samples: &[StagedSample], + ) -> Result, MuxError> { + samples + .iter() + .map(|sample| fallback_transport_mp4v_sample(spec, fallback_base_duration, sample)) + .collect() + } + + if pts_anchors.is_empty() { + return fallback_transport_mp4v_samples(spec, fallback_base_duration, &samples); + } + + let mut anchors_by_offset = BTreeMap::::new(); + for anchor in pts_anchors { + match anchors_by_offset.insert(anchor.sample_offset, anchor.pts_90k) { + Some(existing) if existing != anchor.pts_90k => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream MPEG-4 Part 2 video carried multiple conflicting PES timestamps for the same sample boundary".to_string(), + }); + } + _ => {} + } + } + + if samples.is_empty() || !anchors_by_offset.contains_key(&samples[0].data_offset) { + return fallback_transport_mp4v_samples(spec, fallback_base_duration, &samples); + } + + if samples + .iter() + .all(|sample| anchors_by_offset.contains_key(&sample.data_offset)) + { + return samples + .iter() + .enumerate() + .map(|(index, sample)| { + let current_pts = anchors_by_offset[&sample.data_offset]; + let duration = if let Some(next_sample) = samples.get(index + 1) { + let next_pts = anchors_by_offset[&next_sample.data_offset]; + if next_pts <= current_pts { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream MPEG-4 Part 2 video carried non-monotonic PES timestamps across sample boundaries".to_string(), + }); + } + u32::try_from(next_pts - current_pts).map_err(|_| { + MuxError::LayoutOverflow("transport-stream MPEG-4 Part 2 duration") + })? + } else { + u32::try_from(transport_mp4v_fallback_time( + i64::from(sample.duration), + fallback_base_duration, + spec, + )?) + .map_err(|_| { + MuxError::LayoutOverflow("transport-stream MPEG-4 Part 2 duration") + })? + }; + Ok(CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration, + composition_time_offset: i32::try_from(transport_mp4v_fallback_time( + i64::from(sample.composition_time_offset), + fallback_base_duration, + spec, + )?) + .map_err(|_| { + MuxError::LayoutOverflow( + "transport-stream MPEG-4 Part 2 composition offset", + ) + })?, + is_sync_sample: sample.is_sync_sample, + }) + }) + .collect(); + } + + if pts_anchors.len() == samples.len() { + return samples + .iter() + .enumerate() + .map(|(index, sample)| { + let current_pts = pts_anchors[index].pts_90k; + let duration = if let Some(next_anchor) = pts_anchors.get(index + 1) { + if next_anchor.pts_90k <= current_pts { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream MPEG-4 Part 2 video carried non-monotonic PES timestamps across sample boundaries".to_string(), + }); + } + u32::try_from(next_anchor.pts_90k - current_pts).map_err(|_| { + MuxError::LayoutOverflow("transport-stream MPEG-4 Part 2 duration") + })? + } else { + u32::try_from(transport_mp4v_fallback_time( + i64::from(sample.duration), + fallback_base_duration, + spec, + )?) + .map_err(|_| { + MuxError::LayoutOverflow("transport-stream MPEG-4 Part 2 duration") + })? + }; + Ok(CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration, + composition_time_offset: i32::try_from(transport_mp4v_fallback_time( + i64::from(sample.composition_time_offset), + fallback_base_duration, + spec, + )?) + .map_err(|_| { + MuxError::LayoutOverflow( + "transport-stream MPEG-4 Part 2 composition offset", + ) + })?, + is_sync_sample: sample.is_sync_sample, + }) + }) + .collect(); + } + + fallback_transport_mp4v_samples(spec, fallback_base_duration, &samples) +} + +fn transport_mp4v_total_duration_override(samples: &[CandidateSample]) -> Option { + let total_decode_duration = samples.iter().try_fold(0_u64, |total, sample| { + total.checked_add(u64::from(sample.duration)) + })?; + let max_positive_composition_offset = samples + .iter() + .filter_map(|sample| { + (sample.composition_time_offset > 0).then_some(sample.composition_time_offset as u64) + }) + .max() + .unwrap_or(0); + total_decode_duration.checked_add(max_positive_composition_offset) +} + +fn transport_mp4v_fallback_time( + value: i64, + fallback_base_duration: u32, + spec: &str, +) -> Result { + if fallback_base_duration == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream MPEG-4 Part 2 video did not expose a valid fallback frame cadence" + .to_string(), + }); + } + + let sign = value.signum(); + let magnitude = value.unsigned_abs(); + let scaled = magnitude + .checked_mul(u64::from(TRANSPORT_MP4V_FALLBACK_SAMPLE_DURATION)) + .ok_or(MuxError::LayoutOverflow( + "transport-stream MPEG-4 Part 2 fallback time rescale", + ))?; + if scaled % u64::from(fallback_base_duration) != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream MPEG-4 Part 2 cadence does not map cleanly onto the retained 30 fps transport fallback".to_string(), + }); + } + let normalized = scaled / u64::from(fallback_base_duration); + let normalized = i64::try_from(normalized).map_err(|_| { + MuxError::LayoutOverflow("transport-stream MPEG-4 Part 2 fallback time rescale") + })?; + Ok(normalized * sign) +} + +#[cfg(feature = "async")] +async fn finalize_transport_ac3_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + scan_ac3_segmented_async(file, &builder.segments, builder.total_size, spec).await?; + let (timescale, samples, flat_chunk_sample_counts) = + build_transport_ac3_packet_anchored_samples( + spec, + parsed.samples, + parsed.sample_rate, + &builder.pts_anchors, + )?; + let sample_entry_box = build_ac3_sample_entry_box_with_btrt( + &parsed.decoder_config, + timescale, + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + )?; + Ok(FinalizedTransportTrack { + composite_track: CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("ac3"), + mux_policy: direct_ingest_mux_policy("ac3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }, + flat_chunk_sample_counts, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_truehd_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + scan_truehd_segmented_async(file, &builder.segments, builder.total_size, spec).await?; + let (timescale, samples) = build_transport_timestamped_audio_samples( + spec, + "transport-stream TrueHD audio", + parsed.samples, + parsed.sample_rate, + &builder.pts_anchors, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("truehd"), + mux_policy: direct_ingest_mux_policy("truehd", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: build_truehd_sample_entry_box_with_btrt( + parsed.descriptor, + build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + timescale, + )?, + )?, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_eac3_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + scan_eac3_segmented_async(file, &builder.segments, builder.total_size, spec).await?; + let (timescale, samples) = build_transport_timestamped_audio_samples( + spec, + "transport-stream E-AC-3 audio", + parsed.samples, + parsed.sample_rate, + &builder.pts_anchors, + )?; + let rebuilt_sample_entry_box = build_eac3_sample_entry_box_with_btrt( + &parsed.decoder_config, + build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + timescale, + )?, + )?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("eac3"), + mux_policy: direct_ingest_mux_policy("eac3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: rebuilt_sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_ac4_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + scan_ac4_segmented_async(file, &builder.segments, builder.total_size, spec).await?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale: parsed.media_time_scale, + language: *b"und", + handler_name: direct_ingest_handler_name("ac4"), + mux_policy: direct_ingest_mux_policy("ac4", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples: parsed + .samples + .into_iter() + .map(|sample| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect(), + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_dts_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + scan_dts_segmented_async(file, &builder.segments, builder.total_size, spec).await?; + let sample_entry_box = retune_carried_dts_sample_entry_box(&parsed.sample_entry_box)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Audio, + timescale: parsed.media_timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("dts"), + mux_policy: direct_ingest_mux_policy("dts", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples: parsed + .samples + .into_iter() + .map(|sample| CandidateSample { + source_index: usize::MAX, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect(), + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_h264_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + stage_annex_b_h264_segmented_async(path, file, &builder.segments, builder.total_size, spec) + .await?; + let mut samples = + rescale_transport_h26x_samples(parsed.samples, parsed.timescale, spec, "H.264")?; + let mut source_edit_media_time = rescale_transport_h26x_edit_media_time( + parsed.source_edit_media_time, + parsed.timescale, + spec, + "H.264", + )?; + if source_edit_media_time.unwrap_or(0) != 0 + || samples + .iter() + .any(|sample| sample.composition_time_offset != 0) + { + align_transport_h264_presentation_time( + &mut samples, + &mut source_edit_media_time, + &builder.pts_anchors, + )?; + normalize_transport_h264_wraparound_samples(&mut samples, &mut source_edit_media_time)?; + } else { + source_edit_media_time = None; + } + let sample_entry_box = retune_carried_h264_sample_entry_box( + &parsed.sample_entry_box, + TRANSPORT_VIDEO_TIMESCALE, + Some(authored_h264_media_duration(samples.iter().map( + |sample| (sample.duration, sample.composition_time_offset), + ))?), + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + true, + transport_h264_sample_entry_has_colr(&parsed.sample_entry_box)?, + )?; + let sample_entry_box = ensure_transport_h264_colorized_sample_entry(&sample_entry_box)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Video, + timescale: TRANSPORT_VIDEO_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("h264"), + mux_policy: direct_ingest_mux_policy("h264", MuxTrackKind::Video), + width: parsed.track_width, + height: parsed.track_height, + sample_entry_box, + source_edit_media_time, + samples, + }, + source_spec: parsed.segmented_source, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_h265_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + stage_annex_b_h265_segmented_async(path, file, &builder.segments, builder.total_size, spec) + .await?; + let samples = rescale_transport_h26x_samples(parsed.samples, parsed.timescale, spec, "H.265")?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Video, + timescale: TRANSPORT_VIDEO_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("h265"), + mux_policy: direct_ingest_mux_policy("h265", MuxTrackKind::Video), + width: parsed.track_width, + height: parsed.track_height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: rescale_transport_h26x_edit_media_time( + parsed.source_edit_media_time, + parsed.timescale, + spec, + "H.265", + )?, + samples, + }, + source_spec: parsed.segmented_source, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_vvc_track_async( + path: &Path, + spec: &str, + file: &mut TokioFile, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let parsed = + stage_annex_b_vvc_segmented_async(path, file, &builder.segments, builder.total_size, spec) + .await?; + let samples = build_transport_vvc_samples(spec, parsed.samples, &builder.pts_anchors)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Video, + timescale: TRANSPORT_VIDEO_TIMESCALE, + language: *b"und", + handler_name: direct_ingest_handler_name("vvc"), + mux_policy: direct_ingest_mux_policy("vvc", MuxTrackKind::Video), + width: parsed.track_width, + height: parsed.track_height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: parsed.segmented_source, + }) +} + +fn build_transport_full_au_samples( + spec: &str, + builder: &TransportTrackBuilder, +) -> Result, MuxError> { + if builder.sample_offsets.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport stream input did not contain any subtitle or teletext PES payload units" + .to_string(), + }); + } + let mut samples = Vec::with_capacity(builder.sample_offsets.len()); + for (index, &sample_offset) in builder.sample_offsets.iter().enumerate() { + let next_offset = builder + .sample_offsets + .get(index + 1) + .copied() + .unwrap_or(builder.total_size); + if next_offset <= sample_offset { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport stream carried subtitle or teletext samples must advance monotonically" + .to_string(), + }); + } + let data_size = u32::try_from(next_offset - sample_offset).map_err(|_| { + MuxError::LayoutOverflow("transport-stream carried subtitle sample size") + })?; + samples.push(CandidateSample { + source_index: usize::MAX, + data_offset: sample_offset, + data_size, + duration: DIRECT_SUBTITLE_SAMPLE_DURATION, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + Ok(samples) +} + +fn build_dvb_subtitle_sample_entry_box( + spec: &str, + builder: &TransportTrackBuilder, +) -> Result, MuxError> { + let config = builder + .dvb_subtitle + .ok_or(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "transport-stream DVB subtitle builder is missing its descriptor configuration" + .to_string(), + })?; + let child_box = super::super::mp4::encode_typed_box( + &DvsC { + composition_page_id: config.composition_page_id, + ancillary_page_id: config.ancillary_page_id, + subtitle_type: config.subtitle_type, + }, + &[], + )?; + build_generic_media_sample_entry_box(crate::FourCc::from_bytes(*b"dvbs"), &[child_box]) +} + +fn build_dvb_teletext_sample_entry_box() -> Result, MuxError> { + build_generic_media_sample_entry_box(crate::FourCc::from_bytes(*b"dvbt"), &[]) +} + +fn finalize_transport_dvb_subtitle_track_sync( + path: &Path, + spec: &str, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let language = builder + .dvb_subtitle + .map(|config| config.language) + .unwrap_or(builder.language); + let sample_entry_box = build_dvb_subtitle_sample_entry_box(spec, &builder)?; + let samples = build_transport_full_au_samples(spec, &builder)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Subtitle, + timescale: DIRECT_SUBTITLE_TIMESCALE, + language, + handler_name: "SubtitleHandler".to_string(), + mux_policy: direct_ingest_mux_policy("dvb-subtitle", MuxTrackKind::Subtitle), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +fn finalize_transport_dvb_teletext_track_sync( + path: &Path, + spec: &str, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + let sample_entry_box = build_dvb_teletext_sample_entry_box()?; + let samples = build_transport_full_au_samples(spec, &builder)?; + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(builder.pid), + kind: MuxTrackKind::Subtitle, + timescale: DIRECT_SUBTITLE_TIMESCALE, + language: builder.language, + handler_name: "SubtitleHandler".to_string(), + mux_policy: direct_ingest_mux_policy("dvb-teletext", MuxTrackKind::Subtitle), + width: 0, + height: 0, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: builder.segments, + total_size: builder.total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn finalize_transport_dvb_subtitle_track_async( + path: &Path, + spec: &str, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + finalize_transport_dvb_subtitle_track_sync(path, spec, 0, builder) +} + +#[cfg(feature = "async")] +async fn finalize_transport_dvb_teletext_track_async( + path: &Path, + spec: &str, + _track_index: usize, + builder: TransportTrackBuilder, +) -> Result { + finalize_transport_dvb_teletext_track_sync(path, spec, 0, builder) +} + +#[cfg(test)] +mod tests { + use super::{ + CandidateSample, TRANSPORT_MAX_PCR_90K, TransportTimestampAnchor, + align_transport_h264_presentation_time, + }; + + #[test] + fn align_transport_h264_presentation_time_normalizes_pts_wrap_delta() { + let mut samples = vec![CandidateSample { + source_index: 0, + data_offset: 100, + data_size: 10, + duration: 3_003, + composition_time_offset: 0, + is_sync_sample: true, + }]; + let mut source_edit_media_time = Some(TRANSPORT_MAX_PCR_90K - 9_009); + let pts_anchors = vec![TransportTimestampAnchor { + sample_offset: 100, + pts_90k: 9_009, + }]; + + align_transport_h264_presentation_time( + &mut samples, + &mut source_edit_media_time, + &pts_anchors, + ) + .unwrap(); + + assert_eq!(samples[0].composition_time_offset, 18_018); + assert_eq!(source_edit_media_time, Some(9_009)); + } + + #[test] + fn align_transport_h264_presentation_time_shifts_negative_offsets_positive() { + let mut samples = vec![CandidateSample { + source_index: 0, + data_offset: 100, + data_size: 10, + duration: 3_003, + composition_time_offset: -2_167, + is_sync_sample: true, + }]; + let mut source_edit_media_time = Some(1_433); + let pts_anchors = vec![TransportTimestampAnchor { + sample_offset: 100, + pts_90k: 1_433, + }]; + + align_transport_h264_presentation_time( + &mut samples, + &mut source_edit_media_time, + &pts_anchors, + ) + .unwrap(); + + assert_eq!(samples[0].composition_time_offset, 0); + assert_eq!(source_edit_media_time, Some(3_600)); + } +} diff --git a/src/mux/demux/vobsub.rs b/src/mux/demux/vobsub.rs new file mode 100644 index 0000000..c1e71d3 --- /dev/null +++ b/src/mux/demux/vobsub.rs @@ -0,0 +1,1510 @@ +use std::collections::BTreeMap; +use std::fs::File; +use std::io::{BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader as TokioBufReader}; + +use super::super::MuxError; +use super::super::MuxTrackKind; +#[cfg(feature = "async")] +use super::super::import::read_exact_at_async; +use super::super::import::{ + CandidateSample, CompositeTrackCandidate, SegmentedMuxSourceSegment, + SegmentedMuxSourceSegmentData, SegmentedMuxSourceSpec, TrackCandidate, + build_generic_media_sample_entry_box, direct_ingest_handler_name, direct_ingest_mux_policy, + read_exact_at_sync, +}; +use super::container_common::append_file_range_segment; +use crate::FourCc; +use crate::boxes::iso14496_14::{ + DECODER_CONFIG_DESCRIPTOR_TAG, DECODER_SPECIFIC_INFO_TAG, DecoderConfigDescriptor, Descriptor, + ES_DESCRIPTOR_TAG, EsDescriptor, Esds, SL_CONFIG_DESCRIPTOR_TAG, +}; + +const VOBSUB_SECTOR_SIZE: u64 = 0x800; +pub(super) const VOBSUB_TIMESCALE: u32 = 90_000; +const VOBSUB_ENTRY: FourCc = FourCc::from_bytes(*b"mp4s"); +const VOBSUB_OBJECT_TYPE_INDICATION: u8 = 0xE0; +const VOBSUB_STREAM_TYPE: u8 = 0x38; +const NULL_SUBPICTURE: [u8; 9] = [0x00, 0x09, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0xFF]; + +const VOBSUB_PREFIX: &[u8] = b"# VobSub"; + +const LANGUAGE_TABLE: &[([u8; 2], [u8; 3])] = &[ + (*b"--", *b"und"), + (*b"aa", *b"aar"), + (*b"ab", *b"abk"), + (*b"af", *b"afr"), + (*b"am", *b"amh"), + (*b"ar", *b"ara"), + (*b"as", *b"ast"), + (*b"ay", *b"aym"), + (*b"az", *b"aze"), + (*b"ba", *b"bak"), + (*b"be", *b"bel"), + (*b"bg", *b"bul"), + (*b"bh", *b"bih"), + (*b"bi", *b"bis"), + (*b"bn", *b"ben"), + (*b"bo", *b"bod"), + (*b"br", *b"bre"), + (*b"ca", *b"cat"), + (*b"cc", *b"und"), + (*b"co", *b"cos"), + (*b"cs", *b"ces"), + (*b"cy", *b"cym"), + (*b"da", *b"dan"), + (*b"de", *b"deu"), + (*b"dz", *b"dzo"), + (*b"el", *b"ell"), + (*b"en", *b"eng"), + (*b"eo", *b"epo"), + (*b"es", *b"spa"), + (*b"et", *b"est"), + (*b"eu", *b"eus"), + (*b"fa", *b"fas"), + (*b"fi", *b"fin"), + (*b"fj", *b"fij"), + (*b"fo", *b"fao"), + (*b"fr", *b"fra"), + (*b"fy", *b"fry"), + (*b"ga", *b"gle"), + (*b"gl", *b"glg"), + (*b"gn", *b"grn"), + (*b"gu", *b"guj"), + (*b"ha", *b"hau"), + (*b"he", *b"heb"), + (*b"hi", *b"hin"), + (*b"hr", *b"scr"), + (*b"hu", *b"hun"), + (*b"hy", *b"hye"), + (*b"ia", *b"ina"), + (*b"id", *b"ind"), + (*b"ik", *b"ipk"), + (*b"is", *b"isl"), + (*b"it", *b"ita"), + (*b"iu", *b"iku"), + (*b"ja", *b"jpn"), + (*b"jv", *b"jav"), + (*b"ka", *b"kat"), + (*b"kk", *b"kaz"), + (*b"kl", *b"kal"), + (*b"km", *b"khm"), + (*b"kn", *b"kan"), + (*b"ko", *b"kor"), + (*b"ks", *b"kas"), + (*b"ku", *b"kur"), + (*b"ky", *b"kir"), + (*b"la", *b"lat"), + (*b"ln", *b"lin"), + (*b"lo", *b"lao"), + (*b"lt", *b"lit"), + (*b"lv", *b"lav"), + (*b"mg", *b"mlg"), + (*b"mi", *b"mri"), + (*b"mk", *b"mkd"), + (*b"ml", *b"mlt"), + (*b"mn", *b"mon"), + (*b"mo", *b"mol"), + (*b"mr", *b"mar"), + (*b"ms", *b"msa"), + (*b"my", *b"mya"), + (*b"na", *b"nau"), + (*b"ne", *b"nep"), + (*b"nl", *b"nld"), + (*b"no", *b"nor"), + (*b"oc", *b"oci"), + (*b"om", *b"orm"), + (*b"or", *b"ori"), + (*b"pa", *b"pan"), + (*b"pl", *b"pol"), + (*b"ps", *b"pus"), + (*b"pt", *b"por"), + (*b"qu", *b"que"), + (*b"rm", *b"roh"), + (*b"rn", *b"run"), + (*b"ro", *b"ron"), + (*b"ru", *b"rus"), + (*b"rw", *b"kin"), + (*b"sa", *b"san"), + (*b"sd", *b"snd"), + (*b"sg", *b"sag"), + (*b"sh", *b"scr"), + (*b"si", *b"sin"), + (*b"sk", *b"slk"), + (*b"sl", *b"slv"), + (*b"sm", *b"smo"), + (*b"sn", *b"sna"), + (*b"so", *b"som"), + (*b"sq", *b"sqi"), + (*b"sr", *b"srp"), + (*b"ss", *b"ssw"), + (*b"st", *b"sot"), + (*b"su", *b"sun"), + (*b"sv", *b"swe"), + (*b"sw", *b"swa"), + (*b"ta", *b"tam"), + (*b"te", *b"tel"), + (*b"tg", *b"tgk"), + (*b"th", *b"tha"), + (*b"ti", *b"tir"), + (*b"tk", *b"tuk"), + (*b"tl", *b"tgl"), + (*b"tn", *b"tsn"), + (*b"to", *b"tog"), + (*b"tr", *b"tur"), + (*b"ts", *b"tso"), + (*b"tt", *b"tat"), + (*b"tw", *b"twi"), + (*b"ug", *b"uig"), + (*b"uk", *b"ukr"), + (*b"ur", *b"urd"), + (*b"uz", *b"uzb"), + (*b"vi", *b"vie"), + (*b"vo", *b"vol"), + (*b"wo", *b"wol"), + (*b"xh", *b"xho"), + (*b"yi", *b"yid"), + (*b"yo", *b"yor"), + (*b"za", *b"zha"), + (*b"zh", *b"zho"), + (*b"zu", *b"zul"), +]; + +#[derive(Clone)] +struct VobSubIndex { + width: u16, + height: u16, + palette: [[u8; 4]; 16], + tracks: Vec, +} + +#[derive(Clone)] +struct VobSubTrack { + index: u8, + language: [u8; 3], + positions: Vec, +} + +#[derive(Clone, Copy)] +struct VobSubPosition { + start_pts: u64, + filepos: u64, +} + +struct CollectedPacket { + packet_bytes: Vec, + duration: u32, + spans: Vec<(u64, u32)>, +} + +struct VobSubTrackBuildContext<'a> { + file_size: u64, + sub_path: &'a Path, + spec: &'a str, + width: u16, + height: u16, + palette: &'a [[u8; 4]; 16], +} + +#[derive(Default)] +struct VobSubIndexBuilder { + width: Option, + height: Option, + palette: Option<[[u8; 4]; 16]>, + languages: BTreeMap, + current_track: Option, + delays_ms: BTreeMap, +} + +pub(in crate::mux) fn scan_vobsub_source_sync( + path: &Path, + spec: &str, +) -> Result, MuxError> { + let (idx_path, sub_path) = resolve_vobsub_paths(path, spec)?; + let index = parse_vobsub_index(&idx_path, spec)?; + let mut file = File::open(&sub_path)?; + let file_size = file.metadata()?.len(); + build_vobsub_tracks_sync(&mut file, file_size, &sub_path, spec, index) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_vobsub_source_async( + path: &Path, + spec: &str, +) -> Result, MuxError> { + let (idx_path, sub_path) = resolve_vobsub_paths_async(path, spec).await?; + let index = parse_vobsub_index_async(&idx_path, spec).await?; + let mut file = TokioFile::open(&sub_path).await?; + let file_size = file.metadata().await?.len(); + build_vobsub_tracks_async(&mut file, file_size, &sub_path, spec, index).await +} + +pub(in crate::mux) fn looks_like_vobsub_prefix(prefix: &[u8]) -> bool { + prefix.starts_with(VOBSUB_PREFIX) +} + +fn resolve_vobsub_paths(path: &Path, spec: &str) -> Result<(PathBuf, PathBuf), MuxError> { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().map_err(MuxError::Io)?.join(path) + }; + let extension = absolute + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + match extension.as_deref() { + Some("idx") => { + ensure_vobsub_idx_signature(&absolute, spec)?; + let sub_path = absolute.with_extension("sub"); + if !sub_path.is_file() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "VobSub index input `{}` is missing its sibling `.sub` media file", + absolute.display() + ), + }); + } + Ok((absolute, sub_path)) + } + Some("sub") => { + let idx_path = absolute.with_extension("idx"); + ensure_vobsub_idx_signature(&idx_path, spec)?; + Ok((idx_path, absolute)) + } + _ => Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "VobSub direct ingest expects one `.idx` path or one `.sub` path with a sibling `.idx` file" + .to_string(), + }), + } +} + +#[cfg(feature = "async")] +async fn resolve_vobsub_paths_async( + path: &Path, + spec: &str, +) -> Result<(PathBuf, PathBuf), MuxError> { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().map_err(MuxError::Io)?.join(path) + }; + let extension = absolute + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + match extension.as_deref() { + Some("idx") => { + ensure_vobsub_idx_signature_async(&absolute, spec).await?; + let sub_path = absolute.with_extension("sub"); + let sub_exists = tokio::fs::metadata(&sub_path) + .await + .map(|metadata| metadata.is_file()) + .unwrap_or(false); + if !sub_exists { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "VobSub index input `{}` is missing its sibling `.sub` media file", + absolute.display() + ), + }); + } + Ok((absolute, sub_path)) + } + Some("sub") => { + let idx_path = absolute.with_extension("idx"); + ensure_vobsub_idx_signature_async(&idx_path, spec).await?; + Ok((idx_path, absolute)) + } + _ => Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "VobSub direct ingest expects one `.idx` path or one `.sub` path with a sibling `.idx` file" + .to_string(), + }), + } +} + +fn ensure_vobsub_idx_signature(path: &Path, spec: &str) -> Result<(), MuxError> { + let prefix = read_vobsub_prefix_sync(path)?; + if !looks_like_vobsub_prefix(&prefix) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "`{}` is not a VobSub index file with the expected `# VobSub` signature", + path.display() + ), + }); + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn ensure_vobsub_idx_signature_async(path: &Path, spec: &str) -> Result<(), MuxError> { + let prefix = read_vobsub_prefix_async(path).await?; + if !looks_like_vobsub_prefix(&prefix) { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "`{}` is not a VobSub index file with the expected `# VobSub` signature", + path.display() + ), + }); + } + Ok(()) +} + +fn parse_vobsub_index(path: &Path, spec: &str) -> Result { + let file = File::open(path)?; + let mut reader = BufReader::new(file); + parse_vobsub_index_reader(&mut reader, path, spec) +} + +#[cfg(feature = "async")] +async fn parse_vobsub_index_async(path: &Path, spec: &str) -> Result { + let file = TokioFile::open(path).await?; + let mut reader = TokioBufReader::new(file); + parse_vobsub_index_reader_async(&mut reader, path, spec).await +} + +fn read_vobsub_prefix_sync(path: &Path) -> Result, MuxError> { + let mut file = File::open(path)?; + let mut prefix = vec![0_u8; VOBSUB_PREFIX.len()]; + let mut total = 0; + while total < prefix.len() { + let read = file.read(&mut prefix[total..])?; + if read == 0 { + break; + } + total += read; + } + prefix.truncate(total); + Ok(prefix) +} + +#[cfg(feature = "async")] +async fn read_vobsub_prefix_async(path: &Path) -> Result, MuxError> { + let mut file = TokioFile::open(path).await?; + let mut prefix = vec![0_u8; VOBSUB_PREFIX.len()]; + let mut total = 0; + while total < prefix.len() { + let read = file.read(&mut prefix[total..]).await?; + if read == 0 { + break; + } + total += read; + } + prefix.truncate(total); + Ok(prefix) +} + +fn parse_vobsub_index_reader( + reader: &mut R, + path: &Path, + spec: &str, +) -> Result +where + R: BufRead, +{ + let mut builder = VobSubIndexBuilder::default(); + let mut line = String::new(); + let mut line_number = 0_usize; + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) => { + line_number += 1; + builder.push_line(&line, spec, path, line_number)?; + } + Err(error) if error.kind() == std::io::ErrorKind::InvalidData => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "VobSub index files must be valid UTF-8 or ASCII text".to_string(), + }); + } + Err(error) => return Err(MuxError::Io(error)), + } + } + builder.finish(spec, path) +} + +#[cfg(feature = "async")] +async fn parse_vobsub_index_reader_async( + reader: &mut R, + path: &Path, + spec: &str, +) -> Result +where + R: tokio::io::AsyncBufRead + Unpin, +{ + let mut builder = VobSubIndexBuilder::default(); + let mut line = String::new(); + let mut line_number = 0_usize; + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, + Ok(_) => { + line_number += 1; + builder.push_line(&line, spec, path, line_number)?; + } + Err(error) if error.kind() == std::io::ErrorKind::InvalidData => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "VobSub index files must be valid UTF-8 or ASCII text".to_string(), + }); + } + Err(error) => return Err(MuxError::Io(error)), + } + } + builder.finish(spec, path) +} + +impl VobSubIndexBuilder { + fn push_line( + &mut self, + raw_line: &str, + spec: &str, + path: &Path, + line_number: usize, + ) -> Result<(), MuxError> { + let line = raw_line.trim(); + if line_number == 1 { + if !line.contains("VobSub index file, v") { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "VobSub index files must begin with one `VobSub index file, v...` header line" + .to_string(), + }); + } + return Ok(()); + } + if line.is_empty() || line.starts_with('#') { + return Ok(()); + } + let Some((entry, value)) = line.split_once(':') else { + return Ok(()); + }; + let entry = entry.trim(); + let value = value.trim(); + if value.is_empty() { + return Ok(()); + } + match entry.to_ascii_lowercase().as_str() { + "size" => { + let (parsed_width, parsed_height) = + parse_vobsub_size(value, spec, path, line_number)?; + self.width = Some(parsed_width); + self.height = Some(parsed_height); + } + "palette" => { + self.palette = Some(parse_vobsub_palette(value, spec, path, line_number)?); + } + "id" => { + let (track_index, language) = parse_vobsub_id(value, spec, path, line_number)?; + self.languages.insert( + track_index, + VobSubTrack { + index: track_index, + language, + positions: Vec::new(), + }, + ); + self.delays_ms.insert(track_index, 0); + self.current_track = Some(track_index); + } + "delay" => { + let Some(track_index) = self.current_track else { + return Ok(()); + }; + let delay = parse_vobsub_timestamp_ms(value, spec, path, line_number)?; + let entry = self.delays_ms.entry(track_index).or_default(); + *entry = entry + .checked_add(delay) + .ok_or(MuxError::LayoutOverflow("VobSub delay accumulation"))?; + } + "timestamp" => { + let Some(track_index) = self.current_track else { + return Ok(()); + }; + let (start_ms, filepos) = + parse_vobsub_timestamp_entry(value, spec, path, line_number)?; + let delay_ms = *self.delays_ms.get(&track_index).unwrap_or(&0); + let track = self.languages.get_mut(&track_index).unwrap(); + let mut adjusted_start_ms = start_ms + .checked_add(delay_ms) + .ok_or(MuxError::LayoutOverflow("VobSub timestamp adjustment"))?; + if delay_ms < 0 + && let Some(previous) = track.positions.last() + { + let previous_ms = i64::try_from(previous.start_pts / 90) + .map_err(|_| MuxError::LayoutOverflow("VobSub timestamp normalization"))?; + if adjusted_start_ms < previous_ms { + let correction = previous_ms - adjusted_start_ms; + let entry = self.delays_ms.entry(track_index).or_default(); + *entry = entry + .checked_add(correction) + .ok_or(MuxError::LayoutOverflow("VobSub delay correction"))?; + adjusted_start_ms = previous_ms; + } + } + if adjusted_start_ms < 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "VobSub timestamp on line {} resolved to a negative media time", + line_number + ), + }); + } + let start_pts = u64::try_from(adjusted_start_ms) + .map_err(|_| MuxError::LayoutOverflow("VobSub timestamp"))? + .checked_mul(90) + .ok_or(MuxError::LayoutOverflow("VobSub timestamp"))?; + track.positions.push(VobSubPosition { start_pts, filepos }); + } + _ => {} + } + Ok(()) + } + + fn finish(self, spec: &str, path: &Path) -> Result { + let width = self.width.ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "VobSub index file `{}` is missing one `size:` declaration", + path.display() + ), + })?; + let height = self + .height + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "VobSub index file `{}` is missing one `size:` declaration", + path.display() + ), + })?; + let palette = self + .palette + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "VobSub index file `{}` is missing one 16-color `palette:` declaration", + path.display() + ), + })?; + let tracks = self + .languages + .into_values() + .filter(|track| !track.positions.is_empty()) + .collect::>(); + if tracks.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "VobSub index file `{}` did not declare any subtitle positions", + path.display() + ), + }); + } + Ok(VobSubIndex { + width, + height, + palette, + tracks, + }) + } +} + +fn parse_vobsub_size( + value: &str, + spec: &str, + path: &Path, + line_number: usize, +) -> Result<(u16, u16), MuxError> { + let Some((width, height)) = value.split_once('x') else { + return Err(vobsub_line_error( + spec, + path, + line_number, + "expected `size: WIDTHxHEIGHT`", + )); + }; + let width = width + .trim() + .parse::() + .map_err(|_| vobsub_line_error(spec, path, line_number, "invalid VobSub width"))?; + let height = height + .trim() + .parse::() + .map_err(|_| vobsub_line_error(spec, path, line_number, "invalid VobSub height"))?; + Ok((width, height)) +} + +fn parse_vobsub_palette( + value: &str, + spec: &str, + path: &Path, + line_number: usize, +) -> Result<[[u8; 4]; 16], MuxError> { + let values = value + .split(',') + .map(|entry| { + u32::from_str_radix(entry.trim(), 16) + .map_err(|_| vobsub_line_error(spec, path, line_number, "invalid palette entry")) + }) + .collect::, _>>()?; + let values: [u32; 16] = values.try_into().map_err(|_| { + vobsub_line_error( + spec, + path, + line_number, + "expected 16 comma-separated palette colors", + ) + })?; + let mut palette = [[0_u8; 4]; 16]; + for (index, value) in values.into_iter().enumerate() { + let r = u8::try_from((value >> 16) & 0xFF).unwrap(); + let g = u8::try_from((value >> 8) & 0xFF).unwrap(); + let b = u8::try_from(value & 0xFF).unwrap(); + palette[index][0] = 0; + palette[index][1] = + ((66 * i32::from(r) + 129 * i32::from(g) + 25 * i32::from(b) + 128 + 4096) >> 8) as u8; + palette[index][2] = + ((112 * i32::from(r) - 94 * i32::from(g) - 18 * i32::from(b) + 128 + 32768) >> 8) as u8; + palette[index][3] = + ((-38 * i32::from(r) - 74 * i32::from(g) + 112 * i32::from(b) + 128 + 32768) >> 8) + as u8; + } + Ok(palette) +} + +fn parse_vobsub_id( + value: &str, + spec: &str, + path: &Path, + line_number: usize, +) -> Result<(u8, [u8; 3]), MuxError> { + let lowered = value.to_ascii_lowercase(); + let language = lowered.as_bytes(); + if language.len() < 2 { + return Err(vobsub_line_error( + spec, + path, + line_number, + "expected a two-letter VobSub language code", + )); + } + let Some(index_position) = lowered.find("index:") else { + return Err(vobsub_line_error( + spec, + path, + line_number, + "expected `id: xx, index: N`", + )); + }; + let index_value = lowered[index_position + "index:".len()..] + .trim() + .parse::() + .map_err(|_| vobsub_line_error(spec, path, line_number, "invalid VobSub language index"))?; + if index_value >= 32 { + return Err(vobsub_line_error( + spec, + path, + line_number, + "VobSub language indices must stay below 32", + )); + } + Ok(( + index_value, + vobsub_language_from_two_letter([language[0], language[1]]), + )) +} + +fn parse_vobsub_timestamp_entry( + value: &str, + spec: &str, + path: &Path, + line_number: usize, +) -> Result<(i64, u64), MuxError> { + let Some(filepos_position) = value.to_ascii_lowercase().find("filepos:") else { + return Err(vobsub_line_error( + spec, + path, + line_number, + "expected `timestamp: HH:MM:SS:MS, filepos:XXXXXXXX`", + )); + }; + let start_ms = parse_vobsub_timestamp_ms( + value[..filepos_position] + .trim() + .trim_end_matches(',') + .trim(), + spec, + path, + line_number, + )?; + let filepos = u64::from_str_radix(value[filepos_position + "filepos:".len()..].trim(), 16) + .map_err(|_| vobsub_line_error(spec, path, line_number, "invalid VobSub filepos value"))?; + Ok((start_ms, filepos)) +} + +fn parse_vobsub_timestamp_ms( + value: &str, + spec: &str, + path: &Path, + line_number: usize, +) -> Result { + let trimmed = value.trim(); + let (sign, digits) = if let Some(rest) = trimmed.strip_prefix('-') { + (-1_i64, rest) + } else if let Some(rest) = trimmed.strip_prefix('+') { + (1_i64, rest) + } else { + (1_i64, trimmed) + }; + let parts = digits.split(':').collect::>(); + if parts.len() != 4 { + return Err(vobsub_line_error( + spec, + path, + line_number, + "expected one `HH:MM:SS:MS` timestamp value", + )); + } + let hours = parts[0] + .parse::() + .map_err(|_| vobsub_line_error(spec, path, line_number, "invalid hour field"))?; + let minutes = parts[1] + .parse::() + .map_err(|_| vobsub_line_error(spec, path, line_number, "invalid minute field"))?; + let seconds = parts[2] + .parse::() + .map_err(|_| vobsub_line_error(spec, path, line_number, "invalid second field"))?; + let milliseconds = parts[3] + .parse::() + .map_err(|_| vobsub_line_error(spec, path, line_number, "invalid millisecond field"))?; + let total_ms = hours + .checked_mul(60 * 60 * 1_000) + .and_then(|value| value.checked_add(minutes * 60 * 1_000)) + .and_then(|value| value.checked_add(seconds * 1_000)) + .and_then(|value| value.checked_add(milliseconds)) + .ok_or(MuxError::LayoutOverflow("VobSub timestamp"))?; + Ok(total_ms * sign) +} + +fn vobsub_line_error(spec: &str, path: &Path, line_number: usize, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "{message} in VobSub index file `{}` on line {line_number}", + path.display() + ), + } +} + +fn vobsub_language_from_two_letter(language: [u8; 2]) -> [u8; 3] { + LANGUAGE_TABLE + .iter() + .find(|(key, _)| *key == language) + .map(|(_, value)| *value) + .unwrap_or(*b"und") +} + +fn build_vobsub_tracks_sync( + file: &mut File, + file_size: u64, + sub_path: &Path, + spec: &str, + index: VobSubIndex, +) -> Result, MuxError> { + let context = VobSubTrackBuildContext { + file_size, + sub_path, + spec, + width: index.width, + height: index.height, + palette: &index.palette, + }; + let mut tracks = Vec::with_capacity(index.tracks.len()); + for track in index.tracks { + tracks.push(build_vobsub_track_sync(file, &context, track)?); + } + Ok(tracks) +} + +#[cfg(feature = "async")] +async fn build_vobsub_tracks_async( + file: &mut TokioFile, + file_size: u64, + sub_path: &Path, + spec: &str, + index: VobSubIndex, +) -> Result, MuxError> { + let context = VobSubTrackBuildContext { + file_size, + sub_path, + spec, + width: index.width, + height: index.height, + palette: &index.palette, + }; + let mut tracks = Vec::with_capacity(index.tracks.len()); + for track in index.tracks { + tracks.push(build_vobsub_track_async(file, &context, track).await?); + } + Ok(tracks) +} + +fn build_vobsub_track_sync( + file: &mut File, + context: &VobSubTrackBuildContext<'_>, + track: VobSubTrack, +) -> Result { + let mut segments = Vec::::new(); + let mut total_size = 0_u64; + let mut samples = Vec::::new(); + if let Some(first) = track.positions.first() + && first.start_pts > 0 + { + let data_size = u32::try_from(NULL_SUBPICTURE.len()) + .map_err(|_| MuxError::LayoutOverflow("VobSub blank sample"))?; + let data_offset = total_size; + segments.push(SegmentedMuxSourceSegment { + logical_offset: total_size, + data: SegmentedMuxSourceSegmentData::Bytes(NULL_SUBPICTURE.to_vec()), + }); + total_size = total_size + .checked_add(u64::from(data_size)) + .ok_or(MuxError::LayoutOverflow("VobSub blank sample"))?; + samples.push(CandidateSample { + source_index: usize::MAX, + data_offset, + data_size, + duration: u32::try_from(first.start_pts) + .map_err(|_| MuxError::LayoutOverflow("VobSub blank duration"))?, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + + for (position_index, position) in track.positions.iter().copied().enumerate() { + let next_start = track + .positions + .get(position_index + 1) + .map(|value| value.start_pts); + let packet = collect_vobsub_packet_sync( + file, + context.file_size, + position, + track.index, + context.spec, + )?; + let sample_offset = total_size; + for (source_offset, size) in &packet.spans { + append_file_range_segment(&mut segments, &mut total_size, *source_offset, *size); + } + let data_size = u32::try_from(packet.packet_bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("VobSub sample size"))?; + let duration = effective_vobsub_duration(packet.duration, position.start_pts, next_start)?; + samples.push(CandidateSample { + source_index: usize::MAX, + data_offset: sample_offset, + data_size, + duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + let sample_entry_box = build_vobsub_sample_entry_box(context.palette, &samples)?; + + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(track.index) + 1, + kind: MuxTrackKind::Subtitle, + timescale: VOBSUB_TIMESCALE, + language: track.language, + handler_name: direct_ingest_handler_name("vobsub"), + mux_policy: direct_ingest_mux_policy("vobsub", MuxTrackKind::Subtitle), + width: context.width, + height: context.height, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: context.sub_path.to_path_buf(), + segments, + total_size, + }, + }) +} + +#[cfg(feature = "async")] +async fn build_vobsub_track_async( + file: &mut TokioFile, + context: &VobSubTrackBuildContext<'_>, + track: VobSubTrack, +) -> Result { + let mut segments = Vec::::new(); + let mut total_size = 0_u64; + let mut samples = Vec::::new(); + if let Some(first) = track.positions.first() + && first.start_pts > 0 + { + let data_size = u32::try_from(NULL_SUBPICTURE.len()) + .map_err(|_| MuxError::LayoutOverflow("VobSub blank sample"))?; + let data_offset = total_size; + segments.push(SegmentedMuxSourceSegment { + logical_offset: total_size, + data: SegmentedMuxSourceSegmentData::Bytes(NULL_SUBPICTURE.to_vec()), + }); + total_size = total_size + .checked_add(u64::from(data_size)) + .ok_or(MuxError::LayoutOverflow("VobSub blank sample"))?; + samples.push(CandidateSample { + source_index: usize::MAX, + data_offset, + data_size, + duration: u32::try_from(first.start_pts) + .map_err(|_| MuxError::LayoutOverflow("VobSub blank duration"))?, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + + for (position_index, position) in track.positions.iter().copied().enumerate() { + let next_start = track + .positions + .get(position_index + 1) + .map(|value| value.start_pts); + let packet = collect_vobsub_packet_async( + file, + context.file_size, + position, + track.index, + context.spec, + ) + .await?; + let sample_offset = total_size; + for (source_offset, size) in &packet.spans { + append_file_range_segment(&mut segments, &mut total_size, *source_offset, *size); + } + let data_size = u32::try_from(packet.packet_bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("VobSub sample size"))?; + let duration = effective_vobsub_duration(packet.duration, position.start_pts, next_start)?; + samples.push(CandidateSample { + source_index: usize::MAX, + data_offset: sample_offset, + data_size, + duration, + composition_time_offset: 0, + is_sync_sample: true, + }); + } + let sample_entry_box = build_vobsub_sample_entry_box(context.palette, &samples)?; + + Ok(CompositeTrackCandidate { + track: TrackCandidate { + track_id: u32::from(track.index) + 1, + kind: MuxTrackKind::Subtitle, + timescale: VOBSUB_TIMESCALE, + language: track.language, + handler_name: direct_ingest_handler_name("vobsub"), + mux_policy: direct_ingest_mux_policy("vobsub", MuxTrackKind::Subtitle), + width: context.width, + height: context.height, + sample_entry_box, + source_edit_media_time: None, + samples, + }, + source_spec: SegmentedMuxSourceSpec { + path: context.sub_path.to_path_buf(), + segments, + total_size, + }, + }) +} + +pub(super) fn effective_vobsub_duration( + parsed_duration: u32, + start_pts: u64, + next_start: Option, +) -> Result { + if parsed_duration != 0 { + return Ok(parsed_duration); + } + if let Some(next_start) = next_start + && next_start > start_pts + { + return u32::try_from(next_start - start_pts) + .map_err(|_| MuxError::LayoutOverflow("VobSub sample duration")); + } + Ok(0) +} + +fn collect_vobsub_packet_sync( + file: &mut File, + file_size: u64, + position: VobSubPosition, + track_index: u8, + spec: &str, +) -> Result { + let expected_substream_id = 0x20_u8 | track_index; + let mut sector_offset = position.filepos; + let mut packet_size = None::; + let mut control_offset = None::; + let mut packet_bytes = Vec::::new(); + let mut spans = Vec::<(u64, u32)>::new(); + loop { + let sector = read_vobsub_sector_sync(file, file_size, sector_offset, spec)?; + let header = + parse_vobsub_sector_header(§or, spec, sector_offset, expected_substream_id)?; + if packet_size.is_none() { + packet_size = Some(header.packet_size); + control_offset = Some(header.control_offset); + } + let remaining = packet_size + .unwrap() + .checked_sub(u32::try_from(packet_bytes.len()).unwrap()) + .ok_or(MuxError::LayoutOverflow("VobSub packet remaining bytes"))?; + let chunk_size = remaining.min( + u32::try_from(VOBSUB_SECTOR_SIZE - u64::try_from(header.payload_offset).unwrap()) + .map_err(|_| MuxError::LayoutOverflow("VobSub sector payload"))?, + ); + packet_bytes.extend_from_slice( + §or[header.payload_offset + ..header.payload_offset + usize::try_from(chunk_size).unwrap()], + ); + spans.push(( + sector_offset + u64::try_from(header.payload_offset).unwrap(), + chunk_size, + )); + if packet_bytes.len() == usize::try_from(packet_size.unwrap()).unwrap() { + break; + } + sector_offset = find_next_vobsub_sector_sync( + file, + file_size, + sector_offset + VOBSUB_SECTOR_SIZE, + expected_substream_id, + spec, + )?; + } + let duration = parse_vobsub_duration( + &packet_bytes, + packet_size.unwrap(), + control_offset.unwrap(), + spec, + )?; + Ok(CollectedPacket { + packet_bytes, + duration, + spans, + }) +} + +#[cfg(feature = "async")] +async fn collect_vobsub_packet_async( + file: &mut TokioFile, + file_size: u64, + position: VobSubPosition, + track_index: u8, + spec: &str, +) -> Result { + let expected_substream_id = 0x20_u8 | track_index; + let mut sector_offset = position.filepos; + let mut packet_size = None::; + let mut control_offset = None::; + let mut packet_bytes = Vec::::new(); + let mut spans = Vec::<(u64, u32)>::new(); + loop { + let sector = read_vobsub_sector_async(file, file_size, sector_offset, spec).await?; + let header = + parse_vobsub_sector_header(§or, spec, sector_offset, expected_substream_id)?; + if packet_size.is_none() { + packet_size = Some(header.packet_size); + control_offset = Some(header.control_offset); + } + let remaining = packet_size + .unwrap() + .checked_sub(u32::try_from(packet_bytes.len()).unwrap()) + .ok_or(MuxError::LayoutOverflow("VobSub packet remaining bytes"))?; + let chunk_size = remaining.min( + u32::try_from(VOBSUB_SECTOR_SIZE - u64::try_from(header.payload_offset).unwrap()) + .map_err(|_| MuxError::LayoutOverflow("VobSub sector payload"))?, + ); + packet_bytes.extend_from_slice( + §or[header.payload_offset + ..header.payload_offset + usize::try_from(chunk_size).unwrap()], + ); + spans.push(( + sector_offset + u64::try_from(header.payload_offset).unwrap(), + chunk_size, + )); + if packet_bytes.len() == usize::try_from(packet_size.unwrap()).unwrap() { + break; + } + sector_offset = find_next_vobsub_sector_async( + file, + file_size, + sector_offset + VOBSUB_SECTOR_SIZE, + expected_substream_id, + spec, + ) + .await?; + } + let duration = parse_vobsub_duration( + &packet_bytes, + packet_size.unwrap(), + control_offset.unwrap(), + spec, + )?; + Ok(CollectedPacket { + packet_bytes, + duration, + spans, + }) +} + +fn read_vobsub_sector_sync( + file: &mut File, + file_size: u64, + offset: u64, + spec: &str, +) -> Result<[u8; VOBSUB_SECTOR_SIZE as usize], MuxError> { + if offset + .checked_add(VOBSUB_SECTOR_SIZE) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated VobSub sector at byte offset {offset}"), + }); + } + let mut sector = [0_u8; VOBSUB_SECTOR_SIZE as usize]; + read_exact_at_sync(file, offset, &mut sector, spec, "truncated VobSub sector")?; + Ok(sector) +} + +#[cfg(feature = "async")] +async fn read_vobsub_sector_async( + file: &mut TokioFile, + file_size: u64, + offset: u64, + spec: &str, +) -> Result<[u8; VOBSUB_SECTOR_SIZE as usize], MuxError> { + if offset + .checked_add(VOBSUB_SECTOR_SIZE) + .is_none_or(|end| end > file_size) + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("truncated VobSub sector at byte offset {offset}"), + }); + } + let mut sector = [0_u8; VOBSUB_SECTOR_SIZE as usize]; + read_exact_at_async(file, offset, &mut sector, spec, "truncated VobSub sector").await?; + Ok(sector) +} + +struct VobSubSectorHeader { + packet_size: u32, + control_offset: u32, + payload_offset: usize, +} + +fn parse_vobsub_sector_header( + sector: &[u8; VOBSUB_SECTOR_SIZE as usize], + spec: &str, + sector_offset: u64, + expected_substream_id: u8, +) -> Result { + if sector[0..4] != [0x00, 0x00, 0x01, 0xBA] + || sector[14] != 0 + || sector[15] != 0 + || sector[16] != 0x01 + || sector[17] != 0xBD + || sector[21] & 0x80 == 0 + || sector[23] & 0xF0 != 0x20 + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("corrupted VobSub sector header at byte offset {sector_offset}"), + }); + } + let header_extension_size = usize::from(sector[22]); + let substream_id_offset = header_extension_size + .checked_add(23) + .ok_or(MuxError::LayoutOverflow("VobSub substream id offset"))?; + if substream_id_offset >= sector.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("corrupted VobSub substream id offset at byte offset {sector_offset}"), + }); + } + if sector[substream_id_offset] != expected_substream_id { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "VobSub sector at byte offset {sector_offset} carried substream id 0x{:02X} instead of the expected 0x{:02X}", + sector[substream_id_offset], expected_substream_id + ), + }); + } + let payload_offset = 24usize + .checked_add(header_extension_size) + .ok_or(MuxError::LayoutOverflow("VobSub payload offset"))?; + if payload_offset >= sector.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("corrupted VobSub payload offset at byte offset {sector_offset}"), + }); + } + let packet_size = u32::from(u16::from_be_bytes([ + sector[payload_offset], + sector[payload_offset + 1], + ])); + let control_offset = u32::from(u16::from_be_bytes([ + sector[payload_offset + 2], + sector[payload_offset + 3], + ])); + if packet_size < control_offset || packet_size < 4 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("corrupted VobSub packet sizing at byte offset {sector_offset}"), + }); + } + Ok(VobSubSectorHeader { + packet_size, + control_offset, + payload_offset, + }) +} + +fn find_next_vobsub_sector_sync( + file: &mut File, + file_size: u64, + mut search_offset: u64, + expected_substream_id: u8, + spec: &str, +) -> Result { + while search_offset + .checked_add(VOBSUB_SECTOR_SIZE) + .is_some_and(|end| end <= file_size) + { + let sector = read_vobsub_sector_sync(file, file_size, search_offset, spec)?; + match parse_vobsub_sector_header(§or, spec, search_offset, expected_substream_id) { + Ok(_) => return Ok(search_offset), + Err(MuxError::UnsupportedTrackImport { .. }) => { + search_offset += VOBSUB_SECTOR_SIZE; + } + Err(error) => return Err(error), + } + } + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated VobSub packet continuation".to_string(), + }) +} + +#[cfg(feature = "async")] +async fn find_next_vobsub_sector_async( + file: &mut TokioFile, + file_size: u64, + mut search_offset: u64, + expected_substream_id: u8, + spec: &str, +) -> Result { + while search_offset + .checked_add(VOBSUB_SECTOR_SIZE) + .is_some_and(|end| end <= file_size) + { + let sector = read_vobsub_sector_async(file, file_size, search_offset, spec).await?; + match parse_vobsub_sector_header(§or, spec, search_offset, expected_substream_id) { + Ok(_) => return Ok(search_offset), + Err(MuxError::UnsupportedTrackImport { .. }) => { + search_offset += VOBSUB_SECTOR_SIZE; + } + Err(error) => return Err(error), + } + } + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated VobSub packet continuation".to_string(), + }) +} + +pub(super) fn parse_vobsub_duration( + packet_bytes: &[u8], + packet_size: u32, + control_offset: u32, + spec: &str, +) -> Result { + let packet_size = + usize::try_from(packet_size).map_err(|_| MuxError::LayoutOverflow("VobSub packet size"))?; + let control_offset = usize::try_from(control_offset) + .map_err(|_| MuxError::LayoutOverflow("VobSub control offset"))?; + if packet_bytes.len() != packet_size || control_offset > packet_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "corrupted VobSub packet lengths".to_string(), + }); + } + let mut next_control = control_offset; + let mut start_pts = 0_u32; + let mut stop_pts = 0_u32; + loop { + let mut index = next_control; + if index + 4 > packet_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "corrupted VobSub control sequence header".to_string(), + }); + } + let control_time = u32::from(u16::from_be_bytes([ + packet_bytes[index], + packet_bytes[index + 1], + ])); + next_control = usize::from(u16::from_be_bytes([ + packet_bytes[index + 2], + packet_bytes[index + 3], + ])); + index += 4; + if next_control > packet_size || next_control < control_offset { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "corrupted VobSub control-sequence offset".to_string(), + }); + } + loop { + if index >= packet_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated VobSub control command payload".to_string(), + }); + } + let command = packet_bytes[index]; + index += 1; + let extra = match command { + 0x00..=0x02 => 0usize, + 0x03 | 0x04 => 2, + 0x05 => 6, + 0x06 => 4, + _ => break, + }; + if index + extra > packet_size { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "truncated VobSub control command data".to_string(), + }); + } + index += extra; + if matches!(command, 0x00 | 0x01) { + start_pts = control_time.saturating_mul(1024); + } else if command == 0x02 { + stop_pts = control_time.saturating_mul(1024); + } + } + if !(index <= next_control && index < packet_size) { + break; + } + } + Ok(stop_pts.saturating_sub(start_pts)) +} + +pub(super) fn build_subpicture_sample_entry_box( + decoder_specific_info: &[u8], + samples: &[CandidateSample], +) -> Result, MuxError> { + let buffer_size_db = samples + .iter() + .map(|sample| sample.data_size) + .max() + .unwrap_or(0); + let total_size_bits = samples + .iter() + .try_fold(0_u128, |total, sample| { + total.checked_add(u128::from(sample.data_size) * 8) + }) + .ok_or(MuxError::LayoutOverflow("VobSub total bitrate"))?; + let total_duration = samples + .iter() + .try_fold(0_u64, |total, sample| { + total.checked_add(u64::from(sample.duration)) + }) + .ok_or(MuxError::LayoutOverflow("VobSub total duration"))?; + let average_bitrate = if total_duration == 0 || total_size_bits == 0 { + 0 + } else { + u32::try_from( + total_size_bits + .checked_mul(u128::from(VOBSUB_TIMESCALE)) + .ok_or(MuxError::LayoutOverflow("VobSub total bitrate"))? + / u128::from(total_duration), + ) + .map_err(|_| MuxError::LayoutOverflow("VobSub average bitrate"))? + }; + + let mut esds = Esds::default(); + esds.descriptors = vec![ + Descriptor { + tag: ES_DESCRIPTOR_TAG, + es_descriptor: Some(EsDescriptor::default()), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some(DecoderConfigDescriptor { + object_type_indication: VOBSUB_OBJECT_TYPE_INDICATION, + stream_type: VOBSUB_STREAM_TYPE, + reserved: true, + buffer_size_db, + max_bitrate: average_bitrate, + avg_bitrate: average_bitrate, + ..DecoderConfigDescriptor::default() + }), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_SPECIFIC_INFO_TAG, + size: u32::try_from(decoder_specific_info.len()) + .map_err(|_| MuxError::LayoutOverflow("VobSub decoder config size"))?, + data: decoder_specific_info.to_vec(), + ..Descriptor::default() + }, + Descriptor { + tag: SL_CONFIG_DESCRIPTOR_TAG, + size: 1, + data: vec![0x02], + ..Descriptor::default() + }, + ]; + esds.normalize_descriptor_sizes_for_mux() + .map_err(|_| MuxError::LayoutOverflow("VobSub esds"))?; + let esds_box = super::super::mp4::encode_typed_box(&esds, &[])?; + build_generic_media_sample_entry_box(VOBSUB_ENTRY, &[esds_box]) +} + +fn build_vobsub_sample_entry_box( + palette: &[[u8; 4]; 16], + samples: &[CandidateSample], +) -> Result, MuxError> { + let mut decoder_specific_info = Vec::with_capacity(palette.len() * 4); + for color in palette { + decoder_specific_info.extend_from_slice(color); + } + build_subpicture_sample_entry_box(&decoder_specific_info, samples) +} diff --git a/src/mux/demux/vorbis.rs b/src/mux/demux/vorbis.rs new file mode 100644 index 0000000..963e0d1 --- /dev/null +++ b/src/mux/demux/vorbis.rs @@ -0,0 +1,1021 @@ +use std::fs::File; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; + +use crate::FourCc; +use crate::boxes::iso14496_14::{ + DECODER_CONFIG_DESCRIPTOR_TAG, DECODER_SPECIFIC_INFO_TAG, DecoderConfigDescriptor, Descriptor, + ES_DESCRIPTOR_TAG, EsDescriptor, Esds, SL_CONFIG_DESCRIPTOR_TAG, +}; + +use super::super::MuxError; +#[cfg(feature = "async")] +use super::super::import::read_spans_async; +use super::super::import::{ + SegmentedMuxSourceSegment, SegmentedMuxSourceSegmentData, SegmentedMuxSourceSpec, StagedSample, + build_btrt_from_sample_sizes, build_generic_audio_sample_entry_box, read_spans_sync, +}; +#[cfg(feature = "async")] +use super::ogg_common::read_ogg_page_header_async; +use super::ogg_common::{OggPacketBuilder, read_ogg_page_header_sync}; + +const VORBIS_ENTRY: FourCc = FourCc::from_bytes(*b"mp4a"); + +pub(in crate::mux) struct ParsedOggVorbisTrack { + pub(in crate::mux) segmented_source: SegmentedMuxSourceSpec, + pub(in crate::mux) sample_rate: u32, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) samples: Vec, +} + +struct VorbisParser { + channels: u16, + sample_rate: u32, + min_block: u32, + max_block: u32, + mode_bits: u8, + mode_flags: [bool; 64], + saw_identification: bool, +} + +impl Default for VorbisParser { + fn default() -> Self { + Self { + channels: 0, + sample_rate: 0, + min_block: 0, + max_block: 0, + mode_bits: 0, + mode_flags: [false; 64], + saw_identification: false, + } + } +} + +struct CompletedPacketPageState { + packets: Vec, + granule_position: u64, + eos: bool, +} + +pub(in crate::mux) fn scan_ogg_vorbis_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let mut offset = 0_u64; + let mut packet_builder = OggPacketBuilder::default(); + let mut parser = VorbisParser::default(); + let mut setup_seen = false; + let mut comment_seen = false; + let mut header_packets = Vec::new(); + let mut decoded_samples = 0_u64; + let mut logical_size = 0_u64; + let mut transformed_segments = Vec::new(); + let mut samples = Vec::new(); + + while offset < file_size { + let page = read_ogg_page_header_sync(&mut file, offset, spec)?; + if packet_builder.is_empty() && page.header_type & 0x01 != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Vorbis input started in the middle of a continued packet".to_string(), + }); + } + offset = page + .payload_offset + .checked_add(page.payload_size) + .ok_or(MuxError::LayoutOverflow("Ogg page range"))?; + let mut page_cursor = page.payload_offset; + let mut completed = Vec::new(); + for lacing in &page.lacing_values { + packet_builder.push_span(page_cursor, u32::from(*lacing))?; + page_cursor += u64::from(*lacing); + if *lacing < 255 { + let packet = packet_builder.finish(); + if packet.total_size != 0 { + completed.push(packet); + } + } + } + if completed.is_empty() { + continue; + } + process_vorbis_completed_page_sync( + &mut file, + spec, + &mut parser, + &mut header_packets, + &mut comment_seen, + &mut setup_seen, + &mut decoded_samples, + &mut logical_size, + &mut transformed_segments, + &mut samples, + CompletedPacketPageState { + packets: completed, + granule_position: page.granule_position, + eos: page.header_type & 0x04 != 0, + }, + )?; + } + + finalize_vorbis_track( + path, + spec, + &parser, + &mut packet_builder, + header_packets, + logical_size, + transformed_segments, + samples, + setup_seen, + ) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_ogg_vorbis_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let file_size = file.metadata().await?.len(); + let mut offset = 0_u64; + let mut packet_builder = OggPacketBuilder::default(); + let mut parser = VorbisParser::default(); + let mut setup_seen = false; + let mut comment_seen = false; + let mut header_packets = Vec::new(); + let mut decoded_samples = 0_u64; + let mut logical_size = 0_u64; + let mut transformed_segments = Vec::new(); + let mut samples = Vec::new(); + + while offset < file_size { + let page = read_ogg_page_header_async(&mut file, offset, spec).await?; + if packet_builder.is_empty() && page.header_type & 0x01 != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Vorbis input started in the middle of a continued packet".to_string(), + }); + } + offset = page + .payload_offset + .checked_add(page.payload_size) + .ok_or(MuxError::LayoutOverflow("Ogg page range"))?; + let mut page_cursor = page.payload_offset; + let mut completed = Vec::new(); + for lacing in &page.lacing_values { + packet_builder.push_span(page_cursor, u32::from(*lacing))?; + page_cursor += u64::from(*lacing); + if *lacing < 255 { + let packet = packet_builder.finish(); + if packet.total_size != 0 { + completed.push(packet); + } + } + } + if completed.is_empty() { + continue; + } + process_vorbis_completed_page_async( + &mut file, + spec, + &mut parser, + &mut header_packets, + &mut comment_seen, + &mut setup_seen, + &mut decoded_samples, + &mut logical_size, + &mut transformed_segments, + &mut samples, + CompletedPacketPageState { + packets: completed, + granule_position: page.granule_position, + eos: page.header_type & 0x04 != 0, + }, + ) + .await?; + } + + finalize_vorbis_track( + path, + spec, + &parser, + &mut packet_builder, + header_packets, + logical_size, + transformed_segments, + samples, + setup_seen, + ) +} + +#[allow(clippy::too_many_arguments)] +fn finalize_vorbis_track( + path: &Path, + spec: &str, + parser: &VorbisParser, + packet_builder: &mut OggPacketBuilder, + header_packets: Vec>, + logical_size: u64, + transformed_segments: Vec, + samples: Vec, + setup_seen: bool, +) -> Result { + if !packet_builder.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Vorbis input ended in the middle of a packet".to_string(), + }); + } + if !parser.saw_identification || !setup_seen || header_packets.len() != 3 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Vorbis input did not contain the full three-header setup".to_string(), + }); + } + if samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Vorbis input did not contain any audio packets after headers".to_string(), + }); + } + + let mut dsi = Vec::new(); + for packet in &header_packets { + let packet_len = u16::try_from(packet.len()) + .map_err(|_| MuxError::LayoutOverflow("Vorbis header packet length"))?; + dsi.extend_from_slice(&packet_len.to_be_bytes()); + dsi.extend_from_slice(packet); + } + + Ok(ParsedOggVorbisTrack { + segmented_source: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: transformed_segments, + total_size: logical_size, + }, + sample_rate: parser.sample_rate, + sample_entry_box: build_vorbis_sample_entry_box( + parser.sample_rate, + parser.channels, + &dsi, + build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + parser.sample_rate, + )?, + )?, + samples, + }) +} + +#[allow(clippy::too_many_arguments)] +fn process_vorbis_completed_page_sync( + file: &mut File, + spec: &str, + parser: &mut VorbisParser, + header_packets: &mut Vec>, + comment_seen: &mut bool, + setup_seen: &mut bool, + decoded_samples: &mut u64, + logical_size: &mut u64, + transformed_segments: &mut Vec, + samples: &mut Vec, + page: CompletedPacketPageState, +) -> Result<(), MuxError> { + let mut audio_packets = Vec::new(); + for packet in page.packets { + let packet_bytes = read_spans_sync( + file, + &packet.spans, + packet.total_size, + spec, + "Ogg Vorbis packet is truncated", + )?; + if !parser.saw_identification { + parser.parse_identification_header(&packet_bytes, spec)?; + header_packets.push(packet_bytes); + continue; + } + if !*comment_seen { + validate_vorbis_header_packet(&packet_bytes, 0x03, spec, "comment")?; + *comment_seen = true; + header_packets.push(packet_bytes); + continue; + } + if !*setup_seen { + parser.parse_setup_header(&packet_bytes, spec)?; + *setup_seen = true; + header_packets.push(packet_bytes); + continue; + } + audio_packets.push((packet, packet_bytes)); + } + append_vorbis_audio_packets( + spec, + parser, + decoded_samples, + logical_size, + transformed_segments, + samples, + audio_packets, + page.granule_position, + page.eos, + ) +} + +#[cfg(feature = "async")] +#[allow(clippy::too_many_arguments)] +async fn process_vorbis_completed_page_async( + file: &mut TokioFile, + spec: &str, + parser: &mut VorbisParser, + header_packets: &mut Vec>, + comment_seen: &mut bool, + setup_seen: &mut bool, + decoded_samples: &mut u64, + logical_size: &mut u64, + transformed_segments: &mut Vec, + samples: &mut Vec, + page: CompletedPacketPageState, +) -> Result<(), MuxError> { + let mut audio_packets = Vec::new(); + for packet in page.packets { + let packet_bytes: Vec = read_spans_async( + file, + &packet.spans, + packet.total_size, + spec, + "Ogg Vorbis packet is truncated", + ) + .await?; + if !parser.saw_identification { + parser.parse_identification_header(&packet_bytes, spec)?; + header_packets.push(packet_bytes); + continue; + } + if !*comment_seen { + validate_vorbis_header_packet(&packet_bytes, 0x03, spec, "comment")?; + *comment_seen = true; + header_packets.push(packet_bytes); + continue; + } + if !*setup_seen { + parser.parse_setup_header(&packet_bytes, spec)?; + *setup_seen = true; + header_packets.push(packet_bytes); + continue; + } + audio_packets.push((packet, packet_bytes)); + } + append_vorbis_audio_packets( + spec, + parser, + decoded_samples, + logical_size, + transformed_segments, + samples, + audio_packets, + page.granule_position, + page.eos, + ) +} + +#[allow(clippy::too_many_arguments)] +fn append_vorbis_audio_packets( + spec: &str, + parser: &VorbisParser, + decoded_samples: &mut u64, + logical_size: &mut u64, + transformed_segments: &mut Vec, + samples: &mut Vec, + audio_packets: Vec<(super::ogg_common::CompletedOggPacket, Vec)>, + granule_position: u64, + eos: bool, +) -> Result<(), MuxError> { + let mut nominal_durations = Vec::with_capacity(audio_packets.len()); + for (_, packet_bytes) in &audio_packets { + nominal_durations.push(u64::from(parser.packet_duration(packet_bytes, spec)?)); + } + let last_index = audio_packets.len().saturating_sub(1); + for (index, (packet, _packet_bytes)) in audio_packets.into_iter().enumerate() { + let mut duration = nominal_durations[index]; + if eos && index == last_index && granule_position != u64::MAX { + let remaining = granule_position.saturating_sub(*decoded_samples); + if duration == 0 { + duration = if remaining > 0 { + remaining + } else { + nominal_durations[..index] + .iter() + .rev() + .copied() + .find(|value| *value != 0) + .unwrap_or(0) + }; + } else if remaining > 0 && remaining < duration { + duration = remaining; + } + } + let data_offset = *logical_size; + for span in &packet.spans { + transformed_segments.push(SegmentedMuxSourceSegment { + logical_offset: *logical_size, + data: SegmentedMuxSourceSegmentData::FileRange { + source_offset: span.source_offset, + size: span.size, + }, + }); + *logical_size = logical_size + .checked_add(u64::from(span.size)) + .ok_or(MuxError::LayoutOverflow("Ogg Vorbis logical source size"))?; + } + samples.push(StagedSample { + data_offset, + data_size: packet.total_size, + duration: u32::try_from(duration) + .map_err(|_| MuxError::LayoutOverflow("Ogg Vorbis packet duration"))?, + composition_time_offset: 0, + is_sync_sample: true, + }); + *decoded_samples = decoded_samples + .checked_add(duration) + .ok_or(MuxError::LayoutOverflow("Ogg Vorbis decoded sample count"))?; + } + Ok(()) +} + +fn build_vorbis_sample_entry_box( + sample_rate: u32, + channel_count: u16, + decoder_specific_info: &[u8], + decoder_bitrates: crate::boxes::iso14496_12::Btrt, +) -> Result, MuxError> { + let mut esds = Esds::default(); + esds.descriptors = vec![ + Descriptor { + tag: ES_DESCRIPTOR_TAG, + es_descriptor: Some(EsDescriptor::default()), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some(DecoderConfigDescriptor { + object_type_indication: 0xDD, + stream_type: 5, + reserved: true, + buffer_size_db: decoder_bitrates.buffer_size_db, + max_bitrate: decoder_bitrates.max_bitrate, + avg_bitrate: decoder_bitrates.avg_bitrate, + ..DecoderConfigDescriptor::default() + }), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_SPECIFIC_INFO_TAG, + size: u32::try_from(decoder_specific_info.len()) + .map_err(|_| MuxError::LayoutOverflow("Vorbis decoder config size"))?, + data: decoder_specific_info.to_vec(), + ..Descriptor::default() + }, + Descriptor { + tag: SL_CONFIG_DESCRIPTOR_TAG, + size: 1, + data: vec![0x02], + ..Descriptor::default() + }, + ]; + esds.normalize_descriptor_sizes_for_mux() + .map_err(|_| MuxError::LayoutOverflow("Vorbis esds"))?; + let esds_box = super::super::mp4::encode_typed_box(&esds, &[])?; + build_generic_audio_sample_entry_box(VORBIS_ENTRY, sample_rate, channel_count, 16, &[esds_box]) +} + +fn validate_vorbis_header_packet( + packet: &[u8], + expected_type: u8, + spec: &str, + name: &str, +) -> Result<(), MuxError> { + if packet.len() < 7 || packet[0] != expected_type || &packet[1..7] != b"vorbis" { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("Ogg Vorbis {name} header was missing the expected Vorbis signature"), + }); + } + Ok(()) +} + +impl VorbisParser { + fn parse_identification_header(&mut self, packet: &[u8], spec: &str) -> Result<(), MuxError> { + validate_vorbis_header_packet(packet, 0x01, spec, "identification")?; + if packet.len() < 30 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Vorbis identification header is truncated".to_string(), + }); + } + let version = u32::from_le_bytes(packet[7..11].try_into().unwrap()); + if version != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "Ogg Vorbis identification header used unsupported version {version}" + ), + }); + } + let channels = packet[11]; + let sample_rate = u32::from_le_bytes(packet[12..16].try_into().unwrap()); + let min_block_exp = packet[28] & 0x0F; + let max_block_exp = packet[28] >> 4; + let framing_flag = packet[29]; + let min_block = 1_u32 << u32::from(min_block_exp); + let max_block = 1_u32 << u32::from(max_block_exp); + if channels == 0 + || sample_rate == 0 + || min_block < 8 + || max_block < min_block + || framing_flag != 1 + { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Vorbis identification header carried invalid core fields".to_string(), + }); + } + self.channels = u16::from(channels); + self.sample_rate = sample_rate; + self.min_block = min_block; + self.max_block = max_block; + self.saw_identification = true; + Ok(()) + } + + fn parse_setup_header(&mut self, packet: &[u8], spec: &str) -> Result<(), MuxError> { + validate_vorbis_header_packet(packet, 0x05, spec, "setup")?; + if !self.saw_identification { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Vorbis setup header appeared before identification".to_string(), + }); + } + let mut reader = LsbBitReader::new(packet); + let packet_type = reader + .read(8) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + let _ = packet_type; + for _ in 0..6 { + reader + .read(8) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + } + let codebook_count = reader + .read(8) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + + 1; + for _ in 0..codebook_count { + reader + .read(24) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + let dimensions = reader + .read(16) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + let entries = reader + .read(24) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + if reader + .read(1) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + == 0 + { + if reader + .read(1) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + != 0 + { + for _ in 0..entries { + if reader + .read(1) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + != 0 + { + reader + .read(5) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + } + } + } else { + for _ in 0..entries { + reader + .read(5) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + } + } + } else { + reader + .read(5) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + let mut index = 0_u32; + while index < entries { + let bits = ilog(entries.saturating_sub(index), false); + let count = reader + .read(bits) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + index = index.saturating_add(count); + } + } + + let map_type = reader + .read(4) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + match map_type { + 0 => {} + 1 | 2 => { + reader + .read(32) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + reader + .read(32) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + let quant_bits = reader + .read(4) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + + 1; + reader + .read(1) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + let quant_value_count = match map_type { + 1 => vorbis_book_maptype1_quantvals(entries, dimensions), + 2 => entries.saturating_mul(dimensions), + _ => 0, + }; + for _ in 0..quant_value_count { + reader + .read(quant_bits) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + } + } + _ => { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Vorbis setup header used an unsupported codebook map type" + .to_string(), + }); + } + } + } + + let time_count = reader + .read(6) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + + 1; + for _ in 0..time_count { + reader + .read(16) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + } + + let floor_count = reader + .read(6) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + + 1; + for _ in 0..floor_count { + let floor_type = reader + .read(16) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + if floor_type != 0 { + let partition_count = reader + .read(5) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + let mut partitions = Vec::with_capacity(partition_count as usize); + let mut max_class = 0_u32; + for _ in 0..partition_count { + let partition = reader + .read(4) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + max_class = max_class.max(partition); + partitions.push(partition); + } + let mut class_dimensions = vec![0_u32; usize::try_from(max_class + 1).unwrap()]; + for class in &mut class_dimensions { + *class = reader + .read(3) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + + 1; + let subclass_bits = reader + .read(2) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + if subclass_bits != 0 { + reader + .read(8) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + } + for _ in 0..(1_u32 << subclass_bits) { + reader + .read(8) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + } + } + reader + .read(2) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + let range_bits = reader + .read(4) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + let mut count = 0_u32; + let mut value_index = 0_u32; + for partition in partitions { + count = + count.saturating_add(class_dimensions[usize::try_from(partition).unwrap()]); + while value_index < count { + reader + .read(range_bits) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + value_index += 1; + } + } + } else { + reader + .read(8) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + reader + .read(16) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + reader + .read(16) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + reader + .read(6) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + reader + .read(8) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + let book_count = reader + .read(4) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + + 1; + for _ in 0..book_count { + reader + .read(8) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + } + } + } + + let residue_count = reader + .read(6) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + + 1; + for _ in 0..residue_count { + reader + .read(16) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + reader + .read(24) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + reader + .read(24) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + reader + .read(24) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + let partition_count = reader + .read(6) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + + 1; + reader + .read(8) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + let mut cascade_count = 0_u32; + for _ in 0..partition_count { + let mut cascade = reader + .read(3) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + if reader + .read(1) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + != 0 + { + cascade |= reader + .read(5) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + << 3; + } + cascade_count = cascade_count.saturating_add(icount(cascade)); + } + for _ in 0..cascade_count { + reader + .read(8) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + } + } + + let mapping_count = reader + .read(6) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + + 1; + for _ in 0..mapping_count { + let mut sub_maps = 1_u32; + reader + .read(16) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + if reader + .read(1) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + != 0 + { + sub_maps = reader + .read(4) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + + 1; + } + if reader + .read(1) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + != 0 + { + let coupling_steps = reader + .read(8) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + + 1; + let channel_bits = ilog(u32::from(self.channels), true); + for _ in 0..coupling_steps { + reader + .read(channel_bits) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + reader + .read(channel_bits) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + } + } + reader + .read(2) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + if sub_maps > 1 { + for _ in 0..self.channels { + reader + .read(4) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + } + } + for _ in 0..sub_maps { + reader + .read(8) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + reader + .read(8) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + reader + .read(8) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + } + } + + let mode_count = reader + .read(6) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + + 1; + for mode_index in 0..mode_count { + self.mode_flags[usize::try_from(mode_index).unwrap()] = reader + .read(1) + .ok_or_else(|| truncated_vorbis_setup_error(spec))? + != 0; + reader + .read(16) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + reader + .read(16) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + reader + .read(8) + .ok_or_else(|| truncated_vorbis_setup_error(spec))?; + } + self.mode_bits = 0; + let mut remaining_modes = mode_count; + while remaining_modes > 1 { + self.mode_bits = self.mode_bits.saturating_add(1); + remaining_modes >>= 1; + } + Ok(()) + } + + fn packet_duration(&self, packet: &[u8], spec: &str) -> Result { + if self.mode_bits == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Vorbis setup did not expose any audio modes".to_string(), + }); + } + let mut reader = LsbBitReader::new(packet); + let packet_type = reader + .read(1) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Vorbis audio packet is truncated".to_string(), + })?; + if packet_type != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Vorbis packet was not an audio packet".to_string(), + }); + } + let mode = reader.read(u32::from(self.mode_bits)).ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Vorbis audio packet is truncated before the mode index".to_string(), + } + })?; + let mode_index = usize::try_from(mode).unwrap_or(usize::MAX); + if mode_index >= self.mode_flags.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("Ogg Vorbis audio packet used invalid mode index {mode}"), + }); + } + let block_size = if self.mode_flags[mode_index] { + self.max_block + } else { + self.min_block + }; + Ok(block_size / 2) + } +} + +fn truncated_vorbis_setup_error(spec: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "Ogg Vorbis setup header is truncated".to_string(), + } +} + +fn vorbis_book_maptype1_quantvals(entries: u32, dimensions: u32) -> u32 { + if entries == 0 || dimensions == 0 { + return 0; + } + let mut values = (entries as f64).powf(1.0 / f64::from(dimensions)) as u32; + while values.saturating_pow(dimensions) > entries { + values = values.saturating_sub(1); + } + while values.saturating_add(1).saturating_pow(dimensions) <= entries { + values = values.saturating_add(1); + } + values +} + +fn icount(mut value: u32) -> u32 { + let mut count = 0_u32; + while value != 0 { + count += value & 1; + value >>= 1; + } + count +} + +fn ilog(mut value: u32, allow_zero: bool) -> u32 { + if value == 0 { + return u32::from(!allow_zero); + } + let mut bits = 0_u32; + while value != 0 { + bits += 1; + value >>= 1; + } + bits +} + +struct LsbBitReader<'a> { + data: &'a [u8], + bit_offset: usize, +} + +impl<'a> LsbBitReader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + bit_offset: 0, + } + } + + fn read(&mut self, bits: u32) -> Option { + if bits == 0 { + return Some(0); + } + let mut value = 0_u32; + for bit_index in 0..bits { + let byte_index = self.bit_offset / 8; + if byte_index >= self.data.len() { + return None; + } + let bit_index_in_byte = self.bit_offset % 8; + let bit = (self.data[byte_index] >> bit_index_in_byte) & 1; + value |= u32::from(bit) << bit_index; + self.bit_offset += 1; + } + Some(value) + } +} diff --git a/src/mux/demux/vp10.rs b/src/mux/demux/vp10.rs new file mode 100644 index 0000000..0313443 --- /dev/null +++ b/src/mux/demux/vp10.rs @@ -0,0 +1,108 @@ +use std::path::Path; + +use crate::boxes::vp::VpCodecConfiguration; +use crate::codec::MutableBox; + +use super::super::import::build_visual_sample_entry_box_with_compressor_name; +use super::super::{MuxError, MuxRawCodec}; +#[cfg(feature = "async")] +use super::ivf_common::read_indexed_sample_async; +#[cfg(feature = "async")] +use super::ivf_common::scan_ivf_video_file_async; +use super::ivf_common::{ParsedIvfTrack, read_indexed_sample_sync, scan_ivf_video_file_sync}; + +const VP10_COMPRESSOR_NAME: &[u8] = b"VPC Coding"; + +pub(in crate::mux) fn scan_vp10_file_sync( + path: &Path, + spec: &str, +) -> Result { + let indexed = scan_ivf_video_file_sync(path, MuxRawCodec::Vp10, spec)?; + let first_sample = read_indexed_sample_sync( + path, + indexed.first_sample_span, + spec, + "IVF VP10 sample payload is truncated", + )?; + let sample_entry_box = + build_vp10_sample_entry_box(indexed.width, indexed.height, &first_sample, spec)?; + Ok(ParsedIvfTrack { + width: indexed.width, + height: indexed.height, + timescale: indexed.timescale, + sample_entry_box, + samples: indexed.samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_vp10_file_async( + path: &Path, + spec: &str, +) -> Result { + let indexed = scan_ivf_video_file_async(path, MuxRawCodec::Vp10, spec).await?; + let first_sample = read_indexed_sample_async( + path, + indexed.first_sample_span, + spec, + "IVF VP10 sample payload is truncated", + ) + .await?; + let sample_entry_box = + build_vp10_sample_entry_box(indexed.width, indexed.height, &first_sample, spec)?; + Ok(ParsedIvfTrack { + width: indexed.width, + height: indexed.height, + timescale: indexed.timescale, + sample_entry_box, + samples: indexed.samples, + }) +} + +fn build_vp10_sample_entry_box( + width: u16, + height: u16, + sample: &[u8], + spec: &str, +) -> Result, MuxError> { + if sample.is_empty() { + return Err(unsupported( + spec, + "VP10 direct input must include at least one IVF frame payload", + )); + } + let child_boxes = vec![super::super::mp4::encode_typed_box( + &default_vp10_config(), + &[], + )?]; + build_visual_sample_entry_box_with_compressor_name( + crate::FourCc::from_bytes(*b"vp10"), + width, + height, + VP10_COMPRESSOR_NAME, + &child_boxes, + ) +} + +fn default_vp10_config() -> VpCodecConfiguration { + let mut config = VpCodecConfiguration::default(); + config.set_version(1); + config.profile = 1; + config.level = 10; + config.bit_depth = 8; + config.chroma_subsampling = 0; + config.video_full_range_flag = 0; + config.colour_primaries = 0; + config.transfer_characteristics = 0; + config.matrix_coefficients = 0; + config.codec_initialization_data_size = 0; + config.codec_initialization_data = Vec::new(); + config +} + +fn unsupported(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} diff --git a/src/mux/demux/vp8.rs b/src/mux/demux/vp8.rs new file mode 100644 index 0000000..c4ca7f6 --- /dev/null +++ b/src/mux/demux/vp8.rs @@ -0,0 +1,253 @@ +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +use crate::boxes::vp::VpCodecConfiguration; +use crate::codec::MutableBox; + +use super::super::import::{ + StagedSample, build_btrt_from_sample_sizes, build_visual_sample_entry_box_with_compressor_name, +}; +use super::super::{MuxError, MuxRawCodec}; +#[cfg(feature = "async")] +use super::ivf_common::read_indexed_sample_async; +#[cfg(feature = "async")] +use super::ivf_common::scan_ivf_video_file_async; +use super::ivf_common::{ParsedIvfTrack, read_indexed_sample_sync, scan_ivf_video_file_sync}; +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +const VP8_SYNC_CODE: [u8; 3] = [0x9D, 0x01, 0x2A]; +const VP8_COMPRESSOR_NAME: &[u8] = b"VPC Coding"; + +pub(in crate::mux) fn scan_vp8_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut indexed = scan_ivf_video_file_sync(path, MuxRawCodec::Vp8, spec)?; + let first_sample = read_indexed_sample_sync( + path, + indexed.first_sample_span, + spec, + "IVF VP8 sample payload is truncated", + )?; + annotate_vp8_sync_samples_sync(path, spec, &mut indexed.samples)?; + let sample_entry_box = build_vp8_sample_entry_box( + indexed.width, + indexed.height, + &first_sample, + &indexed.samples, + indexed.timescale, + spec, + )?; + Ok(ParsedIvfTrack { + width: indexed.width, + height: indexed.height, + timescale: indexed.timescale, + sample_entry_box, + samples: indexed.samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_vp8_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut indexed = scan_ivf_video_file_async(path, MuxRawCodec::Vp8, spec).await?; + let first_sample = read_indexed_sample_async( + path, + indexed.first_sample_span, + spec, + "IVF VP8 sample payload is truncated", + ) + .await?; + annotate_vp8_sync_samples_async(path, spec, &mut indexed.samples).await?; + let sample_entry_box = build_vp8_sample_entry_box( + indexed.width, + indexed.height, + &first_sample, + &indexed.samples, + indexed.timescale, + spec, + )?; + Ok(ParsedIvfTrack { + width: indexed.width, + height: indexed.height, + timescale: indexed.timescale, + sample_entry_box, + samples: indexed.samples, + }) +} + +fn build_vp8_sample_entry_box( + width: u16, + height: u16, + sample: &[u8], + samples: &[StagedSample], + timescale: u32, + spec: &str, +) -> Result, MuxError> { + let config = parse_vp8_config(width, height, sample, spec)?; + let btrt = build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + timescale, + )?; + let child_boxes = vec![ + super::super::mp4::encode_typed_box(&config, &[])?, + super::super::mp4::encode_typed_box(&btrt, &[])?, + ]; + build_visual_sample_entry_box_with_compressor_name( + crate::FourCc::from_bytes(*b"vp08"), + width, + height, + VP8_COMPRESSOR_NAME, + &child_boxes, + ) +} + +fn annotate_vp8_sync_samples_sync( + path: &Path, + spec: &str, + samples: &mut [StagedSample], +) -> Result<(), MuxError> { + for sample in samples { + sample.is_sync_sample = read_vp8_sync_flag_sync(path, *sample, spec)?; + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn annotate_vp8_sync_samples_async( + path: &Path, + spec: &str, + samples: &mut [StagedSample], +) -> Result<(), MuxError> { + for sample in samples { + sample.is_sync_sample = read_vp8_sync_flag_async(path, *sample, spec).await?; + } + Ok(()) +} + +fn read_vp8_sync_flag_sync( + path: &Path, + sample: StagedSample, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + file.seek(SeekFrom::Start(sample.data_offset))?; + let mut sample_bytes = vec![ + 0_u8; + usize::try_from(sample.data_size) + .map_err(|_| MuxError::LayoutOverflow("IVF VP8 sample size"))? + ]; + file.read_exact(&mut sample_bytes).map_err(|error| { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + unsupported(spec, "IVF VP8 sample payload is truncated") + } else { + MuxError::Io(error) + } + })?; + vp8_sample_is_keyframe(&sample_bytes, spec) +} + +#[cfg(feature = "async")] +async fn read_vp8_sync_flag_async( + path: &Path, + sample: StagedSample, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + file.seek(SeekFrom::Start(sample.data_offset)).await?; + let mut sample_bytes = vec![ + 0_u8; + usize::try_from(sample.data_size) + .map_err(|_| MuxError::LayoutOverflow("IVF VP8 sample size"))? + ]; + file.read_exact(&mut sample_bytes).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + unsupported(spec, "IVF VP8 sample payload is truncated") + } else { + MuxError::Io(error) + } + })?; + vp8_sample_is_keyframe(&sample_bytes, spec) +} + +fn vp8_sample_is_keyframe(sample: &[u8], spec: &str) -> Result { + if sample.is_empty() { + return Err(unsupported(spec, "IVF VP8 sample payload was empty")); + } + Ok(sample[0] & 0x80 != 0) +} + +fn parse_vp8_config( + width: u16, + height: u16, + sample: &[u8], + spec: &str, +) -> Result { + if sample.len() < 10 { + return Err(unsupported( + spec, + "VP8 keyframe payload is truncated before the frame header", + )); + } + + let frame_tag = + u32::from(sample[0]) | (u32::from(sample[1]) << 8) | (u32::from(sample[2]) << 16); + let frame_type = frame_tag & 1; + if frame_type != 0 { + return Err(unsupported( + spec, + "VP8 direct input must start with one keyframe so container dimensions can be validated", + )); + } + let _profile = u8::try_from((frame_tag >> 1) & 0x07) + .map_err(|_| MuxError::LayoutOverflow("VP8 profile"))?; + if sample[3..6] != VP8_SYNC_CODE { + return Err(unsupported( + spec, + "VP8 keyframe payload did not contain the expected sync code", + )); + } + let parsed_width = u16::from_le_bytes([sample[6], sample[7]]) & 0x3FFF; + let parsed_height = u16::from_le_bytes([sample[8], sample[9]]) & 0x3FFF; + if parsed_width == 0 || parsed_height == 0 { + return Err(unsupported( + spec, + "VP8 keyframe declared zero width or height", + )); + } + if parsed_width != width || parsed_height != height { + return Err(unsupported( + spec, + "VP8 keyframe dimensions did not match the IVF header dimensions", + )); + } + + let mut config = VpCodecConfiguration::default(); + config.set_version(1); + config.profile = 1; + config.level = 10; + config.bit_depth = 8; + config.chroma_subsampling = 0; + config.video_full_range_flag = 0; + config.colour_primaries = 0; + config.transfer_characteristics = 0; + config.matrix_coefficients = 0; + config.codec_initialization_data_size = 0; + config.codec_initialization_data = Vec::new(); + Ok(config) +} + +fn unsupported(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} diff --git a/src/mux/demux/vp9.rs b/src/mux/demux/vp9.rs new file mode 100644 index 0000000..2e5c2be --- /dev/null +++ b/src/mux/demux/vp9.rs @@ -0,0 +1,383 @@ +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +use crate::boxes::vp::VpCodecConfiguration; +use crate::codec::MutableBox; + +use super::super::import::{ + StagedSample, build_btrt_from_sample_sizes, build_visual_sample_entry_box_with_compressor_name, +}; +use super::super::{MuxError, MuxRawCodec}; +#[cfg(feature = "async")] +use super::ivf_common::read_indexed_sample_async; +#[cfg(feature = "async")] +use super::ivf_common::scan_ivf_video_file_async; +use super::ivf_common::{ParsedIvfTrack, read_indexed_sample_sync, scan_ivf_video_file_sync}; +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +const VP9_FRAME_MARKER: u32 = 0b10; +const VP9_KEYFRAME_SYNC: u32 = 0x49_83_42; +const VP9_COMPRESSOR_NAME: &[u8] = b"VPC Coding"; + +pub(in crate::mux) fn scan_vp9_file_sync( + path: &Path, + spec: &str, +) -> Result { + let mut indexed = scan_ivf_video_file_sync(path, MuxRawCodec::Vp9, spec)?; + let first_sample = read_indexed_sample_sync( + path, + indexed.first_sample_span, + spec, + "IVF VP9 sample payload is truncated", + )?; + annotate_vp9_sync_samples_sync(path, spec, &mut indexed.samples)?; + let sample_entry_box = build_vp9_sample_entry_box( + indexed.width, + indexed.height, + &first_sample, + &indexed.samples, + indexed.timescale, + spec, + )?; + Ok(ParsedIvfTrack { + width: indexed.width, + height: indexed.height, + timescale: indexed.timescale, + sample_entry_box, + samples: indexed.samples, + }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn scan_vp9_file_async( + path: &Path, + spec: &str, +) -> Result { + let mut indexed = scan_ivf_video_file_async(path, MuxRawCodec::Vp9, spec).await?; + let first_sample = read_indexed_sample_async( + path, + indexed.first_sample_span, + spec, + "IVF VP9 sample payload is truncated", + ) + .await?; + annotate_vp9_sync_samples_async(path, spec, &mut indexed.samples).await?; + let sample_entry_box = build_vp9_sample_entry_box( + indexed.width, + indexed.height, + &first_sample, + &indexed.samples, + indexed.timescale, + spec, + )?; + Ok(ParsedIvfTrack { + width: indexed.width, + height: indexed.height, + timescale: indexed.timescale, + sample_entry_box, + samples: indexed.samples, + }) +} + +fn build_vp9_sample_entry_box( + width: u16, + height: u16, + sample: &[u8], + samples: &[StagedSample], + timescale: u32, + spec: &str, +) -> Result, MuxError> { + let config = parse_vp9_config(width, height, sample, spec)?; + let btrt = build_btrt_from_sample_sizes( + samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + timescale, + )?; + let child_boxes = vec![ + super::super::mp4::encode_typed_box(&config, &[])?, + super::super::mp4::encode_typed_box(&btrt, &[])?, + ]; + build_visual_sample_entry_box_with_compressor_name( + crate::FourCc::from_bytes(*b"vp09"), + width, + height, + VP9_COMPRESSOR_NAME, + &child_boxes, + ) +} + +fn annotate_vp9_sync_samples_sync( + path: &Path, + spec: &str, + samples: &mut [StagedSample], +) -> Result<(), MuxError> { + for sample in samples { + sample.is_sync_sample = read_vp9_sync_flag_sync(path, *sample, spec)?; + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn annotate_vp9_sync_samples_async( + path: &Path, + spec: &str, + samples: &mut [StagedSample], +) -> Result<(), MuxError> { + for sample in samples { + sample.is_sync_sample = read_vp9_sync_flag_async(path, *sample, spec).await?; + } + Ok(()) +} + +fn read_vp9_sync_flag_sync( + path: &Path, + sample: StagedSample, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + file.seek(SeekFrom::Start(sample.data_offset))?; + let mut sample_bytes = vec![ + 0_u8; + usize::try_from(sample.data_size) + .map_err(|_| MuxError::LayoutOverflow("IVF VP9 sample size"))? + ]; + file.read_exact(&mut sample_bytes).map_err(|error| { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + unsupported(spec, "IVF VP9 sample payload is truncated") + } else { + MuxError::Io(error) + } + })?; + Ok(vp9_sample_is_sync(&sample_bytes)) +} + +#[cfg(feature = "async")] +async fn read_vp9_sync_flag_async( + path: &Path, + sample: StagedSample, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + file.seek(SeekFrom::Start(sample.data_offset)).await?; + let mut sample_bytes = vec![ + 0_u8; + usize::try_from(sample.data_size) + .map_err(|_| MuxError::LayoutOverflow("IVF VP9 sample size"))? + ]; + file.read_exact(&mut sample_bytes).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::UnexpectedEof { + unsupported(spec, "IVF VP9 sample payload is truncated") + } else { + MuxError::Io(error) + } + })?; + Ok(vp9_sample_is_sync(&sample_bytes)) +} + +fn vp9_sample_is_sync(sample: &[u8]) -> bool { + let mut bits = BitCursor::new(sample); + if bits.read_bits_u8(2).map(u32::from) != Some(VP9_FRAME_MARKER) { + return false; + } + let profile_low = bits.read_bit().unwrap_or(false); + let profile_high = bits.read_bit().unwrap_or(false); + let profile = u8::from(profile_low) | (u8::from(profile_high) << 1); + if profile == 3 { + let _reserved_profile_bit = bits.read_bit().unwrap_or(false); + } + if bits.read_bit().unwrap_or(false) { + return false; + } + let frame_type = bits.read_bit().unwrap_or(true); + let _show_frame = bits.read_bit().unwrap_or(false); + let _error_resilient_mode = bits.read_bit().unwrap_or(false); + !frame_type +} + +fn parse_vp9_config( + width: u16, + height: u16, + sample: &[u8], + spec: &str, +) -> Result { + let mut bits = BitCursor::new(sample); + let frame_marker = match bits.read_bits_u8(2) { + Some(value) => value, + None => return Ok(default_vp9_config(0)), + }; + if u32::from(frame_marker) != VP9_FRAME_MARKER { + return Err(unsupported( + spec, + "VP9 frame did not start with the expected frame marker", + )); + } + + let profile_low = bits.read_bit().unwrap_or(false); + let profile_high = bits.read_bit().unwrap_or(false); + let mut profile = u8::from(profile_low) | (u8::from(profile_high) << 1); + if profile == 3 { + profile += u8::from(bits.read_bit().unwrap_or(false)); + } + if bits.read_bit().unwrap_or(false) { + return Ok(default_vp9_config(profile)); + } + + let frame_type = bits.read_bit().unwrap_or(false); + let _show_frame = bits.read_bit().unwrap_or(false); + let _error_resilient_mode = bits.read_bit().unwrap_or(false); + if frame_type { + return Ok(default_vp9_config(profile)); + } + let sync_code = match bits.read_bits_u32(24) { + Some(value) => value, + None => return Ok(default_vp9_config(profile)), + }; + if sync_code != VP9_KEYFRAME_SYNC { + return Err(unsupported( + spec, + "VP9 keyframe did not contain the expected sync code", + )); + } + + let mut bit_depth = 8_u8; + if profile >= 2 { + bit_depth = if bits.read_bit().unwrap_or(false) { + 12 + } else { + 10 + }; + } + let color_space = match bits.read_bits_u8(3) { + Some(value) => value, + None => return Ok(default_vp9_config(profile)), + }; + let (colour_primaries, transfer_characteristics, matrix_coefficients) = + vp9_color_space_to_cicp(color_space); + let (video_full_range_flag, chroma_subsampling) = if color_space == 7 { + if profile == 1 || profile == 3 { + let _reserved_zero = bits.read_bit().unwrap_or(false); + } + (1_u8, 3_u8) + } else { + let video_full_range_flag = u8::from(bits.read_bit().unwrap_or(false)); + let chroma_subsampling = if profile != 1 && profile != 3 { + 0_u8 + } else { + let subsampling_x = u8::from(bits.read_bit().unwrap_or(false)); + let subsampling_y = u8::from(bits.read_bit().unwrap_or(false)); + let _reserved_zero = bits.read_bit().unwrap_or(false); + ((subsampling_x << 1) | subsampling_y) + 1 + }; + (video_full_range_flag, chroma_subsampling) + }; + + let parsed_width = match bits.read_bits_u16(16) { + Some(value) => value.saturating_add(1), + None => return Ok(default_vp9_config(profile)), + }; + let parsed_height = match bits.read_bits_u16(16) { + Some(value) => value.saturating_add(1), + None => return Ok(default_vp9_config(profile)), + }; + if parsed_width != width || parsed_height != height { + return Err(unsupported( + spec, + "VP9 frame dimensions did not match the IVF header dimensions", + )); + } + + let mut config = VpCodecConfiguration::default(); + config.set_version(1); + config.profile = profile; + config.level = 0; + config.bit_depth = bit_depth; + config.chroma_subsampling = chroma_subsampling; + config.video_full_range_flag = video_full_range_flag; + config.colour_primaries = colour_primaries; + config.transfer_characteristics = transfer_characteristics; + config.matrix_coefficients = matrix_coefficients; + config.codec_initialization_data_size = 0; + config.codec_initialization_data = Vec::new(); + Ok(config) +} + +fn vp9_color_space_to_cicp(color_space: u8) -> (u8, u8, u8) { + const COLOUR_PRIMARIES: [u8; 8] = [2, 5, 1, 6, 7, 9, 2, 1]; + const TRANSFER_CHARACTERISTICS: [u8; 8] = [2, 5, 1, 6, 7, 9, 2, 13]; + const MATRIX_COEFFICIENTS: [u8; 8] = [2, 6, 1, 2, 2, 9, 2, 0]; + let index = usize::from(color_space.min(7)); + ( + COLOUR_PRIMARIES[index], + TRANSFER_CHARACTERISTICS[index], + MATRIX_COEFFICIENTS[index], + ) +} + +fn default_vp9_config(profile: u8) -> VpCodecConfiguration { + let mut config = VpCodecConfiguration::default(); + config.set_version(1); + config.profile = profile; + config.level = 0; + config.bit_depth = 0; + config.chroma_subsampling = 0; + config.video_full_range_flag = 0; + config.colour_primaries = 0; + config.transfer_characteristics = 0; + config.matrix_coefficients = 0; + config.codec_initialization_data_size = 0; + config.codec_initialization_data = Vec::new(); + config +} + +struct BitCursor<'a> { + data: &'a [u8], + bit_offset: usize, +} + +impl<'a> BitCursor<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + bit_offset: 0, + } + } + + fn read_bit(&mut self) -> Option { + self.read_bits_u32(1).map(|value| value != 0) + } + + fn read_bits_u8(&mut self, width: usize) -> Option { + u8::try_from(self.read_bits_u32(width)?).ok() + } + + fn read_bits_u16(&mut self, width: usize) -> Option { + u16::try_from(self.read_bits_u32(width)?).ok() + } + + fn read_bits_u32(&mut self, width: usize) -> Option { + let end = self.bit_offset.checked_add(width)?; + if end > self.data.len() * 8 { + return None; + } + let mut value = 0_u32; + for _ in 0..width { + let byte = self.data[self.bit_offset / 8]; + let shift = 7 - (self.bit_offset % 8); + value = (value << 1) | u32::from((byte >> shift) & 1); + self.bit_offset += 1; + } + Some(value) + } +} + +fn unsupported(spec: &str, message: &str) -> MuxError { + MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: message.to_string(), + } +} diff --git a/src/mux/demux/vvc.rs b/src/mux/demux/vvc.rs new file mode 100644 index 0000000..41db3ad --- /dev/null +++ b/src/mux/demux/vvc.rs @@ -0,0 +1,888 @@ +use std::fs::File; +use std::io::{Cursor, Read, Write}; +use std::path::Path; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::AsyncReadExt; + +use crate::FourCc; +use crate::bitio::{BitReader, BitWriter}; +use crate::boxes::AnyTypeBox; +use crate::boxes::iso14496_12::{SampleEntry, VisualSampleEntry}; +use crate::boxes::iso14496_15::VVCDecoderConfiguration; + +use super::super::MuxError; +use super::super::import::{ + SegmentedMuxSourceSegment, SegmentedMuxSourceSegmentData, SegmentedMuxSourceSpec, StagedSample, +}; +use super::annexb_common::{ + AnnexBNal, AnnexBNalScanner, IndexedAnnexBTrack, nal_to_rbsp, push_unique_nal, + read_bit_labeled, read_bits_u8_labeled, read_bits_u32_labeled, read_ue_labeled, + skip_bits_labeled, +}; +#[cfg(feature = "async")] +use super::container_common::read_segmented_bytes_async; +use super::container_common::read_segmented_bytes_sync; + +const VVC1: FourCc = FourCc::from_bytes(*b"vvc1"); +const VVCC_LENGTH_SIZE_MINUS_ONE: u8 = 3; +const VVC_NAL_TYPE_OPI: u8 = 12; +const VVC_NAL_TYPE_DCI: u8 = 13; +const VVC_NAL_TYPE_VPS: u8 = 14; +const VVC_NAL_TYPE_SPS: u8 = 15; +const VVC_NAL_TYPE_PPS: u8 = 16; +const VVC_NAL_TYPE_PREFIX_APS: u8 = 17; +const VVC_NAL_TYPE_AUD: u8 = 20; +const DEFAULT_VVC_TIMESCALE: u32 = 25; +const DEFAULT_VVC_COMPOSITION_OFFSET: i32 = 1; +const DEFAULT_VVC_EDIT_MEDIA_TIME: u64 = 1; +const VVC_GENERAL_CONSTRAINT_INFO_BYTES: usize = 12; + +pub(in crate::mux) fn stage_annex_b_vvc_sync( + path: &Path, + spec: &str, +) -> Result { + let mut file = File::open(path)?; + let mut scanner = AnnexBNalScanner::default(); + let mut state = VvcStageState::default(); + let mut chunk = [0_u8; 16 * 1024]; + + loop { + let read = file.read(&mut chunk)?; + if read == 0 { + break; + } + scanner.push(&chunk[..read], |nal| stage_vvc_nal(&mut state, nal, spec))?; + } + scanner.finish(|nal| stage_vvc_nal(&mut state, nal, spec))?; + finalize_vvc_staged_track(path, state, spec) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn stage_annex_b_vvc_async( + path: &Path, + spec: &str, +) -> Result { + let mut file = TokioFile::open(path).await?; + let mut scanner = AnnexBNalScanner::default(); + let mut state = VvcStageState::default(); + let mut chunk = [0_u8; 16 * 1024]; + + loop { + let read = file.read(&mut chunk).await?; + if read == 0 { + break; + } + for nal in scanner.collect(&chunk[..read]) { + stage_vvc_nal(&mut state, nal, spec)?; + } + } + for nal in scanner.finish_collect() { + stage_vvc_nal(&mut state, nal, spec)?; + } + finalize_vvc_staged_track(path, state, spec) +} + +pub(in crate::mux) fn stage_annex_b_vvc_segmented_sync( + path: &Path, + file: &mut File, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut scanner = AnnexBNalScanner::default(); + let mut state = VvcStageState::default(); + let mut offset = 0_u64; + + while offset < total_size { + let read_len = usize::try_from((total_size - offset).min(16 * 1024)) + .map_err(|_| MuxError::LayoutOverflow("segmented VVC scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_segmented_bytes_sync( + file, + segments, + total_size, + offset, + &mut chunk, + spec, + "segmented VVC scan chunk is truncated", + )?; + for nal in scanner.collect(&chunk) { + stage_vvc_nal_segmented(&mut state, nal, spec)?; + } + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("segmented VVC scan offset"))?; + } + for nal in scanner.finish_collect() { + stage_vvc_nal_segmented(&mut state, nal, spec)?; + } + finalize_vvc_staged_track(path, state, spec) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn stage_annex_b_vvc_segmented_async( + path: &Path, + file: &mut TokioFile, + segments: &[SegmentedMuxSourceSegment], + total_size: u64, + spec: &str, +) -> Result { + let mut scanner = AnnexBNalScanner::default(); + let mut state = VvcStageState::default(); + let mut offset = 0_u64; + + while offset < total_size { + let read_len = usize::try_from((total_size - offset).min(16 * 1024)) + .map_err(|_| MuxError::LayoutOverflow("segmented VVC scan chunk length"))?; + let mut chunk = vec![0_u8; read_len]; + read_segmented_bytes_async( + file, + segments, + total_size, + offset, + &mut chunk, + spec, + "segmented VVC scan chunk is truncated", + ) + .await?; + for nal in scanner.collect(&chunk) { + stage_vvc_nal_segmented(&mut state, nal, spec)?; + } + offset = offset + .checked_add(u64::try_from(read_len).unwrap()) + .ok_or(MuxError::LayoutOverflow("segmented VVC scan offset"))?; + } + for nal in scanner.finish_collect() { + stage_vvc_nal_segmented(&mut state, nal, spec)?; + } + finalize_vvc_staged_track(path, state, spec) +} + +#[derive(Default)] +struct VvcStageState { + vps_list: Vec>, + sps_list: Vec>, + pps_list: Vec>, + samples: Vec, + segments: Vec, + current_sample_offset: Option, + current_sample_size: u32, + current_sync: bool, + current_has_vcl: bool, + logical_size: u64, +} + +impl VvcStageState { + fn finish_current_sample(&mut self) { + if let Some(data_offset) = self.current_sample_offset.take() { + self.samples.push(StagedSample { + data_offset, + data_size: self.current_sample_size, + duration: 0, + composition_time_offset: 0, + is_sync_sample: self.current_sync, + }); + self.current_sample_size = 0; + self.current_sync = false; + self.current_has_vcl = false; + } + } + + fn append_sample_nal( + &mut self, + source_offset: u64, + source_size: u32, + is_sync_sample: bool, + is_vcl: bool, + ) -> Result<(), MuxError> { + if self.current_sample_offset.is_none() { + self.current_sample_offset = Some(self.logical_size); + } + let prefix = source_size.to_be_bytes(); + self.segments.push(SegmentedMuxSourceSegment { + logical_offset: self.logical_size, + data: SegmentedMuxSourceSegmentData::Prefix(prefix), + }); + self.logical_size = self + .logical_size + .checked_add(4) + .ok_or(MuxError::LayoutOverflow("raw VVC transformed payload"))?; + self.segments.push(SegmentedMuxSourceSegment { + logical_offset: self.logical_size, + data: SegmentedMuxSourceSegmentData::FileRange { + source_offset, + size: source_size, + }, + }); + self.current_sample_size = self + .current_sample_size + .checked_add( + 4_u32 + .checked_add(source_size) + .ok_or(MuxError::LayoutOverflow("raw VVC transformed sample size"))?, + ) + .ok_or(MuxError::LayoutOverflow("raw VVC staged sample size"))?; + self.logical_size = self + .logical_size + .checked_add(u64::from(source_size)) + .ok_or(MuxError::LayoutOverflow("raw VVC transformed payload"))?; + self.current_sync |= is_sync_sample; + self.current_has_vcl |= is_vcl; + Ok(()) + } + + fn append_sample_bytes( + &mut self, + bytes: Vec, + is_sync_sample: bool, + is_vcl: bool, + ) -> Result<(), MuxError> { + let source_size = u32::try_from(bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("segmented VVC NAL length"))?; + if self.current_sample_offset.is_none() { + self.current_sample_offset = Some(self.logical_size); + } + let prefix = source_size.to_be_bytes(); + self.segments.push(SegmentedMuxSourceSegment { + logical_offset: self.logical_size, + data: SegmentedMuxSourceSegmentData::Prefix(prefix), + }); + self.logical_size = self + .logical_size + .checked_add(4) + .ok_or(MuxError::LayoutOverflow( + "segmented VVC transformed payload", + ))?; + self.segments.push(SegmentedMuxSourceSegment { + logical_offset: self.logical_size, + data: SegmentedMuxSourceSegmentData::Bytes(bytes), + }); + self.current_sample_size = self + .current_sample_size + .checked_add( + 4_u32 + .checked_add(source_size) + .ok_or(MuxError::LayoutOverflow( + "segmented VVC transformed sample size", + ))?, + ) + .ok_or(MuxError::LayoutOverflow("segmented VVC staged sample size"))?; + self.logical_size = self + .logical_size + .checked_add(u64::from(source_size)) + .ok_or(MuxError::LayoutOverflow( + "segmented VVC transformed payload", + ))?; + self.current_sync |= is_sync_sample; + self.current_has_vcl |= is_vcl; + Ok(()) + } +} + +fn stage_vvc_nal(state: &mut VvcStageState, nal: AnnexBNal, spec: &str) -> Result<(), MuxError> { + if nal.bytes.len() < 2 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "VVC NAL units must be at least two bytes long".to_string(), + }); + } + let nal_type = vvc_nal_type(&nal.bytes); + match nal_type { + VVC_NAL_TYPE_VPS => push_unique_nal(&mut state.vps_list, nal.bytes), + VVC_NAL_TYPE_SPS => push_unique_nal(&mut state.sps_list, nal.bytes), + VVC_NAL_TYPE_PPS => push_unique_nal(&mut state.pps_list, nal.bytes), + VVC_NAL_TYPE_PREFIX_APS => { + let nal_len = u32::try_from(nal.bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("VVC NAL length"))?; + state.append_sample_nal(nal.source_offset, nal_len, false, false)?; + } + VVC_NAL_TYPE_AUD => { + if state.current_sample_offset.is_some() { + state.finish_current_sample(); + } + let nal_len = u32::try_from(nal.bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("VVC NAL length"))?; + state.append_sample_nal(nal.source_offset, nal_len, false, false)?; + } + _ => { + let is_vcl = is_vvc_vcl_nal_type(nal_type); + let nal_len = u32::try_from(nal.bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("VVC NAL length"))?; + state.append_sample_nal( + nal.source_offset, + nal_len, + is_vvc_sync_nal_type(nal_type), + is_vcl, + )?; + } + } + Ok(()) +} + +fn stage_vvc_nal_segmented( + state: &mut VvcStageState, + nal: AnnexBNal, + spec: &str, +) -> Result<(), MuxError> { + if nal.bytes.len() < 2 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "VVC NAL units must be at least two bytes long".to_string(), + }); + } + let nal_type = vvc_nal_type(&nal.bytes); + match nal_type { + VVC_NAL_TYPE_VPS => push_unique_nal(&mut state.vps_list, nal.bytes), + VVC_NAL_TYPE_SPS => push_unique_nal(&mut state.sps_list, nal.bytes), + VVC_NAL_TYPE_PPS => push_unique_nal(&mut state.pps_list, nal.bytes), + VVC_NAL_TYPE_PREFIX_APS => state.append_sample_bytes(nal.bytes, false, false)?, + VVC_NAL_TYPE_AUD => { + if state.current_sample_offset.is_some() { + state.finish_current_sample(); + } + state.append_sample_bytes(nal.bytes, false, false)?; + } + _ => { + let is_vcl = is_vvc_vcl_nal_type(nal_type); + state.append_sample_bytes(nal.bytes, is_vvc_sync_nal_type(nal_type), is_vcl)?; + } + } + Ok(()) +} + +fn finalize_vvc_staged_track( + path: &Path, + mut state: VvcStageState, + spec: &str, +) -> Result { + state.finish_current_sample(); + if state.sps_list.is_empty() || state.pps_list.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "VVC input must include SPS and PPS NAL units".to_string(), + }); + } + if state.samples.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "VVC input contained parameter sets but no media samples".to_string(), + }); + } + let sps_info = parse_vvc_sps_configuration(&state.sps_list[0], spec)?; + for sample in &mut state.samples { + sample.duration = 1; + sample.composition_time_offset = DEFAULT_VVC_COMPOSITION_OFFSET; + } + let sample_entry_box = build_vvc_sample_entry_box( + sps_info.width, + sps_info.height, + build_vvc_decoder_configuration_record( + &sps_info, + &state.vps_list, + &state.sps_list, + &state.pps_list, + )?, + )?; + + Ok(IndexedAnnexBTrack { + segmented_source: SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: state.segments, + total_size: state.logical_size, + }, + track_width: sps_info.width, + track_height: sps_info.height, + timescale: DEFAULT_VVC_TIMESCALE, + sample_entry_box, + source_edit_media_time: Some(DEFAULT_VVC_EDIT_MEDIA_TIME), + samples: state.samples, + }) +} + +struct VvcSpsInfo { + width: u16, + height: u16, + max_sublayers: u8, + chroma_format_idc: u8, + bit_depth: u8, + profile_tier_level: Option, +} + +struct VvcProfileTierLevel { + general_profile_idc: u8, + general_tier_flag: bool, + general_level_idc: u8, + frame_only_constraint: bool, + multilayer_enabled: bool, + general_constraint_info: [u8; VVC_GENERAL_CONSTRAINT_INFO_BYTES], + sublayer_present_mask: u8, + sublayer_level_idc: [u8; 8], + num_sub_profiles: u8, + sub_profiles_idc: Vec, +} + +fn parse_vvc_sps_configuration(nal: &[u8], spec: &str) -> Result { + if nal.len() < 3 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "VVC SPS NAL is too short".to_string(), + }); + } + let rbsp = nal_to_rbsp(&nal[2..]); + let mut reader = BitReader::new(Cursor::new(rbsp)); + + skip_bits_labeled(&mut reader, 4, spec, "VVC SPS id")?; + skip_bits_labeled(&mut reader, 4, spec, "VVC SPS VPS id")?; + let max_sublayers = read_bits_u8_labeled(&mut reader, 3, spec, "VVC SPS max sublayers")? + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("VVC SPS max sublayers"))?; + let chroma_format_idc = read_bits_u8_labeled(&mut reader, 2, spec, "VVC SPS chroma format")?; + let log2_ctu_size = read_bits_u8_labeled(&mut reader, 2, spec, "VVC SPS CTU size")? + .checked_add(5) + .ok_or(MuxError::LayoutOverflow("VVC SPS CTU size"))?; + let profile_tier_level = if read_bit_labeled(&mut reader, spec, "VVC SPS PTL presence")? { + Some(read_vvc_profile_tier_level( + &mut reader, + max_sublayers.saturating_sub(1), + spec, + )?) + } else { + None + }; + let _gdr_enabled = read_bit_labeled(&mut reader, spec, "VVC SPS GDR enabled")?; + let ref_pic_resampling = read_bit_labeled(&mut reader, spec, "VVC SPS ref pic resampling")?; + if ref_pic_resampling { + let _res_change_in_clvs = + read_bit_labeled(&mut reader, spec, "VVC SPS res change in CLVS")?; + } + let mut width = read_ue_labeled(&mut reader, spec, "VVC SPS width")?; + let mut height = read_ue_labeled(&mut reader, spec, "VVC SPS height")?; + let conf_window_present = read_bit_labeled(&mut reader, spec, "VVC SPS conformance window")?; + if conf_window_present { + let left = read_ue_labeled(&mut reader, spec, "VVC SPS conformance left")?; + let right = read_ue_labeled(&mut reader, spec, "VVC SPS conformance right")?; + let top = read_ue_labeled(&mut reader, spec, "VVC SPS conformance top")?; + let bottom = read_ue_labeled(&mut reader, spec, "VVC SPS conformance bottom")?; + let (sub_width_c, sub_height_c) = match chroma_format_idc { + 1 => (2_u32, 2_u32), + 2 => (2_u32, 1_u32), + _ => (1_u32, 1_u32), + }; + let horizontal_crop = sub_width_c + .checked_mul( + left.checked_add(right) + .ok_or(MuxError::LayoutOverflow("VVC conformance width crop"))?, + ) + .ok_or(MuxError::LayoutOverflow("VVC conformance width crop"))?; + let vertical_crop = sub_height_c + .checked_mul( + top.checked_add(bottom) + .ok_or(MuxError::LayoutOverflow("VVC conformance height crop"))?, + ) + .ok_or(MuxError::LayoutOverflow("VVC conformance height crop"))?; + if horizontal_crop >= width || vertical_crop >= height { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "VVC SPS conformance window exceeds coded dimensions".to_string(), + }); + } + width -= horizontal_crop; + height -= vertical_crop; + } + if width == 0 || height == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "VVC SPS coded dimensions resolved to zero".to_string(), + }); + } + let ctb_size_y = 1_u32 + .checked_shl(u32::from(log2_ctu_size)) + .ok_or(MuxError::LayoutOverflow("VVC CTU size"))?; + let subpic_info_present = read_bit_labeled(&mut reader, spec, "VVC SPS subpic info")?; + if subpic_info_present { + let nb_subpics = read_ue_labeled(&mut reader, spec, "VVC SPS subpic count")? + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("VVC SPS subpic count"))?; + if nb_subpics > 1 { + let independent_subpic_flags = + read_bit_labeled(&mut reader, spec, "VVC SPS independent subpics")?; + let subpic_same_size = + read_bit_labeled(&mut reader, spec, "VVC SPS equal-sized subpics")?; + let tmp_width_bits = vvc_ceil_log2( + width + .checked_add(ctb_size_y - 1) + .ok_or(MuxError::LayoutOverflow("VVC SPS width CTU count"))? + / ctb_size_y, + ); + let tmp_height_bits = vvc_ceil_log2( + height + .checked_add(ctb_size_y - 1) + .ok_or(MuxError::LayoutOverflow("VVC SPS height CTU count"))? + / ctb_size_y, + ); + for index in 0..nb_subpics { + if !subpic_same_size || index == 0 { + if index != 0 && width > ctb_size_y { + skip_bits_labeled( + &mut reader, + usize::try_from(tmp_width_bits).map_err(|_| { + MuxError::LayoutOverflow("VVC SPS subpic width bits") + })?, + spec, + "VVC SPS subpic CTU x", + )?; + } + if index != 0 && height > ctb_size_y { + skip_bits_labeled( + &mut reader, + usize::try_from(tmp_height_bits).map_err(|_| { + MuxError::LayoutOverflow("VVC SPS subpic height bits") + })?, + spec, + "VVC SPS subpic CTU y", + )?; + } + if index + 1 < nb_subpics && width > ctb_size_y { + skip_bits_labeled( + &mut reader, + usize::try_from(tmp_width_bits).map_err(|_| { + MuxError::LayoutOverflow("VVC SPS subpic width bits") + })?, + spec, + "VVC SPS subpic width", + )?; + } + if index + 1 < nb_subpics && height > ctb_size_y { + skip_bits_labeled( + &mut reader, + usize::try_from(tmp_height_bits).map_err(|_| { + MuxError::LayoutOverflow("VVC SPS subpic height bits") + })?, + spec, + "VVC SPS subpic height", + )?; + } + } + if !independent_subpic_flags { + let _ = read_bit_labeled( + &mut reader, + spec, + "VVC SPS subpic treated-as-picture flag", + )?; + let _ = read_bit_labeled(&mut reader, spec, "VVC SPS subpic loop-filter flag")?; + } + } + } + let subpic_id_len = read_ue_labeled(&mut reader, spec, "VVC SPS subpic id len")? + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("VVC SPS subpic id len"))?; + let subpicid_mapping_explicit = + read_bit_labeled(&mut reader, spec, "VVC SPS subpic id mapping explicit")?; + if subpicid_mapping_explicit { + let subpicid_mapping_present = + read_bit_labeled(&mut reader, spec, "VVC SPS subpic id mapping present")?; + if subpicid_mapping_present { + for _ in 0..nb_subpics { + skip_bits_labeled( + &mut reader, + usize::try_from(subpic_id_len) + .map_err(|_| MuxError::LayoutOverflow("VVC SPS subpic id len"))?, + spec, + "VVC SPS subpic id", + )?; + } + } + } + } + let bit_depth = read_ue_labeled(&mut reader, spec, "VVC SPS bitdepth minus 8")? + .checked_add(8) + .ok_or(MuxError::LayoutOverflow("VVC bit depth"))?; + Ok(VvcSpsInfo { + width: u16::try_from(width).map_err(|_| MuxError::LayoutOverflow("VVC coded width"))?, + height: u16::try_from(height).map_err(|_| MuxError::LayoutOverflow("VVC coded height"))?, + max_sublayers, + chroma_format_idc, + bit_depth: u8::try_from(bit_depth) + .map_err(|_| MuxError::LayoutOverflow("VVC coded bit depth"))?, + profile_tier_level, + }) +} + +fn read_vvc_profile_tier_level( + reader: &mut BitReader, + max_tid: u8, + spec: &str, +) -> Result +where + R: Read, +{ + let general_profile_idc = read_bits_u8_labeled(reader, 7, spec, "VVC PTL general profile idc")?; + let general_tier_flag = read_bit_labeled(reader, spec, "VVC PTL general tier flag")?; + let general_level_idc = read_bits_u8_labeled(reader, 8, spec, "VVC PTL general level idc")?; + let frame_only_constraint = read_bit_labeled(reader, spec, "VVC PTL frame-only constraint")?; + let multilayer_enabled = read_bit_labeled(reader, spec, "VVC PTL multilayer enabled")?; + let gci_present = read_bit_labeled(reader, spec, "VVC PTL constraint presence")?; + let mut general_constraint_info = [0_u8; VVC_GENERAL_CONSTRAINT_INFO_BYTES]; + if gci_present { + general_constraint_info[0] = + 0x80 | read_bits_u8_labeled(reader, 7, spec, "VVC PTL constraint prefix")?; + for byte in &mut general_constraint_info[1..9] { + *byte = read_bits_u8_labeled(reader, 8, spec, "VVC PTL constraint payload")?; + } + general_constraint_info[10] = + read_bits_u8_labeled(reader, 2, spec, "VVC PTL constraint suffix")? << 6; + let extension_bits = read_bits_u8_labeled(reader, 8, spec, "VVC PTL extension length")?; + if extension_bits != 0 { + skip_bits_labeled( + reader, + usize::from(extension_bits), + spec, + "VVC PTL extension payload", + )?; + } + } + while !reader.is_aligned() { + let _ = read_bit_labeled(reader, spec, "VVC PTL alignment")?; + } + let mut sublayer_present_mask = 0_u8; + for layer_index in (0..max_tid).rev() { + if read_bit_labeled(reader, spec, "VVC PTL sublayer level flag")? { + sublayer_present_mask |= 1 << layer_index; + } + } + while !reader.is_aligned() { + let _ = read_bit_labeled(reader, spec, "VVC PTL alignment")?; + } + let mut sublayer_level_idc = [0_u8; 8]; + for layer_index in (0..max_tid).rev() { + if sublayer_present_mask & (1 << layer_index) != 0 { + sublayer_level_idc[usize::from(layer_index)] = + read_bits_u8_labeled(reader, 8, spec, "VVC PTL sublayer level idc")?; + } + } + let num_sub_profiles = read_bits_u8_labeled(reader, 8, spec, "VVC PTL sub-profile count")?; + let mut sub_profiles_idc = Vec::with_capacity(usize::from(num_sub_profiles)); + for _ in 0..num_sub_profiles { + sub_profiles_idc.push(read_bits_u32_labeled( + reader, + 32, + spec, + "VVC PTL sub-profile idc", + )?); + } + Ok(VvcProfileTierLevel { + general_profile_idc, + general_tier_flag, + general_level_idc, + frame_only_constraint, + multilayer_enabled, + general_constraint_info, + sublayer_present_mask, + sublayer_level_idc, + num_sub_profiles, + sub_profiles_idc, + }) +} + +fn build_vvc_decoder_configuration_record( + sps_info: &VvcSpsInfo, + vps_list: &[Vec], + sps_list: &[Vec], + pps_list: &[Vec], +) -> Result, MuxError> { + let mut writer = BitWriter::new(Vec::new()); + write_u8_bits(&mut writer, 0x1F, 5)?; + write_u8_bits(&mut writer, VVCC_LENGTH_SIZE_MINUS_ONE, 2)?; + writer + .write_bit(sps_info.profile_tier_level.is_some()) + .map_err(|_| MuxError::LayoutOverflow("VVC decoder configuration record"))?; + if let Some(ptl) = &sps_info.profile_tier_level { + write_u16_bits(&mut writer, 0, 9)?; + write_u8_bits(&mut writer, sps_info.max_sublayers, 3)?; + write_u8_bits(&mut writer, 1, 2)?; + write_u8_bits(&mut writer, sps_info.chroma_format_idc, 2)?; + let bit_depth_minus_eight = + sps_info + .bit_depth + .checked_sub(8) + .ok_or(MuxError::UnsupportedTrackImport { + spec: "VVC".to_string(), + message: "VVC bit depth must be at least 8".to_string(), + })?; + write_u8_bits(&mut writer, bit_depth_minus_eight, 3)?; + write_u8_bits(&mut writer, 0x1F, 5)?; + write_u8_bits(&mut writer, 0, 2)?; + write_u8_bits( + &mut writer, + u8::try_from(VVC_GENERAL_CONSTRAINT_INFO_BYTES) + .map_err(|_| MuxError::LayoutOverflow("VVC constraint info length"))?, + 6, + )?; + write_u8_bits(&mut writer, ptl.general_profile_idc, 7)?; + writer + .write_bit(ptl.general_tier_flag) + .map_err(|_| MuxError::LayoutOverflow("VVC decoder configuration record"))?; + write_u8_bits(&mut writer, ptl.general_level_idc, 8)?; + writer + .write_bit(ptl.frame_only_constraint) + .map_err(|_| MuxError::LayoutOverflow("VVC decoder configuration record"))?; + writer + .write_bit(ptl.multilayer_enabled) + .map_err(|_| MuxError::LayoutOverflow("VVC decoder configuration record"))?; + for &byte in &ptl.general_constraint_info[..VVC_GENERAL_CONSTRAINT_INFO_BYTES - 1] { + write_u8_bits(&mut writer, byte, 8)?; + } + write_u8_bits( + &mut writer, + ptl.general_constraint_info[VVC_GENERAL_CONSTRAINT_INFO_BYTES - 1], + 6, + )?; + for layer_index in (0..sps_info.max_sublayers.saturating_sub(1)).rev() { + writer + .write_bit(ptl.sublayer_present_mask & (1 << layer_index) != 0) + .map_err(|_| MuxError::LayoutOverflow("VVC decoder configuration record"))?; + } + if sps_info.max_sublayers > 1 { + for _ in sps_info.max_sublayers..=8 { + writer + .write_bit(false) + .map_err(|_| MuxError::LayoutOverflow("VVC decoder configuration record"))?; + } + } + for layer_index in (0..sps_info.max_sublayers.saturating_sub(1)).rev() { + if ptl.sublayer_present_mask & (1 << layer_index) != 0 { + write_u8_bits( + &mut writer, + ptl.sublayer_level_idc[usize::from(layer_index)], + 8, + )?; + } + } + write_u8_bits(&mut writer, ptl.num_sub_profiles, 8)?; + for &sub_profile_idc in &ptl.sub_profiles_idc { + write_u32_bits(&mut writer, sub_profile_idc, 32)?; + } + write_u16_bits(&mut writer, sps_info.width, 16)?; + write_u16_bits(&mut writer, sps_info.height, 16)?; + write_u16_bits(&mut writer, 0, 16)?; + } + let mut arrays = Vec::new(); + if !vps_list.is_empty() { + arrays.push((VVC_NAL_TYPE_VPS, vps_list)); + } + arrays.push((VVC_NAL_TYPE_SPS, sps_list)); + arrays.push((VVC_NAL_TYPE_PPS, pps_list)); + write_u8_bits( + &mut writer, + u8::try_from(arrays.len()).map_err(|_| MuxError::LayoutOverflow("VVC NAL array count"))?, + 8, + )?; + for (nal_type, nalus) in arrays { + writer + .write_bit(true) + .map_err(|_| MuxError::LayoutOverflow("VVC decoder configuration record"))?; + write_u8_bits(&mut writer, 0, 2)?; + write_u8_bits(&mut writer, nal_type, 5)?; + if nal_type != VVC_NAL_TYPE_OPI && nal_type != VVC_NAL_TYPE_DCI { + write_u16_bits( + &mut writer, + u16::try_from(nalus.len()) + .map_err(|_| MuxError::LayoutOverflow("VVC NAL count"))?, + 16, + )?; + } + for nal in nalus { + write_u16_bits( + &mut writer, + u16::try_from(nal.len()).map_err(|_| MuxError::LayoutOverflow("VVC NAL length"))?, + 16, + )?; + writer + .write_all(nal) + .map_err(|_| MuxError::LayoutOverflow("VVC decoder configuration record"))?; + } + } + writer + .into_inner() + .map_err(|_| MuxError::LayoutOverflow("VVC decoder configuration record")) +} + +fn write_u8_bits(writer: &mut BitWriter>, value: u8, width: usize) -> Result<(), MuxError> { + writer + .write_bits(&[value], width) + .map_err(|_| MuxError::LayoutOverflow("VVC decoder configuration record")) +} + +fn write_u16_bits( + writer: &mut BitWriter>, + value: u16, + width: usize, +) -> Result<(), MuxError> { + writer + .write_bits(&value.to_be_bytes(), width) + .map_err(|_| MuxError::LayoutOverflow("VVC decoder configuration record")) +} + +fn write_u32_bits( + writer: &mut BitWriter>, + value: u32, + width: usize, +) -> Result<(), MuxError> { + writer + .write_bits(&value.to_be_bytes(), width) + .map_err(|_| MuxError::LayoutOverflow("VVC decoder configuration record")) +} + +fn vvc_ceil_log2(value: u32) -> u32 { + let mut bits = 0_u32; + while value > (1_u32 << bits) { + bits = bits.saturating_add(1); + } + bits +} + +fn build_vvc_sample_entry_box( + width: u16, + height: u16, + decoder_configuration_record: Vec, +) -> Result, MuxError> { + let mut sample_entry = VisualSampleEntry::default(); + sample_entry.set_box_type(VVC1); + sample_entry.sample_entry = SampleEntry { + box_type: VVC1, + data_reference_index: 1, + }; + sample_entry.width = width; + sample_entry.height = height; + sample_entry.horizresolution = 72_u32 << 16; + sample_entry.vertresolution = 72_u32 << 16; + sample_entry.frame_count = 1; + sample_entry.depth = 0x0018; + sample_entry.pre_defined3 = -1; + + let child_boxes = super::super::mp4::encode_typed_box( + &VVCDecoderConfiguration { + version: 0, + flags: 0, + decoder_configuration_record, + }, + &[], + )?; + + super::super::mp4::encode_typed_box(&sample_entry, &child_boxes) +} + +fn vvc_nal_type(nal: &[u8]) -> u8 { + nal[1] >> 3 +} + +fn is_vvc_vcl_nal_type(nal_type: u8) -> bool { + nal_type <= 11 +} + +fn is_vvc_sync_nal_type(nal_type: u8) -> bool { + matches!(nal_type, 7..=11) +} diff --git a/src/mux/event.rs b/src/mux/event.rs new file mode 100644 index 0000000..180e390 --- /dev/null +++ b/src/mux/event.rs @@ -0,0 +1,363 @@ +use std::collections::HashMap; + +use super::coordination::{MuxCoordinationPlan, MuxDurationBoundaryKind}; +use super::{MuxPlannedMediaItem, MuxTrackPlan}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct MuxStreamDescription { + stream_index: usize, + track_id: u32, + item_count: u32, + first_decode_time: u64, + end_decode_time: u64, + first_output_offset: u64, + end_output_offset: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct MuxSampleEvent { + stream_index: usize, + sample_index_in_stream: u32, + planned_item: MuxPlannedMediaItem, +} + +impl MuxSampleEvent { + pub(crate) const fn planned_item(&self) -> &MuxPlannedMediaItem { + &self.planned_item + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MuxBoundaryEventKind { + SegmentBoundary, + FragmentBoundary, + TrackDrain, + PlanEnd, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct MuxBoundaryEvent { + kind: MuxBoundaryEventKind, + stream_index: Option, + track_id: Option, + output_offset: u64, + decode_time: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum MuxEvent { + StreamDescription(MuxStreamDescription), + Sample(MuxSampleEvent), + Boundary(MuxBoundaryEvent), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct MuxEventGraph { + events: Vec, +} + +impl MuxEventGraph { + pub(crate) fn from_plan( + planned_items: &[MuxPlannedMediaItem], + track_plans: &[MuxTrackPlan], + total_payload_size: u64, + coordination: &MuxCoordinationPlan, + ) -> Self { + let mut first_output_offset_by_track = HashMap::::new(); + let mut end_output_offset_by_track = HashMap::::new(); + let mut last_sample_index_by_track = HashMap::::new(); + let mut max_decode_end_time = 0_u64; + + for (planned_index, item) in planned_items.iter().enumerate() { + let track_id = item.staged().track_id(); + first_output_offset_by_track + .entry(track_id) + .or_insert_with(|| item.output_offset()); + end_output_offset_by_track.insert(track_id, item.output_end_offset()); + last_sample_index_by_track.insert(track_id, planned_index); + max_decode_end_time = max_decode_end_time.max(item.decode_end_time()); + } + + let mut stream_index_by_track = HashMap::::new(); + let mut events = Vec::new(); + for (stream_index, track_plan) in track_plans.iter().enumerate() { + stream_index_by_track.insert(track_plan.track_id(), stream_index); + events.push(MuxEvent::StreamDescription(MuxStreamDescription { + stream_index, + track_id: track_plan.track_id(), + item_count: track_plan.item_count(), + first_decode_time: track_plan.first_decode_time(), + end_decode_time: track_plan.end_decode_time(), + first_output_offset: first_output_offset_by_track + .get(&track_plan.track_id()) + .copied() + .unwrap_or(total_payload_size), + end_output_offset: end_output_offset_by_track + .get(&track_plan.track_id()) + .copied() + .unwrap_or(total_payload_size), + })); + } + + let mut sample_index_in_stream = HashMap::::new(); + for (planned_index, item) in planned_items.iter().enumerate() { + let track_id = item.staged().track_id(); + let stream_index = stream_index_by_track[&track_id]; + let sample_index = sample_index_in_stream.entry(track_id).or_insert(0); + let event = MuxSampleEvent { + stream_index, + sample_index_in_stream: *sample_index, + planned_item: *item, + }; + let current_sample_index = *sample_index; + *sample_index += 1; + events.push(MuxEvent::Sample(event)); + + if let Some(kind) = + coordination.duration_boundary_after_sample(track_id, current_sample_index) + { + events.push(MuxEvent::Boundary(MuxBoundaryEvent { + kind: boundary_kind_from_duration(kind), + stream_index: Some(stream_index), + track_id: Some(track_id), + output_offset: item.output_end_offset(), + decode_time: item.decode_end_time(), + })); + } + + if last_sample_index_by_track.get(&track_id) == Some(&planned_index) { + events.push(MuxEvent::Boundary(MuxBoundaryEvent { + kind: MuxBoundaryEventKind::TrackDrain, + stream_index: Some(stream_index), + track_id: Some(track_id), + output_offset: item.output_end_offset(), + decode_time: item.decode_end_time(), + })); + } + } + + events.push(MuxEvent::Boundary(MuxBoundaryEvent { + kind: MuxBoundaryEventKind::PlanEnd, + stream_index: None, + track_id: None, + output_offset: total_payload_size, + decode_time: max_decode_end_time, + })); + + Self { events } + } + + pub(crate) fn cursor(&self) -> MuxEventCursor<'_> { + MuxEventCursor { + events: &self.events, + index: 0, + } + } + + #[cfg(test)] + pub(crate) fn events(&self) -> &[MuxEvent] { + &self.events + } +} + +const fn boundary_kind_from_duration(kind: MuxDurationBoundaryKind) -> MuxBoundaryEventKind { + match kind { + MuxDurationBoundaryKind::Segment => MuxBoundaryEventKind::SegmentBoundary, + MuxDurationBoundaryKind::Fragment => MuxBoundaryEventKind::FragmentBoundary, + } +} + +pub(crate) struct MuxEventCursor<'a> { + events: &'a [MuxEvent], + index: usize, +} + +impl<'a> MuxEventCursor<'a> { + pub(crate) fn next_sample(&mut self) -> Option<&'a MuxSampleEvent> { + while let Some(event) = self.events.get(self.index) { + self.index += 1; + if let MuxEvent::Sample(sample) = event { + return Some(sample); + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mux::{ + MuxDurationBoundaryKind, MuxInterleavePolicy, MuxStagedMediaItem, + TrackCoordinationDirective, plan_staged_media_items, + plan_staged_media_items_with_coordination, + }; + + #[test] + fn event_graph_emits_streams_samples_track_drains_and_plan_end() { + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 2, 10, 4, 13, 2), + MuxStagedMediaItem::new(1, 1, 0, 5, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 2, 0, 4, 4, 5).with_composition_time_offset(2), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let events = plan.event_graph().events(); + assert!(matches!( + &events[0], + MuxEvent::StreamDescription(MuxStreamDescription { + stream_index: 0, + track_id: 1, + item_count: 1, + first_decode_time: 0, + end_decode_time: 5, + first_output_offset: 5, + end_output_offset: 9, + }) + )); + assert!(matches!( + &events[1], + MuxEvent::StreamDescription(MuxStreamDescription { + stream_index: 1, + track_id: 2, + item_count: 2, + first_decode_time: 0, + end_decode_time: 14, + first_output_offset: 0, + end_output_offset: 11, + }) + )); + assert!(matches!( + &events[2], + MuxEvent::Sample(MuxSampleEvent { + stream_index: 1, + sample_index_in_stream: 0, + planned_item, + }) if planned_item.output_offset() == 0 + )); + assert!(matches!( + &events[3], + MuxEvent::Sample(MuxSampleEvent { + stream_index: 0, + sample_index_in_stream: 0, + planned_item, + }) if planned_item.output_offset() == 5 + )); + assert!(matches!( + &events[4], + MuxEvent::Boundary(MuxBoundaryEvent { + kind: MuxBoundaryEventKind::TrackDrain, + stream_index: Some(0), + track_id: Some(1), + output_offset: 9, + decode_time: 5, + }) + )); + assert!(matches!( + &events[5], + MuxEvent::Sample(MuxSampleEvent { + stream_index: 1, + sample_index_in_stream: 1, + planned_item, + }) if planned_item.output_offset() == 9 + )); + assert!(matches!( + &events[6], + MuxEvent::Boundary(MuxBoundaryEvent { + kind: MuxBoundaryEventKind::TrackDrain, + stream_index: Some(1), + track_id: Some(2), + output_offset: 11, + decode_time: 14, + }) + )); + assert!(matches!( + &events[7], + MuxEvent::Boundary(MuxBoundaryEvent { + kind: MuxBoundaryEventKind::PlanEnd, + stream_index: None, + track_id: None, + output_offset: 11, + decode_time: 14, + }) + )); + } + + #[test] + fn event_cursor_skips_non_sample_events_when_asked_for_samples() { + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 4, 4, 5), + MuxStagedMediaItem::new(1, 2, 5, 4, 4, 4), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut cursor = plan.event_graph().cursor(); + + let first = cursor.next_sample().unwrap(); + assert_eq!(first.stream_index, 0); + assert_eq!(first.sample_index_in_stream, 0); + assert_eq!(first.planned_item.output_offset(), 0); + + let second = cursor.next_sample().unwrap(); + assert_eq!(second.stream_index, 1); + assert_eq!(second.sample_index_in_stream, 0); + assert_eq!(second.planned_item.output_offset(), 5); + + assert!(cursor.next_sample().is_none()); + } + + #[test] + fn event_graph_emits_duration_boundaries_from_coordination() { + let plan = plan_staged_media_items_with_coordination( + vec![ + MuxStagedMediaItem::new(0, 7, 0, 10, 0, 3), + MuxStagedMediaItem::new(0, 7, 10, 10, 3, 3), + MuxStagedMediaItem::new(0, 7, 20, 10, 6, 3), + ], + MuxInterleavePolicy::DecodeTime, + vec![ + TrackCoordinationDirective::new(7, vec![2, 1]) + .with_duration_boundaries(MuxDurationBoundaryKind::Fragment), + ], + ) + .unwrap(); + + let events = plan.event_graph().events(); + assert!(matches!( + &events[3], + MuxEvent::Boundary(MuxBoundaryEvent { + kind: MuxBoundaryEventKind::FragmentBoundary, + stream_index: Some(0), + track_id: Some(7), + output_offset: 6, + decode_time: 20, + }) + )); + assert!(matches!( + &events[5], + MuxEvent::Boundary(MuxBoundaryEvent { + kind: MuxBoundaryEventKind::FragmentBoundary, + stream_index: Some(0), + track_id: Some(7), + output_offset: 9, + decode_time: 30, + }) + )); + assert!(matches!( + &events[6], + MuxEvent::Boundary(MuxBoundaryEvent { + kind: MuxBoundaryEventKind::TrackDrain, + stream_index: Some(0), + track_id: Some(7), + output_offset: 9, + decode_time: 30, + }) + )); + } +} diff --git a/src/mux/import.rs b/src/mux/import.rs new file mode 100644 index 0000000..98377b6 --- /dev/null +++ b/src/mux/import.rs @@ -0,0 +1,22119 @@ +use std::collections::BTreeMap; +use std::fs::File; +use std::io::Cursor; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +#[cfg(feature = "async")] +use std::pin::Pin; +#[cfg(feature = "async")] +use std::task::{Context, Poll}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::{ + AsyncRead, AsyncReadExt, AsyncSeek, AsyncSeekExt, AsyncWriteExt, BufWriter, ReadBuf, +}; + +use crate::FourCc; +#[cfg(feature = "async")] +use crate::async_io::AsyncReadSeek; +use crate::bitio::BitReader; +use crate::boxes::dts::Ddts; +use crate::boxes::iso14496_12::{ + AVCDecoderConfiguration, AudioSampleEntry, Btrt, Co64, Cslg, Ctts, Dref, Elst, + GenericMediaSampleEntry, HEVCDecoderConfiguration, Hdlr, Mdhd, Mvhd, Pasp, SampleEntry, Sbgp, + SbgpEntry, Sdtp, SdtpSampleElem, Sgpd, Stco, Stsc, Stss, Stsz, Stts, + TFHD_BASE_DATA_OFFSET_PRESENT, TFHD_DEFAULT_BASE_IS_MOOF, TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, + TFHD_DEFAULT_SAMPLE_FLAGS_PRESENT, TFHD_DEFAULT_SAMPLE_SIZE_PRESENT, + TFHD_SAMPLE_DESCRIPTION_INDEX_PRESENT, TRUN_DATA_OFFSET_PRESENT, + TRUN_FIRST_SAMPLE_FLAGS_PRESENT, TRUN_SAMPLE_COMPOSITION_TIME_OFFSET_PRESENT, + TRUN_SAMPLE_DURATION_PRESENT, TRUN_SAMPLE_FLAGS_PRESENT, TRUN_SAMPLE_SIZE_PRESENT, Tfdt, Tfhd, + Tkhd, Trex, Trun, Url, Urn, VisualSampleEntry, +}; +use crate::boxes::iso14496_14::{DECODER_CONFIG_DESCRIPTOR_TAG, Esds}; +use crate::boxes::vp::VpCodecConfiguration; +use crate::codec::{CodecBox, ImmutableBox, MutableBox}; +use crate::extract::{ + ExtractedBox, extract_box, extract_box_as, extract_box_bytes, extract_box_with_payload, +}; +#[cfg(feature = "async")] +use crate::extract::{ + extract_box_as_async, extract_box_async, extract_box_bytes_async, + extract_box_with_payload_async, +}; +use crate::header::BoxInfo as HeaderInfo; +use crate::walk::BoxPath; + +use super::demux::{ + DetectedContainerPathKind, DetectedNhmlSidecarKind, DetectedPathTrackKind, ParsedAv1Track, + ParsedAv1TrackSource, ParsedDashSource, ParsedNhmlSource, ParsedNhmlSourceSpec, + PcmContainerKind, build_h264_sample_entry_from_avc_config_with_box_type_and_options, + detect_caf_track_kind_sync, detect_container_path_kind_from_path_and_prefix, + detect_id3_wrapped_audio_from_prefix, detect_nhml_sidecar_kind, detect_ogg_track_kind_sync, + detect_path_track_kind_from_prefix, id3v2_size_from_prefix, nal_to_rbsp, + parse_dash_source_sync, parse_nhml_source_sync, read_ue_labeled, scan_ac3_file_sync, + scan_ac4_file_sync, scan_adts_file_sync, scan_amr_file_sync, scan_amr_wb_file_sync, + scan_av1_file_sync, scan_avi_source_sync, scan_bmp_file_sync, scan_caf_alac_file_sync, + scan_dts_file_sync, scan_eac3_file_sync, scan_flac_file_sync, scan_h263_file_sync, + scan_iamf_file_sync, scan_j2k_file_sync, scan_jpeg_file_sync, scan_latm_file_sync, + scan_mhas_file_sync, scan_mp3_file_sync, scan_mp4v_file_sync, scan_mpeg2v_file_sync, + scan_ogg_flac_file_sync, scan_ogg_opus_file_sync, scan_ogg_speex_file_sync, + scan_ogg_theora_file_sync, scan_ogg_vorbis_file_sync, scan_pcm_file_sync, scan_png_file_sync, + scan_program_stream_sync, scan_prores_file_sync, scan_qcp_file_sync, scan_raw_video_file_sync, + scan_transport_stream_sync, scan_truehd_file_sync, scan_vobsub_source_sync, scan_vp8_file_sync, + scan_vp9_file_sync, scan_vp10_file_sync, scan_y4m_file_sync, stage_annex_b_h264_sync, + stage_annex_b_h265_sync, stage_annex_b_vvc_sync, wrapped_dts_family_has_native_core_sync_sync, +}; +#[cfg(feature = "async")] +use super::demux::{ + detect_caf_track_kind_async, detect_ogg_track_kind_async, parse_dash_source_async, + parse_nhml_source_async, scan_ac3_file_async, scan_ac4_file_async, scan_adts_file_async, + scan_amr_file_async, scan_amr_wb_file_async, scan_av1_file_async, scan_avi_source_async, + scan_bmp_file_async, scan_caf_alac_file_async, scan_dts_file_async, scan_eac3_file_async, + scan_flac_file_async, scan_h263_file_async, scan_iamf_file_async, scan_j2k_file_async, + scan_jpeg_file_async, scan_latm_file_async, scan_mhas_file_async, scan_mp3_file_async, + scan_mp4v_file_async, scan_mpeg2v_file_async, scan_ogg_flac_file_async, + scan_ogg_opus_file_async, scan_ogg_speex_file_async, scan_ogg_theora_file_async, + scan_ogg_vorbis_file_async, scan_pcm_file_async, scan_png_file_async, + scan_program_stream_async, scan_prores_file_async, scan_qcp_file_async, + scan_raw_video_file_async, scan_transport_stream_async, scan_truehd_file_async, + scan_vobsub_source_async, scan_vp8_file_async, scan_vp9_file_async, scan_vp10_file_async, + scan_y4m_file_async, stage_annex_b_h264_async, stage_annex_b_h265_async, + stage_annex_b_vvc_async, wrapped_dts_family_has_native_core_sync_async, +}; +use super::inspect::{ + DirectIngestDetectedKind, DirectIngestPacketEntry, DirectIngestPacketReport, + DirectIngestReport, DirectIngestSampleReport, DirectIngestSourceSegmentReport, + DirectIngestStagedSourceReport, DirectIngestTrackReport, +}; +use super::mp4::{write_fragmented_mp4_mux, write_fragmented_mp4_mux_split}; +#[cfg(feature = "async")] +use super::mp4::{write_fragmented_mp4_mux_async, write_fragmented_mp4_mux_split_async}; +#[cfg(feature = "async")] +use super::write_mp4_mux_async; +use super::{ + FlatTimingOverride, MuxDestinationMode, MuxDurationBoundaryKind, MuxError, MuxFileConfig, + MuxInterleavePolicy, MuxMp4TrackSelector, MuxOutputLayout, MuxRawCodec, MuxRawVideoParams, + MuxRequest, MuxStagedMediaItem, MuxTrackConfig, MuxTrackKind, MuxTrackSpec, + StscRunEncodingMode, SttsRunEncodingMode, SyncSampleTableMode, TrackCoordinationDirective, + build_capped_duration_chunk_sample_counts, build_duration_chunk_sample_counts, + build_duration_chunk_sample_counts_with_start_time, + build_fragmented_duration_chunk_sample_counts_with_start_time, + build_sync_aligned_fragmented_duration_chunk_sample_counts, + build_sync_aligned_segment_chunk_sample_counts, plan_staged_media_items_with_coordination, + rebalance_small_multi_audio_chunk_sample_counts, write_mp4_mux, +}; + +const MOOV: FourCc = FourCc::from_bytes(*b"moov"); +const MVHD: FourCc = FourCc::from_bytes(*b"mvhd"); +const TRAK: FourCc = FourCc::from_bytes(*b"trak"); +const TKHD: FourCc = FourCc::from_bytes(*b"tkhd"); +const EDTS: FourCc = FourCc::from_bytes(*b"edts"); +const ELST: FourCc = FourCc::from_bytes(*b"elst"); +const TREF: FourCc = FourCc::from_bytes(*b"tref"); +const MDIA: FourCc = FourCc::from_bytes(*b"mdia"); +const MDHD: FourCc = FourCc::from_bytes(*b"mdhd"); +const HDLR: FourCc = FourCc::from_bytes(*b"hdlr"); +const MINF: FourCc = FourCc::from_bytes(*b"minf"); +const DINF: FourCc = FourCc::from_bytes(*b"dinf"); +const DREF: FourCc = FourCc::from_bytes(*b"dref"); +const URL: FourCc = FourCc::from_bytes(*b"url "); +const URN: FourCc = FourCc::from_bytes(*b"urn "); +const STBL: FourCc = FourCc::from_bytes(*b"stbl"); +const STSD: FourCc = FourCc::from_bytes(*b"stsd"); +const STTS: FourCc = FourCc::from_bytes(*b"stts"); +const CTTS: FourCc = FourCc::from_bytes(*b"ctts"); +const STSC: FourCc = FourCc::from_bytes(*b"stsc"); +const STSZ: FourCc = FourCc::from_bytes(*b"stsz"); +const STCO: FourCc = FourCc::from_bytes(*b"stco"); +const CO64: FourCc = FourCc::from_bytes(*b"co64"); +const STSS: FourCc = FourCc::from_bytes(*b"stss"); +const CSLG: FourCc = FourCc::from_bytes(*b"cslg"); +const SDTP: FourCc = FourCc::from_bytes(*b"sdtp"); +const STPS: FourCc = FourCc::from_bytes(*b"stps"); +const STDP: FourCc = FourCc::from_bytes(*b"stdp"); +const SUBS: FourCc = FourCc::from_bytes(*b"subs"); +const SGPD: FourCc = FourCc::from_bytes(*b"sgpd"); +const SBGP: FourCc = FourCc::from_bytes(*b"sbgp"); +const STZ2: FourCc = FourCc::from_bytes(*b"stz2"); +const FTYP: FourCc = FourCc::from_bytes(*b"ftyp"); +const FREE: FourCc = FourCc::from_bytes(*b"free"); +const IODS: FourCc = FourCc::from_bytes(*b"iods"); +const SKIP: FourCc = FourCc::from_bytes(*b"skip"); +const WIDE: FourCc = FourCc::from_bytes(*b"wide"); +const MVEX: FourCc = FourCc::from_bytes(*b"mvex"); +const TREX: FourCc = FourCc::from_bytes(*b"trex"); +const MOOF: FourCc = FourCc::from_bytes(*b"moof"); +const TRAF: FourCc = FourCc::from_bytes(*b"traf"); +const TFHD: FourCc = FourCc::from_bytes(*b"tfhd"); +const TRUN: FourCc = FourCc::from_bytes(*b"trun"); +const UDTA: FourCc = FourCc::from_bytes(*b"udta"); +const VIDE: FourCc = FourCc::from_bytes(*b"vide"); +const PATH_KIND_PREFIX_BYTES: usize = 2_048; +const SOUN: FourCc = FourCc::from_bytes(*b"soun"); +const TEXT: FourCc = FourCc::from_bytes(*b"text"); +const SUBT: FourCc = FourCc::from_bytes(*b"subt"); +const DEFAULT_FRAGMENTED_REFERENCE_GROUP_SECONDS: u64 = 6; +const LOCAL_DASH_FLAT_TOOL_METADATA_VALUE: &str = + concat!(env!("CARGO_PKG_NAME"), " v", env!("CARGO_PKG_VERSION")); +const SUBP: FourCc = FourCc::from_bytes(*b"subp"); +const ENCV: FourCc = FourCc::from_bytes(*b"encv"); +const ENCA: FourCc = FourCc::from_bytes(*b"enca"); +const NON_KEY_SAMPLE_FLAGS: u32 = 0x0001_0000; +const AUTO_FLAT_INTERLEAVE_MILLISECONDS: u64 = 500; +const IMPORTED_DDTS_FRAME_DURATION: u8 = 3; +const IMPORTED_DDTS_STREAM_CONSTRUCTION: u8 = 18; +const IMPORTED_DDTS_CORE_LAYOUT: u8 = 31; +const IMPORTED_DDTS_REPRESENTATION_TYPE: u8 = 4; +const IMPORTED_DDTS_CHANNEL_LAYOUT_MASK: u16 = 0x000f; + +fn mux_io_at_path(operation: &'static str, path: &Path, source: io::Error) -> MuxError { + MuxError::Io(io::Error::new( + source.kind(), + format!("failed to {operation} `{}`: {source}", path.display()), + )) +} + +/// Opens the requested track specs, validates the narrowed mux request shape, and writes one newly +/// created output MP4 file to `output_path`. +/// +/// This task-level helper is the sync programmatic companion to the explicit `--out PATH` mux CLI +/// surface. It always treats `output_path` as a newly created destination and rejects unsupported +/// multi-video or duration-mode combinations explicitly. +pub fn mux_to_path

(request: &MuxRequest, output_path: P) -> Result<(), MuxError> +where + P: AsRef, +{ + let request = request + .clone() + .with_destination_mode(MuxDestinationMode::CreateNew); + mux_to_path_inner(&request, output_path.as_ref()) +} + +/// Opens the requested track specs, preserves an existing MP4 destination when present, and +/// otherwise creates one new output MP4 at `destination_path`. +/// +/// When `destination_path` already exists and probes as MP4, this helper preserves that file's +/// current tracks and imports the requested tracks into it. When the path does not exist or does +/// not probe as MP4, the same path is treated as the newly created destination file. +pub fn mux_into_path

(request: &MuxRequest, destination_path: P) -> Result<(), MuxError> +where + P: AsRef, +{ + let request = request + .clone() + .with_destination_mode(MuxDestinationMode::UpdateOrCreateDestination); + mux_into_path_inner(&request, destination_path.as_ref()) +} + +/// Opens the requested track specs and writes fragmented initialization and media data to separate +/// newly created paths. +/// +/// This helper requires [`MuxOutputLayout::Fragmented`] and a duration mode on the request. The +/// two destination paths must be distinct, must not already exist, and must not alias an input +/// track path. On failure before both outputs are finalized, temporary outputs are removed. +pub fn mux_fragmented_to_paths( + request: &MuxRequest, + init_path: P, + media_path: Q, +) -> Result<(), MuxError> +where + P: AsRef, + Q: AsRef, +{ + let request = request + .clone() + .with_destination_mode(MuxDestinationMode::CreateNew); + mux_fragmented_to_paths_inner(&request, init_path.as_ref(), media_path.as_ref()) +} + +fn mux_to_path_inner(request: &MuxRequest, output_path: &Path) -> Result<(), MuxError> { + let prepared = prepare_request_sync(request, output_path)?; + let mut sources = prepared + .source_specs + .iter() + .map(SyncMuxSource::open) + .collect::, _>>()?; + let mut writer = std::io::BufWriter::new( + File::create(output_path) + .map_err(|error| mux_io_at_path("create mux output", output_path, error))?, + ); + match prepared.output_layout { + MuxOutputLayout::Flat => write_mp4_mux( + &mut sources, + &mut writer, + &prepared.file_config, + &prepared.track_configs, + &prepared.plan, + )?, + MuxOutputLayout::Fragmented => write_fragmented_mp4_mux( + &mut sources, + &mut writer, + &prepared.file_config, + &prepared.track_configs, + prepared.fragmented_single_sidx_reference, + &prepared.plan, + )?, + } + writer.flush()?; + Ok(()) +} + +fn mux_fragmented_to_paths_inner( + request: &MuxRequest, + init_path: &Path, + media_path: &Path, +) -> Result<(), MuxError> { + validate_fragmented_split_paths(request, init_path, media_path)?; + let prepared = prepare_request_sync(request, media_path)?; + let mut sources = prepared + .source_specs + .iter() + .map(SyncMuxSource::open) + .collect::, _>>()?; + ensure_output_parent_dir(init_path)?; + ensure_output_parent_dir(media_path)?; + let init_temp_path = create_update_temp_path(init_path, MuxDestinationMode::CreateNew)?; + let media_temp_path = create_update_temp_path(media_path, MuxDestinationMode::CreateNew)?; + let write_result = (|| { + let mut init_writer = + std::io::BufWriter::new(File::create(&init_temp_path).map_err(|error| { + mux_io_at_path("create mux init output", &init_temp_path, error) + })?); + let mut media_writer = + std::io::BufWriter::new(File::create(&media_temp_path).map_err(|error| { + mux_io_at_path("create mux media output", &media_temp_path, error) + })?); + write_fragmented_mp4_mux_split( + &mut sources, + &mut init_writer, + &mut media_writer, + &prepared.file_config, + &prepared.track_configs, + prepared.fragmented_single_sidx_reference, + &prepared.plan, + ) + })(); + if let Err(error) = write_result { + let _ = std::fs::remove_file(&init_temp_path); + let _ = std::fs::remove_file(&media_temp_path); + return Err(error); + } + finalize_new_split_output(&init_temp_path, init_path, None)?; + if let Err(error) = finalize_new_split_output(&media_temp_path, media_path, Some(init_path)) { + let _ = std::fs::remove_file(init_path); + return Err(error); + } + Ok(()) +} + +fn mux_into_path_inner(request: &MuxRequest, destination_path: &Path) -> Result<(), MuxError> { + if should_preserve_destination_mp4(destination_path) { + let amended_request = build_destination_preserving_request(request, destination_path)?; + let temp_path = create_update_temp_path(destination_path, request.destination_mode())?; + let write_result = mux_to_path_inner(&amended_request, &temp_path); + if let Err(error) = write_result { + let _ = std::fs::remove_file(&temp_path); + return Err(error); + } + replace_output_path(&temp_path, destination_path)?; + return Ok(()); + } + let create_new_request = request + .clone() + .with_destination_mode(MuxDestinationMode::CreateNew); + mux_to_path_inner(&create_new_request, destination_path) +} + +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(all(feature = "mux", feature = "async"))))] +/// Async companion to [`mux_to_path`] that keeps the file-backed mux path on the crate's additive +/// Tokio-based async surface. +/// +/// The request validation and supported public behavior match the sync helper exactly; only the +/// file-backed I/O path differs. +pub async fn mux_to_path_async

(request: &MuxRequest, output_path: P) -> Result<(), MuxError> +where + P: AsRef, +{ + let request = request + .clone() + .with_destination_mode(MuxDestinationMode::CreateNew); + mux_to_path_async_inner(&request, output_path.as_ref()).await +} + +/// Async companion to [`mux_into_path`] on the file-backed Tokio surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(all(feature = "mux", feature = "async"))))] +pub async fn mux_into_path_async

( + request: &MuxRequest, + destination_path: P, +) -> Result<(), MuxError> +where + P: AsRef, +{ + let request = request + .clone() + .with_destination_mode(MuxDestinationMode::UpdateOrCreateDestination); + mux_into_path_async_inner(&request, destination_path.as_ref()).await +} + +/// Async companion to [`mux_fragmented_to_paths`] for separate fragmented init/media outputs. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(all(feature = "mux", feature = "async"))))] +pub async fn mux_fragmented_to_paths_async( + request: &MuxRequest, + init_path: P, + media_path: Q, +) -> Result<(), MuxError> +where + P: AsRef, + Q: AsRef, +{ + let request = request + .clone() + .with_destination_mode(MuxDestinationMode::CreateNew); + mux_fragmented_to_paths_async_inner(&request, init_path.as_ref(), media_path.as_ref()).await +} + +#[cfg(feature = "async")] +async fn mux_to_path_async_inner(request: &MuxRequest, output_path: &Path) -> Result<(), MuxError> { + let prepared = prepare_request_async(request, output_path).await?; + let mut sources = Vec::with_capacity(prepared.source_specs.len()); + for spec in &prepared.source_specs { + sources.push(AsyncMuxSource::open(spec).await?); + } + let output = TokioFile::create(output_path) + .await + .map_err(|error| mux_io_at_path("create mux output", output_path, error))?; + let mut writer = BufWriter::new(output); + match prepared.output_layout { + MuxOutputLayout::Flat => { + write_mp4_mux_async( + &mut sources, + &mut writer, + &prepared.file_config, + &prepared.track_configs, + &prepared.plan, + ) + .await? + } + MuxOutputLayout::Fragmented => { + write_fragmented_mp4_mux_async( + &mut sources, + &mut writer, + &prepared.file_config, + &prepared.track_configs, + prepared.fragmented_single_sidx_reference, + &prepared.plan, + ) + .await? + } + } + writer.flush().await?; + Ok(()) +} + +#[cfg(feature = "async")] +async fn mux_fragmented_to_paths_async_inner( + request: &MuxRequest, + init_path: &Path, + media_path: &Path, +) -> Result<(), MuxError> { + validate_fragmented_split_paths_async(request, init_path, media_path).await?; + let prepared = prepare_request_async(request, media_path).await?; + let mut sources = Vec::with_capacity(prepared.source_specs.len()); + for spec in &prepared.source_specs { + sources.push(AsyncMuxSource::open(spec).await?); + } + ensure_output_parent_dir_async(init_path).await?; + ensure_output_parent_dir_async(media_path).await?; + let init_temp_path = create_update_temp_path(init_path, MuxDestinationMode::CreateNew)?; + let media_temp_path = create_update_temp_path(media_path, MuxDestinationMode::CreateNew)?; + let write_result = async { + let init_output = TokioFile::create(&init_temp_path) + .await + .map_err(|error| mux_io_at_path("create mux init output", &init_temp_path, error))?; + let media_output = TokioFile::create(&media_temp_path) + .await + .map_err(|error| mux_io_at_path("create mux media output", &media_temp_path, error))?; + let mut init_writer = BufWriter::new(init_output); + let mut media_writer = BufWriter::new(media_output); + write_fragmented_mp4_mux_split_async( + &mut sources, + &mut init_writer, + &mut media_writer, + &prepared.file_config, + &prepared.track_configs, + prepared.fragmented_single_sidx_reference, + &prepared.plan, + ) + .await + } + .await; + if let Err(error) = write_result { + let _ = tokio::fs::remove_file(&init_temp_path).await; + let _ = tokio::fs::remove_file(&media_temp_path).await; + return Err(error); + } + finalize_new_split_output_async(&init_temp_path, init_path, None).await?; + if let Err(error) = + finalize_new_split_output_async(&media_temp_path, media_path, Some(init_path)).await + { + let _ = tokio::fs::remove_file(init_path).await; + return Err(error); + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn mux_into_path_async_inner( + request: &MuxRequest, + destination_path: &Path, +) -> Result<(), MuxError> { + if should_preserve_destination_mp4_async(destination_path).await { + let amended_request = build_destination_preserving_request(request, destination_path)?; + let temp_path = create_update_temp_path(destination_path, request.destination_mode())?; + let write_result = mux_to_path_async_inner(&amended_request, &temp_path).await; + if let Err(error) = write_result { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(error); + } + replace_output_path_async(&temp_path, destination_path).await?; + return Ok(()); + } + let create_new_request = request + .clone() + .with_destination_mode(MuxDestinationMode::CreateNew); + mux_to_path_async_inner(&create_new_request, destination_path).await +} + +struct PreparedMuxRequest { + output_layout: MuxOutputLayout, + file_config: MuxFileConfig, + track_configs: Vec, + fragmented_single_sidx_reference: bool, + plan: super::MuxPlan, + source_specs: Vec, +} + +struct FragmentRunContext<'a> { + path: &'a Path, + source_index: usize, + track_id: u32, + moof_offset: u64, + trex: Option<&'a Trex>, +} + +struct ImportedFragmentBatch { + base_decode_time: Option, + samples: Vec, + sample_description_indices: Vec, +} + +struct ImportedFragmentSamples { + samples: Vec, + sample_description_indices: Vec, + first_base_decode_time: Option, + fragmented_decode_time_gaps: Vec, +} + +#[derive(Clone)] +enum SourceSpec { + File(PathBuf), + Segmented(SegmentedMuxSourceSpec), +} + +#[derive(Clone)] +pub(in crate::mux) struct SegmentedMuxSourceSpec { + pub(in crate::mux) path: PathBuf, + pub(in crate::mux) segments: Vec, + pub(in crate::mux) total_size: u64, +} + +#[derive(Clone)] +pub(in crate::mux) struct SegmentedMuxSourceSegment { + pub(in crate::mux) logical_offset: u64, + pub(in crate::mux) data: SegmentedMuxSourceSegmentData, +} + +#[derive(Clone)] +pub(in crate::mux) enum SegmentedMuxSourceSegmentData { + Prefix([u8; 4]), + Bytes(Vec), + FileRange { + source_offset: u64, + size: u32, + }, + ExternalFileRange { + path: PathBuf, + source_offset: u64, + size: u32, + }, +} + +impl SegmentedMuxSourceSegment { + pub(in crate::mux) fn logical_size(&self) -> u64 { + match &self.data { + SegmentedMuxSourceSegmentData::Prefix(_) => 4, + SegmentedMuxSourceSegmentData::Bytes(bytes) => u64::try_from(bytes.len()).unwrap(), + SegmentedMuxSourceSegmentData::FileRange { size, .. } => u64::from(*size), + SegmentedMuxSourceSegmentData::ExternalFileRange { size, .. } => u64::from(*size), + } + } + + pub(in crate::mux) fn logical_end(&self) -> u64 { + self.logical_offset + self.logical_size() + } +} + +fn find_segmented_source_segment_index( + segments: &[SegmentedMuxSourceSegment], + position: u64, +) -> Option { + segments + .binary_search_by(|segment| { + if segment.logical_end() <= position { + std::cmp::Ordering::Less + } else if segment.logical_offset > position { + std::cmp::Ordering::Greater + } else { + std::cmp::Ordering::Equal + } + }) + .ok() +} + +fn seek_mux_source_position(position: u64, end: u64, target: SeekFrom) -> io::Result { + let next = match target { + SeekFrom::Start(offset) => i128::from(offset), + SeekFrom::Current(delta) => i128::from(position) + i128::from(delta), + SeekFrom::End(delta) => i128::from(end) + i128::from(delta), + }; + if next < 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid seek before start of segmented mux source", + )); + } + u64::try_from(next).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "invalid seek target for segmented mux source", + ) + }) +} + +struct SyncMuxSource { + inner: SyncMuxSourceInner, +} + +enum SyncMuxSourceInner { + File(File), + Segmented(SegmentedSyncMuxSource), +} + +struct SegmentedSyncMuxSource { + primary_path: PathBuf, + file: File, + extra_files: BTreeMap, + segments: Vec, + total_size: u64, + position: u64, + file_path: Option, + file_position: Option, +} + +impl SyncMuxSource { + fn open(spec: &SourceSpec) -> Result { + let inner = match spec { + SourceSpec::File(path) => SyncMuxSourceInner::File( + File::open(path).map_err(|error| mux_io_at_path("open mux input", path, error))?, + ), + SourceSpec::Segmented(spec) => SyncMuxSourceInner::Segmented(SegmentedSyncMuxSource { + primary_path: spec.path.clone(), + file: File::open(&spec.path) + .map_err(|error| mux_io_at_path("open mux input", &spec.path, error))?, + extra_files: BTreeMap::new(), + segments: spec.segments.clone(), + total_size: spec.total_size, + position: 0, + file_path: None, + file_position: None, + }), + }; + Ok(Self { inner }) + } +} + +impl SegmentedSyncMuxSource { + fn file_for_path_mut(&mut self, path: &Path) -> io::Result<&mut File> { + if path == self.primary_path { + return Ok(&mut self.file); + } + if !self.extra_files.contains_key(path) { + let opened = File::open(path)?; + self.extra_files.insert(path.to_path_buf(), opened); + } + Ok(self.extra_files.get_mut(path).unwrap()) + } + + fn read_file_range_into( + &mut self, + path: &Path, + source_offset: u64, + size: u32, + segment_offset: usize, + buf: &mut [u8], + written: &mut usize, + ) -> io::Result<()> { + let available = + usize::try_from(u64::from(size) - u64::try_from(segment_offset).unwrap()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "segment size overflow"))?; + let to_read = available.min(buf.len() - *written); + let file_offset = source_offset + u64::try_from(segment_offset).unwrap(); + let should_seek = + self.file_path.as_deref() != Some(path) || self.file_position != Some(file_offset); + let read = { + let file = self.file_for_path_mut(path)?; + if should_seek { + file.seek(SeekFrom::Start(file_offset))?; + } + file.read(&mut buf[*written..*written + to_read])? + }; + if read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "truncated segmented mux source input", + )); + } + *written += read; + self.position += u64::try_from(read).unwrap(); + self.file_path = Some(path.to_path_buf()); + self.file_position = Some(file_offset + u64::try_from(read).unwrap()); + Ok(()) + } + + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if buf.is_empty() || self.position >= self.total_size { + return Ok(0); + } + + let mut written = 0usize; + while written < buf.len() && self.position < self.total_size { + let Some(segment_index) = + find_segmented_source_segment_index(&self.segments, self.position) + else { + break; + }; + let segment = &self.segments[segment_index]; + let segment_logical_offset = segment.logical_offset; + let segment_offset = + usize::try_from(self.position - segment_logical_offset).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "logical offset overflow") + })?; + match &segment.data { + SegmentedMuxSourceSegmentData::Prefix(prefix) => { + let available = prefix.len().saturating_sub(segment_offset); + let to_copy = available.min(buf.len() - written); + buf[written..written + to_copy] + .copy_from_slice(&prefix[segment_offset..segment_offset + to_copy]); + written += to_copy; + self.position += u64::try_from(to_copy).unwrap(); + } + SegmentedMuxSourceSegmentData::Bytes(bytes) => { + let available = bytes.len().saturating_sub(segment_offset); + let to_copy = available.min(buf.len() - written); + buf[written..written + to_copy] + .copy_from_slice(&bytes[segment_offset..segment_offset + to_copy]); + written += to_copy; + self.position += u64::try_from(to_copy).unwrap(); + } + SegmentedMuxSourceSegmentData::FileRange { + source_offset, + size, + } => { + let source_offset = *source_offset; + let size = *size; + let primary_path = self.primary_path.clone(); + self.read_file_range_into( + &primary_path, + source_offset, + size, + segment_offset, + buf, + &mut written, + )? + } + SegmentedMuxSourceSegmentData::ExternalFileRange { + path, + source_offset, + size, + } => { + let path = path.clone(); + let source_offset = *source_offset; + let size = *size; + self.read_file_range_into( + &path, + source_offset, + size, + segment_offset, + buf, + &mut written, + )? + } + } + } + Ok(written) + } + + fn seek(&mut self, target: SeekFrom) -> io::Result { + self.position = seek_mux_source_position(self.position, self.total_size, target)?; + Ok(self.position) + } +} + +impl Read for SyncMuxSource { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + match &mut self.inner { + SyncMuxSourceInner::File(file) => file.read(buf), + SyncMuxSourceInner::Segmented(source) => source.read(buf), + } + } +} + +impl Seek for SyncMuxSource { + fn seek(&mut self, pos: SeekFrom) -> io::Result { + match &mut self.inner { + SyncMuxSourceInner::File(file) => file.seek(pos), + SyncMuxSourceInner::Segmented(source) => source.seek(pos), + } + } +} + +#[cfg(feature = "async")] +struct AsyncMuxSource { + inner: AsyncMuxSourceInner, +} + +#[cfg(feature = "async")] +enum AsyncMuxSourceInner { + File(TokioFile), + Segmented(SegmentedAsyncMuxSource), +} + +#[cfg(feature = "async")] +struct SegmentedAsyncMuxSource { + primary_path: PathBuf, + file: TokioFile, + extra_files: BTreeMap, + segments: Vec, + total_size: u64, + position: u64, + file_path: Option, + file_position: Option, + pending_file_seek: Option<(PathBuf, u64)>, +} + +#[cfg(feature = "async")] +impl AsyncMuxSource { + async fn open(spec: &SourceSpec) -> Result { + let inner = match spec { + SourceSpec::File(path) => AsyncMuxSourceInner::File( + TokioFile::open(path) + .await + .map_err(|error| mux_io_at_path("open mux input", path, error))?, + ), + SourceSpec::Segmented(spec) => { + AsyncMuxSourceInner::Segmented(SegmentedAsyncMuxSource { + primary_path: spec.path.clone(), + file: TokioFile::open(&spec.path) + .await + .map_err(|error| mux_io_at_path("open mux input", &spec.path, error))?, + extra_files: BTreeMap::new(), + segments: spec.segments.clone(), + total_size: spec.total_size, + position: 0, + file_path: None, + file_position: None, + pending_file_seek: None, + }) + } + }; + let mut source = Self { inner }; + if let AsyncMuxSourceInner::Segmented(segmented) = &mut source.inner { + segmented.open_external_files().await?; + } + Ok(source) + } +} + +#[cfg(feature = "async")] +impl SegmentedAsyncMuxSource { + fn file_for_path_mut(&mut self, path: &Path) -> io::Result<&mut TokioFile> { + if path == self.primary_path { + return Ok(&mut self.file); + } + if !self.extra_files.contains_key(path) { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!( + "segmented async mux source file `{}` was not opened before polling", + path.display() + ), + )); + } + Ok(self.extra_files.get_mut(path).unwrap()) + } + + async fn open_external_files(&mut self) -> io::Result<()> { + let mut pending = Vec::new(); + for segment in &self.segments { + if let SegmentedMuxSourceSegmentData::ExternalFileRange { path, .. } = &segment.data + && !self.extra_files.contains_key(path) + { + pending.push(path.clone()); + } + } + for path in pending { + let file = TokioFile::open(&path).await?; + self.extra_files.insert(path, file); + } + Ok(()) + } + + fn start_seek(&mut self, target: SeekFrom) -> io::Result<()> { + self.position = seek_mux_source_position(self.position, self.total_size, target)?; + Ok(()) + } + + fn poll_complete(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(self.position)) + } + + fn poll_read_internal( + &mut self, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if buf.remaining() == 0 || self.position >= self.total_size { + return Poll::Ready(Ok(())); + } + + let Some(segment_index) = + find_segmented_source_segment_index(&self.segments, self.position) + else { + return Poll::Ready(Ok(())); + }; + let segment = &self.segments[segment_index]; + let segment_offset = usize::try_from(self.position - segment.logical_offset) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "logical offset overflow"))?; + match &segment.data { + SegmentedMuxSourceSegmentData::Prefix(prefix) => { + let available = prefix.len().saturating_sub(segment_offset); + let to_copy = available.min(buf.remaining()); + buf.put_slice(&prefix[segment_offset..segment_offset + to_copy]); + self.position += u64::try_from(to_copy).unwrap(); + Poll::Ready(Ok(())) + } + SegmentedMuxSourceSegmentData::Bytes(bytes) => { + let available = bytes.len().saturating_sub(segment_offset); + let to_copy = available.min(buf.remaining()); + buf.put_slice(&bytes[segment_offset..segment_offset + to_copy]); + self.position += u64::try_from(to_copy).unwrap(); + Poll::Ready(Ok(())) + } + SegmentedMuxSourceSegmentData::FileRange { + source_offset, + size, + } => { + let path = self.primary_path.clone(); + self.poll_read_file_range(cx, buf, &path, *source_offset, *size, segment_offset) + } + SegmentedMuxSourceSegmentData::ExternalFileRange { + path, + source_offset, + size, + } => { + let path = path.clone(); + self.poll_read_file_range(cx, buf, &path, *source_offset, *size, segment_offset) + } + } + } + + fn poll_read_file_range( + &mut self, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + path: &Path, + source_offset: u64, + size: u32, + segment_offset: usize, + ) -> Poll> { + let available = + match usize::try_from(u64::from(size) - u64::try_from(segment_offset).unwrap()) { + Ok(value) => value, + Err(_) => { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + "segment size overflow", + ))); + } + }; + let to_read = available.min(buf.remaining()); + let file_offset = source_offset + u64::try_from(segment_offset).unwrap(); + let should_seek = + self.file_path.as_deref() != Some(path) || self.file_position != Some(file_offset); + if should_seek { + if self.pending_file_seek.is_none() { + let start_seek = { + let file = match self.file_for_path_mut(path) { + Ok(file) => file, + Err(error) => return Poll::Ready(Err(error)), + }; + Pin::new(file).start_seek(SeekFrom::Start(file_offset)) + }; + if let Err(error) = start_seek { + return Poll::Ready(Err(error)); + } + self.pending_file_seek = Some((path.to_path_buf(), file_offset)); + } + let seek_target = self.pending_file_seek.clone().unwrap(); + let poll = { + let file = match self.file_for_path_mut(&seek_target.0) { + Ok(file) => file, + Err(error) => return Poll::Ready(Err(error)), + }; + Pin::new(file).poll_complete(cx) + }; + match poll { + Poll::Ready(Ok(position)) => { + self.pending_file_seek = None; + self.file_path = Some(path.to_path_buf()); + self.file_position = Some(position); + } + Poll::Ready(Err(error)) => { + self.pending_file_seek = None; + return Poll::Ready(Err(error)); + } + Poll::Pending => return Poll::Pending, + } + } + + let read = { + let dst = buf.initialize_unfilled_to(to_read); + let mut limited = ReadBuf::new(dst); + let file = match self.file_for_path_mut(path) { + Ok(file) => file, + Err(error) => return Poll::Ready(Err(error)), + }; + match Pin::new(file).poll_read(cx, &mut limited) { + Poll::Ready(Ok(())) => limited.filled().len(), + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Pending => return Poll::Pending, + } + }; + + if read == 0 { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "truncated segmented mux source input", + ))); + } + buf.advance(read); + self.position += u64::try_from(read).unwrap(); + self.file_path = Some(path.to_path_buf()); + self.file_position = Some(file_offset + u64::try_from(read).unwrap()); + Poll::Ready(Ok(())) + } +} + +#[cfg(feature = "async")] +impl AsyncRead for AsyncMuxSource { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match &mut self.inner { + AsyncMuxSourceInner::File(file) => Pin::new(file).poll_read(cx, buf), + AsyncMuxSourceInner::Segmented(source) => source.poll_read_internal(cx, buf), + } + } +} + +#[cfg(feature = "async")] +impl AsyncSeek for AsyncMuxSource { + fn start_seek(mut self: Pin<&mut Self>, position: SeekFrom) -> io::Result<()> { + match &mut self.inner { + AsyncMuxSourceInner::File(file) => Pin::new(file).start_seek(position), + AsyncMuxSourceInner::Segmented(source) => source.start_seek(position), + } + } + + fn poll_complete(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match &mut self.inner { + AsyncMuxSourceInner::File(file) => Pin::new(file).poll_complete(cx), + AsyncMuxSourceInner::Segmented(source) => source.poll_complete(cx), + } + } +} + +struct ImportedTrack { + kind: MuxTrackKind, + timescale: u32, + language: [u8; 3], + handler_name: String, + mux_policy: ImportedTrackMuxPolicy, + width: u16, + height: u16, + sample_entry_box: Vec, + source_edit_media_time: Option, + sample_roll_distance: Option, + samples: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct ImportedMp4TrackCarry { + flat_chunk_sample_counts: Option>, + flat_stsc: Option, + sample_entry_boxes: Option>>, + sample_description_indices: Option>, + fragmented_decode_time_gaps: Vec, + source_had_empty_stts: bool, + source_sync_samples: Option>, + preserved_flat_stbl_boxes: Vec>, + preserved_flat_trak_boxes: Vec>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct FragmentedDecodeTimeGap { + sample_index: usize, + delta: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct PreservedAuthorityFlatVideoAlignment { + timescale: u32, + sample_durations: Vec, + chunk_sample_counts: Vec, +} + +impl PreservedAuthorityFlatVideoAlignment { + fn driving_chunk_sample_counts(&self) -> &[u32] { + if self.chunk_sample_counts.len() > 1 && self.chunk_sample_counts.last() == Some(&1) { + &self.chunk_sample_counts[..self.chunk_sample_counts.len() - 1] + } else { + &self.chunk_sample_counts + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ImportedTrackHeaderPolicy { + tkhd_flags: u32, + alternate_group: i16, + volume: i16, + matrix: [i32; 9], + source_track_id: Option, + source_track_creation_time: Option, + source_track_modification_time: Option, + source_media_creation_time: Option, + source_media_modification_time: Option, + source_movie_timescale: Option, + source_media_duration: Option, + source_edit_segment_duration: Option, + source_media_decode_time_offset: Option, + source_stss_first_only: bool, +} + +const DEFAULT_IMPORTED_TKHD_FLAGS: u32 = 0x0000_0001 | 0x0000_0002 | 0x0000_0004; +const DEFAULT_IMPORTED_TKHD_MATRIX: [i32; 9] = + [0x0001_0000, 0, 0, 0, 0x0001_0000, 0, 0, 0, 0x4000_0000]; + +const fn default_imported_track_header_policy(kind: MuxTrackKind) -> ImportedTrackHeaderPolicy { + ImportedTrackHeaderPolicy { + tkhd_flags: DEFAULT_IMPORTED_TKHD_FLAGS, + alternate_group: match kind { + MuxTrackKind::Audio => 1, + MuxTrackKind::Subtitle => 0, + MuxTrackKind::Video | MuxTrackKind::Text => 0, + }, + volume: match kind { + MuxTrackKind::Audio => 0x0100, + MuxTrackKind::Video | MuxTrackKind::Text | MuxTrackKind::Subtitle => 0, + }, + matrix: DEFAULT_IMPORTED_TKHD_MATRIX, + source_track_id: None, + source_track_creation_time: None, + source_track_modification_time: None, + source_media_creation_time: None, + source_media_modification_time: None, + source_movie_timescale: None, + source_media_duration: None, + source_edit_segment_duration: None, + source_media_decode_time_offset: None, + source_stss_first_only: false, + } +} + +#[derive(Clone, Copy)] +struct ImportedSample { + source_index: usize, + data_offset: u64, + data_size: u32, + duration: u32, + composition_time_offset: i32, + is_sync_sample: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum ImportedDataReference { + SelfContained, + LocalFile(PathBuf), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FlatChunkingMode { + Auto, + AutoWithoutTerminalVideoSplit, + OneSamplePerChunk, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::mux) struct ImportedTrackMuxPolicy { + sync_sample_table_mode: SyncSampleTableMode, + stts_run_encoding_mode: SttsRunEncodingMode, + stsc_run_encoding_mode: StscRunEncodingMode, + flat_chunking_mode: FlatChunkingMode, + preferred_track_id: Option, + sample_roll_distance: Option, + emit_roll_sbgp: bool, + flat_audio_profile_level_indication: Option, + header_policy: Option, + strip_single_sample_dts_btrt: bool, +} + +impl ImportedTrackMuxPolicy { + const DEFAULT: Self = Self { + sync_sample_table_mode: SyncSampleTableMode::Auto, + stts_run_encoding_mode: SttsRunEncodingMode::CollapseIdentical, + stsc_run_encoding_mode: StscRunEncodingMode::CollapseIdentical, + flat_chunking_mode: FlatChunkingMode::Auto, + preferred_track_id: None, + sample_roll_distance: None, + emit_roll_sbgp: true, + flat_audio_profile_level_indication: None, + header_policy: None, + strip_single_sample_dts_btrt: false, + }; + + const fn with_preferred_track_id(mut self, preferred_track_id: u32) -> Self { + self.preferred_track_id = if preferred_track_id == 0 { + None + } else { + Some(preferred_track_id) + }; + self + } + + const fn preferred_track_id(self) -> Option { + self.preferred_track_id + } + + pub(crate) const fn sample_roll_distance(self) -> Option { + self.sample_roll_distance + } + + pub(crate) const fn with_sample_roll_distance(mut self, sample_roll_distance: i16) -> Self { + self.sample_roll_distance = Some(sample_roll_distance); + self + } + + pub(crate) const fn emit_roll_sbgp(self) -> bool { + self.emit_roll_sbgp + } + + pub(crate) const fn with_emit_roll_sbgp(mut self, emit_roll_sbgp: bool) -> Self { + self.emit_roll_sbgp = emit_roll_sbgp; + self + } + + pub(crate) const fn with_sync_sample_table_mode( + mut self, + sync_sample_table_mode: SyncSampleTableMode, + ) -> Self { + self.sync_sample_table_mode = sync_sample_table_mode; + self + } + + pub(crate) const fn flat_audio_profile_level_indication(self) -> Option { + self.flat_audio_profile_level_indication + } + + pub(crate) const fn with_flat_audio_profile_level_indication( + mut self, + flat_audio_profile_level_indication: u8, + ) -> Self { + self.flat_audio_profile_level_indication = Some(flat_audio_profile_level_indication); + self + } + + const fn header_policy(self) -> Option { + self.header_policy + } + + const fn with_header_policy(mut self, header_policy: ImportedTrackHeaderPolicy) -> Self { + self.header_policy = Some(header_policy); + self + } + + pub(crate) const fn stts_run_encoding_mode(self) -> SttsRunEncodingMode { + self.stts_run_encoding_mode + } + + pub(crate) const fn with_stts_run_encoding_mode( + mut self, + stts_run_encoding_mode: SttsRunEncodingMode, + ) -> Self { + self.stts_run_encoding_mode = stts_run_encoding_mode; + self + } + + pub(crate) const fn strip_single_sample_dts_btrt(self) -> bool { + self.strip_single_sample_dts_btrt + } + + pub(crate) const fn with_strip_single_sample_dts_btrt(mut self, enabled: bool) -> Self { + self.strip_single_sample_dts_btrt = enabled; + self + } + + pub(in crate::mux) const fn without_terminal_flat_video_chunk_split(mut self) -> Self { + self.flat_chunking_mode = FlatChunkingMode::AutoWithoutTerminalVideoSplit; + self + } + + const fn with_terminal_flat_video_chunk_split(mut self) -> Self { + self.flat_chunking_mode = FlatChunkingMode::Auto; + self + } +} + +#[derive(Clone, Copy)] +pub(in crate::mux) struct StagedSample { + pub(in crate::mux) data_offset: u64, + pub(in crate::mux) data_size: u32, + pub(in crate::mux) duration: u32, + pub(in crate::mux) composition_time_offset: i32, + pub(in crate::mux) is_sync_sample: bool, +} + +#[derive(Clone)] +pub(in crate::mux) struct TrackCandidate { + pub(in crate::mux) track_id: u32, + pub(in crate::mux) kind: MuxTrackKind, + pub(in crate::mux) timescale: u32, + pub(in crate::mux) language: [u8; 3], + pub(in crate::mux) handler_name: String, + pub(in crate::mux) mux_policy: ImportedTrackMuxPolicy, + pub(in crate::mux) width: u16, + pub(in crate::mux) height: u16, + pub(in crate::mux) sample_entry_box: Vec, + pub(in crate::mux) source_edit_media_time: Option, + pub(in crate::mux) samples: Vec, +} + +#[derive(Clone, Copy)] +pub(in crate::mux) struct CandidateSample { + pub(in crate::mux) source_index: usize, + pub(in crate::mux) data_offset: u64, + pub(in crate::mux) data_size: u32, + pub(in crate::mux) duration: u32, + pub(in crate::mux) composition_time_offset: i32, + pub(in crate::mux) is_sync_sample: bool, +} + +pub(in crate::mux) struct CompositeTrackCandidate { + pub(in crate::mux) track: TrackCandidate, + pub(in crate::mux) source_spec: SegmentedMuxSourceSpec, +} + +fn assign_candidate_source_index(track: &mut TrackCandidate, source_index: usize) { + for sample in &mut track.samples { + sample.source_index = source_index; + } +} + +fn imported_samples_from_staged( + staged_samples: Vec, + source_index: usize, +) -> Vec { + staged_samples + .into_iter() + .map(|sample| ImportedSample { + source_index, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect() +} + +fn prepare_request_sync( + request: &MuxRequest, + output_path: &Path, +) -> Result { + validate_request_shape(request, output_path)?; + + let mut path_kinds = Vec::with_capacity(request.tracks().len()); + let mut all_profile_authority_inputs = true; + for track in request.tracks() { + let kind = match track { + MuxTrackSpec::Path { path, .. } => detect_path_track_kind_sync(path)?, + MuxTrackSpec::RawVideo { .. } => DetectedPathTrackKind::Unknown, + }; + if !matches!( + kind, + DetectedPathTrackKind::Mp4 + | DetectedPathTrackKind::Container(DetectedContainerPathKind::Dash) + ) { + all_profile_authority_inputs = false; + } + path_kinds.push(kind); + } + let mut sources = SourceCatalog::default(); + let mut mp4_cache = BTreeMap::::new(); + let mut avi_cache = BTreeMap::::new(); + let mut dash_cache = BTreeMap::::new(); + let mut nhml_cache = BTreeMap::::new(); + let mut program_stream_cache = BTreeMap::::new(); + let mut saf_cache = BTreeMap::::new(); + let mut transport_stream_cache = BTreeMap::::new(); + let mut vobsub_cache = BTreeMap::::new(); + let mut imported_tracks = Vec::new(); + let mut authority_file_config = None::; + let mut selected_mp4_track_carries = SelectedImportedMp4CarryMap::new(); + + for (track, path_kind) in request.tracks().iter().zip(path_kinds) { + let spec = display_track_spec(track); + match track { + MuxTrackSpec::RawVideo { path, params } => { + imported_tracks.push(import_raw_video_sync( + path.as_path(), + *params, + spec, + &mut sources, + )?); + continue; + } + MuxTrackSpec::Path { path, selector } => match path_kind { + DetectedPathTrackKind::Mp4 => { + let selected_metadata; + let metadata = if let Some(selector) = selector { + selected_metadata = + load_selected_mp4_source_sync(path.as_path(), *selector, &mut sources)?; + &selected_metadata + } else { + load_mp4_source_sync(path.as_path(), &mut mp4_cache, &mut sources)? + }; + if all_profile_authority_inputs && authority_file_config.is_none() { + authority_file_config = metadata.file_config.clone(); + } + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, false)?; + capture_selected_mp4_track_carries( + &selected, + &metadata.carries_by_track_id, + &mut selected_mp4_track_carries, + ); + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Avi) => { + let metadata = + load_avi_source_sync(path.as_path(), &mut avi_cache, &mut sources)?; + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Dash) => { + let metadata = + load_dash_source_sync(path.as_path(), &mut dash_cache, &mut sources)?; + if all_profile_authority_inputs && authority_file_config.is_none() { + authority_file_config = metadata.file_config.clone(); + } + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Ghi) => { + return Err(MuxError::UnsupportedTrackImport { + spec, + message: unsupported_ghi_container_message().to_string(), + }); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Gsf) => { + return Err(MuxError::UnsupportedTrackImport { + spec, + message: unsupported_gsf_container_message().to_string(), + }); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Nhml) => { + let metadata = load_nhml_source_sync( + path.as_path(), + DetectedNhmlSidecarKind::Nhml, + &mut nhml_cache, + &mut sources, + )?; + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Nhnt) => { + let metadata = load_nhml_source_sync( + path.as_path(), + DetectedNhmlSidecarKind::Nhnt, + &mut nhml_cache, + &mut sources, + )?; + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::ProgramStream) => { + let metadata = load_program_stream_source_sync( + path.as_path(), + &mut program_stream_cache, + &mut sources, + )?; + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Saf) => { + let metadata = + load_saf_source_sync(path.as_path(), &mut saf_cache, &mut sources)?; + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::TransportStream) => { + let metadata = load_transport_stream_source_sync( + path.as_path(), + &mut transport_stream_cache, + &mut sources, + )?; + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::VobSub) => { + let metadata = + load_vobsub_source_sync(path.as_path(), &mut vobsub_cache, &mut sources)?; + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Raw(_) + | DetectedPathTrackKind::Mp4ImportOnly(_) + | DetectedPathTrackKind::Unknown => { + if let Some(selector) = selector { + return Err(MuxError::UnsupportedTrackImport { + spec, + message: format!( + "selector `{}` only applies to containerized sources", + format_mp4_selector(*selector) + ), + }); + } + imported_tracks.push(import_detected_path_raw_sync( + path.as_path(), + &spec, + &mut sources, + )?); + } + }, + } + } + + finish_prepared_request( + request, + output_path, + imported_tracks, + sources, + authority_file_config, + selected_mp4_track_carries, + ) +} + +#[cfg(feature = "async")] +async fn prepare_request_async( + request: &MuxRequest, + output_path: &Path, +) -> Result { + validate_request_shape(request, output_path)?; + + let mut path_kinds = Vec::with_capacity(request.tracks().len()); + let mut all_profile_authority_inputs = true; + for track in request.tracks() { + let kind = match track { + MuxTrackSpec::Path { path, .. } => detect_path_track_kind_async(path).await?, + MuxTrackSpec::RawVideo { .. } => DetectedPathTrackKind::Unknown, + }; + if !matches!( + kind, + DetectedPathTrackKind::Mp4 + | DetectedPathTrackKind::Container(DetectedContainerPathKind::Dash) + ) { + all_profile_authority_inputs = false; + } + path_kinds.push(kind); + } + let mut sources = SourceCatalog::default(); + let mut mp4_cache = BTreeMap::::new(); + let mut avi_cache = BTreeMap::::new(); + let mut dash_cache = BTreeMap::::new(); + let mut nhml_cache = BTreeMap::::new(); + let mut program_stream_cache = BTreeMap::::new(); + let mut saf_cache = BTreeMap::::new(); + let mut transport_stream_cache = BTreeMap::::new(); + let mut vobsub_cache = BTreeMap::::new(); + let mut imported_tracks = Vec::new(); + let mut authority_file_config = None::; + let mut selected_mp4_track_carries = SelectedImportedMp4CarryMap::new(); + + for (track, path_kind) in request.tracks().iter().zip(path_kinds) { + let spec = display_track_spec(track); + match track { + MuxTrackSpec::RawVideo { path, params } => { + imported_tracks.push( + import_raw_video_async(path.as_path(), *params, spec, &mut sources).await?, + ); + continue; + } + MuxTrackSpec::Path { path, selector } => match path_kind { + DetectedPathTrackKind::Mp4 => { + let selected_metadata; + let metadata = if let Some(selector) = selector { + selected_metadata = + load_selected_mp4_source_async(path.as_path(), *selector, &mut sources) + .await?; + &selected_metadata + } else { + load_mp4_source_async(path.as_path(), &mut mp4_cache, &mut sources).await? + }; + if all_profile_authority_inputs && authority_file_config.is_none() { + authority_file_config = metadata.file_config.clone(); + } + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, false)?; + capture_selected_mp4_track_carries( + &selected, + &metadata.carries_by_track_id, + &mut selected_mp4_track_carries, + ); + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Avi) => { + let metadata = + load_avi_source_async(path.as_path(), &mut avi_cache, &mut sources).await?; + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Dash) => { + let metadata = + load_dash_source_async(path.as_path(), &mut dash_cache, &mut sources) + .await?; + if all_profile_authority_inputs && authority_file_config.is_none() { + authority_file_config = metadata.file_config.clone(); + } + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Ghi) => { + return Err(MuxError::UnsupportedTrackImport { + spec, + message: unsupported_ghi_container_message().to_string(), + }); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Gsf) => { + return Err(MuxError::UnsupportedTrackImport { + spec, + message: unsupported_gsf_container_message().to_string(), + }); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Nhml) => { + let metadata = load_nhml_source_async( + path.as_path(), + DetectedNhmlSidecarKind::Nhml, + &mut nhml_cache, + &mut sources, + ) + .await?; + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Nhnt) => { + let metadata = load_nhml_source_async( + path.as_path(), + DetectedNhmlSidecarKind::Nhnt, + &mut nhml_cache, + &mut sources, + ) + .await?; + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::ProgramStream) => { + let metadata = load_program_stream_source_async( + path.as_path(), + &mut program_stream_cache, + &mut sources, + ) + .await?; + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Saf) => { + let metadata = + load_saf_source_async(path.as_path(), &mut saf_cache, &mut sources).await?; + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::TransportStream) => { + let metadata = load_transport_stream_source_async( + path.as_path(), + &mut transport_stream_cache, + &mut sources, + ) + .await?; + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::VobSub) => { + let metadata = + load_vobsub_source_async(path.as_path(), &mut vobsub_cache, &mut sources) + .await?; + let mut selected = + select_container_tracks(&metadata.tracks, *selector, spec, true)?; + imported_tracks.append(&mut selected); + } + DetectedPathTrackKind::Raw(_) + | DetectedPathTrackKind::Mp4ImportOnly(_) + | DetectedPathTrackKind::Unknown => { + if let Some(selector) = selector { + return Err(MuxError::UnsupportedTrackImport { + spec, + message: format!( + "selector `{}` only applies to containerized sources", + format_mp4_selector(*selector) + ), + }); + } + imported_tracks.push( + import_detected_path_raw_async(path.as_path(), &spec, &mut sources).await?, + ); + } + }, + } + } + + finish_prepared_request( + request, + output_path, + imported_tracks, + sources, + authority_file_config, + selected_mp4_track_carries, + ) +} + +fn finish_prepared_request( + request: &MuxRequest, + _output_path: &Path, + mut imported_tracks: Vec, + sources: SourceCatalog, + authority_file_config: Option, + mut selected_mp4_track_carries: SelectedImportedMp4CarryMap, +) -> Result { + if request.output_layout() == MuxOutputLayout::Flat && request.preserve_flat_authority_layout() + { + merge_flat_destination_append_tracks( + &mut imported_tracks, + &mut selected_mp4_track_carries, + )?; + } + if request.output_layout() == MuxOutputLayout::Flat { + reconcile_flat_imported_fragment_decode_time_gaps( + &mut imported_tracks, + &selected_mp4_track_carries, + &sources, + )?; + } + + let video_count = imported_tracks + .iter() + .filter(|track| track.kind == MuxTrackKind::Video) + .count(); + if request.output_layout() == MuxOutputLayout::Fragmented && video_count > 1 { + return Err(MuxError::MultipleVideoTracks { count: video_count }); + } + + let movie_timescale = choose_movie_timescale( + &imported_tracks, + authority_file_config.as_ref(), + request.output_layout(), + )?; + let file_config = choose_file_config( + movie_timescale, + &imported_tracks, + &sources, + authority_file_config.as_ref(), + request.preserve_flat_authority_layout(), + ) + .with_fragment_event_messages(request.fragment_event_messages().to_vec()) + .with_producer_reference_times(request.producer_reference_times().to_vec()); + let duration_boundary_kind = request + .duration_mode() + .map(|duration_mode| match duration_mode { + super::MuxDurationMode::Segment { .. } => MuxDurationBoundaryKind::Segment, + super::MuxDurationMode::Fragment { .. } => MuxDurationBoundaryKind::Fragment, + }); + let fragmented_single_sidx_reference = matches!( + request.duration_mode(), + Some(super::MuxDurationMode::Fragment { .. }) + ); + + let duration_target = if let Some(duration_mode) = request.duration_mode() { + let seconds = duration_mode.seconds(); + if !seconds.is_finite() || seconds <= 0.0 { + return Err(MuxError::InvalidDurationMode { + mode: duration_mode.label(), + message: "duration must be a finite value greater than zero".to_string(), + }); + } + let ticks = (seconds * f64::from(movie_timescale)).round(); + if ticks < 1.0 { + return Err(MuxError::InvalidDurationMode { + mode: duration_mode.label(), + message: "duration is too small for the selected movie timescale".to_string(), + }); + } + Some(ticks as u64) + } else { + None + }; + let auto_flat_interleave_target = if duration_target.is_none() + && request.output_layout() == MuxOutputLayout::Flat + && file_config.auto_flat_profile() + { + Some(auto_flat_interleave_target_ticks(movie_timescale)) + } else { + None + }; + let audio_track_count = imported_tracks + .iter() + .filter(|track| track.kind.is_audio()) + .count(); + let mut staged_items = Vec::new(); + let mut track_configs = Vec::new(); + let mut coordination_directives = Vec::new(); + let assigned_track_ids = assign_imported_track_ids( + &imported_tracks, + request.output_layout() == MuxOutputLayout::Flat, + )?; + let source_track_id_remap = + imported_source_track_id_remap(&imported_tracks, &assigned_track_ids); + let preserved_authority_video_alignment = if request.preserve_flat_authority_layout() + && auto_flat_interleave_target.is_some() + && audio_track_count == 1 + { + preserved_authority_flat_video_alignment(&imported_tracks, &assigned_track_ids)? + } else { + None + }; + if request.output_layout() == MuxOutputLayout::Fragmented + || (request.preserve_flat_authority_layout() + && request.output_layout() == MuxOutputLayout::Flat) + { + for imported_track in &mut imported_tracks { + let should_restore_source_sync_samples = request.output_layout() + == MuxOutputLayout::Fragmented + || imported_track.source_edit_media_time.is_some() + || imported_track_source_edit_segment_duration(imported_track).is_some(); + if !should_restore_source_sync_samples { + continue; + } + let imported_mp4_carry = + imported_track_selected_mp4_carry(imported_track, &selected_mp4_track_carries); + restore_preserved_imported_source_sync_samples(imported_track, imported_mp4_carry); + } + } + + for (imported_track, track_id) in imported_tracks + .iter() + .zip(assigned_track_ids.iter().copied()) + { + let imported_mp4_carry = + imported_track_selected_mp4_carry(imported_track, &selected_mp4_track_carries); + let mut preserved_flat_stbl_boxes = imported_mp4_carry + .map(|carry| carry.preserved_flat_stbl_boxes.clone()) + .unwrap_or_default(); + preserved_flat_stbl_boxes.extend(generated_flat_stbl_boxes_for_imported_track( + imported_track, + imported_mp4_carry, + &preserved_flat_stbl_boxes, + request.preserve_flat_authority_layout(), + )?); + let mut preserved_flat_trak_boxes = imported_mp4_carry + .map(|carry| carry.preserved_flat_trak_boxes.clone()) + .unwrap_or_default(); + if request.preserve_flat_authority_layout() + && request.output_layout() == MuxOutputLayout::Flat + { + preserved_flat_trak_boxes = remap_preserved_flat_trak_boxes( + &source_track_id_remap, + imported_track_source_key(imported_track), + imported_track, + preserved_flat_trak_boxes, + )?; + } + preserved_flat_trak_boxes = filter_preserved_flat_trak_boxes_for_output( + imported_track, + movie_timescale, + request.output_layout(), + preserved_flat_trak_boxes, + ); + let normalized_sample_entry_box = normalize_imported_sample_entry_box( + imported_track, + imported_mp4_carry, + request.output_layout(), + request.preserve_flat_authority_layout(), + )?; + let normalized_sample_entry_boxes = normalized_imported_sample_entry_boxes( + imported_mp4_carry, + normalized_sample_entry_box.clone(), + ); + let sample_description_indices = imported_mp4_carry + .and_then(|carry| carry.sample_description_indices.as_deref()) + .filter(|indices| indices.len() == imported_track.samples.len()); + let preserved_sample_description_chunk_counts = + if request.output_layout() == MuxOutputLayout::Flat { + imported_mp4_carry + .and_then(|carry| carry.flat_chunk_sample_counts.as_deref()) + .filter(|_| { + sample_description_indices + .is_some_and(sample_description_indices_use_multiple_entries) + }) + .map(|chunk_sample_counts| { + validate_imported_flat_chunk_sample_counts( + track_id, + imported_track.kind, + imported_track.samples.len(), + chunk_sample_counts, + ) + }) + .transpose()? + } else { + None + }; + let allow_inexact_movie_scaling = imported_track.mux_policy.header_policy().is_some() + && imported_track.timescale != movie_timescale; + let fragmented_decode_time_offset = fragmented_imported_decode_time_offset_for_staging( + track_id, + imported_track, + request.output_layout(), + movie_timescale, + allow_inexact_movie_scaling, + )?; + let duration_start_time_ticks = imported_timing_start_time_ticks( + track_id, + imported_track, + request.output_layout(), + movie_timescale, + allow_inexact_movie_scaling, + )?; + let mut fragmented_reference_group_fragment_counts = None; + let mut preserved_flat_stsc_override = None::; + let mut decode_time = fragmented_decode_time_offset; + if let (Some(target_ticks), Some(duration_boundary_kind)) = + (duration_target, duration_boundary_kind) + { + let normalized_sample_durations = imported_track + .samples + .iter() + .map(|sample| { + scale_track_time_to_movie( + track_id, + i64::from(sample.duration), + imported_track.timescale, + movie_timescale, + allow_inexact_movie_scaling, + ) + .map(|duration| duration as u32) + }) + .collect::, _>>()?; + if !normalized_sample_durations.is_empty() { + let chunk_sample_counts = if imported_track.kind.is_video() { + let segment_samples = imported_track + .samples + .iter() + .zip(normalized_sample_durations.iter().copied()) + .map(|(sample, duration_ticks)| { + let composition_offset_ticks = scale_track_time_to_movie( + track_id, + i64::from(sample.composition_time_offset), + imported_track.timescale, + movie_timescale, + allow_inexact_movie_scaling, + )?; + Ok(( + duration_ticks, + composition_offset_ticks, + sample.is_sync_sample, + )) + }) + .collect::, MuxError>>()?; + if duration_boundary_kind == MuxDurationBoundaryKind::Fragment { + let implicit_reference_group_target = + default_fragmented_reference_group_target_ticks(movie_timescale) + .max(target_ticks); + let (chunk_sample_counts, reference_group_fragment_counts) = + build_sync_aligned_fragmented_duration_chunk_sample_counts( + track_id, + segment_samples, + target_ticks, + implicit_reference_group_target, + duration_start_time_ticks, + )?; + if implicit_reference_group_target > target_ticks { + fragmented_reference_group_fragment_counts = + Some(reference_group_fragment_counts); + } + chunk_sample_counts + } else { + build_sync_aligned_segment_chunk_sample_counts( + track_id, + segment_samples, + target_ticks, + duration_start_time_ticks, + )? + } + } else if duration_boundary_kind == MuxDurationBoundaryKind::Segment { + let use_sync_aligned_segment_boundaries = imported_track + .samples + .iter() + .any(|sample| sample.is_sync_sample) + && !imported_track + .samples + .iter() + .all(|sample| sample.is_sync_sample); + if use_sync_aligned_segment_boundaries { + let normalized_segment_samples = imported_track + .samples + .iter() + .zip(normalized_sample_durations.iter().copied()) + .map(|(sample, duration_ticks)| { + let composition_offset_ticks = scale_track_time_to_movie( + track_id, + i64::from(sample.composition_time_offset), + imported_track.timescale, + movie_timescale, + allow_inexact_movie_scaling, + )?; + Ok(( + duration_ticks, + composition_offset_ticks, + sample.is_sync_sample, + )) + }) + .collect::, MuxError>>()?; + build_sync_aligned_segment_chunk_sample_counts( + track_id, + normalized_segment_samples, + target_ticks, + duration_start_time_ticks, + )? + } else { + build_duration_chunk_sample_counts_with_start_time( + track_id, + normalized_sample_durations, + target_ticks, + duration_start_time_ticks, + )? + } + } else { + let implicit_reference_group_target = + default_fragmented_reference_group_target_ticks(movie_timescale) + .max(target_ticks); + if implicit_reference_group_target > target_ticks { + let use_sync_aligned_fragment_boundaries = imported_track + .samples + .iter() + .any(|sample| sample.is_sync_sample) + && !imported_track + .samples + .iter() + .all(|sample| sample.is_sync_sample); + let (chunk_sample_counts, reference_group_fragment_counts) = + if use_sync_aligned_fragment_boundaries { + let normalized_fragment_samples = imported_track + .samples + .iter() + .zip(normalized_sample_durations.iter().copied()) + .map(|(sample, duration_ticks)| { + let composition_offset_ticks = scale_track_time_to_movie( + track_id, + i64::from(sample.composition_time_offset), + imported_track.timescale, + movie_timescale, + allow_inexact_movie_scaling, + )?; + Ok(( + duration_ticks, + composition_offset_ticks, + sample.is_sync_sample, + )) + }) + .collect::, MuxError>>()?; + build_sync_aligned_fragmented_duration_chunk_sample_counts( + track_id, + normalized_fragment_samples, + target_ticks, + implicit_reference_group_target, + duration_start_time_ticks, + )? + } else { + build_fragmented_duration_chunk_sample_counts_with_start_time( + track_id, + normalized_sample_durations.clone(), + target_ticks, + implicit_reference_group_target, + duration_start_time_ticks, + )? + }; + fragmented_reference_group_fragment_counts = + Some(reference_group_fragment_counts); + chunk_sample_counts + } else { + build_duration_chunk_sample_counts( + track_id, + normalized_sample_durations, + target_ticks, + )? + } + }; + coordination_directives.push( + TrackCoordinationDirective::new(track_id, chunk_sample_counts) + .with_duration_boundaries(duration_boundary_kind), + ); + } + } else if let Some(chunk_sample_counts) = preserved_sample_description_chunk_counts { + coordination_directives.push(TrackCoordinationDirective::new( + track_id, + chunk_sample_counts, + )); + } else if auto_flat_interleave_target.is_some() { + if imported_track.kind.is_audio() { + if !imported_track.samples.is_empty() { + if imported_track.mux_policy.flat_chunking_mode + == FlatChunkingMode::OneSamplePerChunk + { + coordination_directives.push(TrackCoordinationDirective::new( + track_id, + vec![1; imported_track.samples.len()], + )); + } else if let Some(source_index) = imported_track + .samples + .first() + .map(|sample| sample.source_index) + .filter(|source_index| { + imported_track + .samples + .iter() + .all(|sample| sample.source_index == *source_index) + }) + { + if let Some(chunk_sample_counts) = + (!imported_track_should_rechunk_flat_audio(imported_track)) + .then(|| { + preserved_imported_flat_audio_chunk_sample_counts( + imported_track, + imported_mp4_carry, + sources.flat_chunk_sample_counts(source_index), + ) + }) + .flatten() + { + let planned_sample_count = chunk_sample_counts + .iter() + .try_fold(0_usize, |total, chunk_sample_count| { + total.checked_add(usize::try_from(*chunk_sample_count).ok()?) + }) + .ok_or(MuxError::InvalidChunkPlan { + track_id, + message: + "explicit flat audio chunk plan overflowed while validating staged sample coverage" + .to_string(), + })?; + if planned_sample_count != imported_track.samples.len() { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: format!( + "explicit flat audio chunk plan covered {planned_sample_count} sample{} but the imported track carried {}", + if planned_sample_count == 1 { "" } else { "s" }, + imported_track.samples.len(), + ), + }); + } + let mut chunk_sample_counts = chunk_sample_counts; + let should_split_terminal_short_audio_chunk = + imported_track_should_split_terminal_flat_audio_chunk( + imported_track, + ); + let should_preserve_source_stsc = + !(stsc_run_encoding_mode_for_imported_track(imported_track) + == StscRunEncodingMode::PreserveTerminalBoundary + && should_split_terminal_short_audio_chunk); + if !should_preserve_source_stsc { + let sample_durations = imported_track + .samples + .iter() + .map(|sample| sample.duration) + .collect::>(); + split_terminal_short_audio_chunk_sample_counts( + &sample_durations, + &mut chunk_sample_counts, + ); + } else if let Some(flat_stsc) = + imported_mp4_carry.and_then(|carry| carry.flat_stsc.clone()) + { + preserved_flat_stsc_override = Some(flat_stsc); + } + coordination_directives.push(TrackCoordinationDirective::new( + track_id, + chunk_sample_counts, + )); + } else { + let sample_durations = imported_track + .samples + .iter() + .map(|sample| sample.duration) + .collect::>(); + let mut chunk_sample_counts = if request + .preserve_flat_authority_layout() + && imported_mp4_carry.is_some() + && let Some(video_alignment) = + preserved_authority_video_alignment.as_ref() + { + build_preserved_authority_flat_audio_chunk_sample_counts( + track_id, + imported_track, + movie_timescale, + video_alignment, + )? + } else { + build_imported_flat_audio_chunk_sample_counts( + track_id, + imported_track, + sample_durations, + )? + }; + if audio_track_count > 1 { + rebalance_small_multi_audio_chunk_sample_counts( + &mut chunk_sample_counts, + ); + } + if stsc_run_encoding_mode_for_imported_track(imported_track) + == StscRunEncodingMode::PreserveTerminalBoundary + && imported_track_should_split_terminal_flat_audio_chunk( + imported_track, + ) + { + let sample_durations = imported_track + .samples + .iter() + .map(|sample| sample.duration) + .collect::>(); + split_terminal_short_audio_chunk_sample_counts( + &sample_durations, + &mut chunk_sample_counts, + ); + } + coordination_directives.push(TrackCoordinationDirective::new( + track_id, + chunk_sample_counts, + )); + } + } else { + let sample_durations = imported_track + .samples + .iter() + .map(|sample| sample.duration) + .collect::>(); + let mut chunk_sample_counts = if request.preserve_flat_authority_layout() + && imported_mp4_carry.is_some() + && let Some(video_alignment) = + preserved_authority_video_alignment.as_ref() + { + build_preserved_authority_flat_audio_chunk_sample_counts( + track_id, + imported_track, + movie_timescale, + video_alignment, + )? + } else { + build_imported_flat_audio_chunk_sample_counts( + track_id, + imported_track, + sample_durations, + )? + }; + if audio_track_count > 1 { + rebalance_small_multi_audio_chunk_sample_counts( + &mut chunk_sample_counts, + ); + } + if stsc_run_encoding_mode_for_imported_track(imported_track) + == StscRunEncodingMode::PreserveTerminalBoundary + && imported_track_should_split_terminal_flat_audio_chunk(imported_track) + { + let sample_durations = imported_track + .samples + .iter() + .map(|sample| sample.duration) + .collect::>(); + split_terminal_short_audio_chunk_sample_counts( + &sample_durations, + &mut chunk_sample_counts, + ); + } + coordination_directives.push(TrackCoordinationDirective::new( + track_id, + chunk_sample_counts, + )); + } + } + } else if imported_track.kind == MuxTrackKind::Subtitle + && imported_track.sample_entry_box.get(4..8) == Some(b"mp4s".as_slice()) + && !imported_track.samples.is_empty() + { + coordination_directives.push(TrackCoordinationDirective::new( + track_id, + vec![1; imported_track.samples.len()], + )); + } else if imported_track.kind.is_video() && !imported_track.samples.is_empty() { + let sample_durations = imported_track + .samples + .iter() + .map(|sample| sample.duration) + .collect::>(); + let mut chunk_sample_counts = if let Some(source_index) = imported_track + .samples + .first() + .map(|sample| sample.source_index) + .filter(|source_index| { + imported_track + .samples + .iter() + .all(|sample| sample.source_index == *source_index) + }) + .filter(|_| { + sample_entry_box_type(&imported_track.sample_entry_box) + == Some(FourCc::from_bytes(*b"vp08")) + }) { + if let Some(chunk_sample_counts) = imported_mp4_carry + .and_then(|carry| carry.flat_chunk_sample_counts.as_deref()) + .or_else(|| sources.flat_chunk_sample_counts(source_index)) + { + let planned_sample_count = chunk_sample_counts + .iter() + .try_fold(0_usize, |total, chunk_sample_count| { + total.checked_add(usize::try_from(*chunk_sample_count).ok()?) + }) + .ok_or(MuxError::InvalidChunkPlan { + track_id, + message: + "explicit flat video chunk plan overflowed while validating staged sample coverage" + .to_string(), + })?; + if planned_sample_count != imported_track.samples.len() { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: format!( + "explicit flat video chunk plan covered {planned_sample_count} sample{} but the imported track carried {}", + if planned_sample_count == 1 { "" } else { "s" }, + imported_track.samples.len(), + ), + }); + } + if let Some(flat_stsc) = + imported_mp4_carry.and_then(|carry| carry.flat_stsc.clone()) + { + preserved_flat_stsc_override = Some(flat_stsc); + } + chunk_sample_counts.to_vec() + } else if imported_mp4_carry.is_some() { + build_fragmented_imported_vp08_flat_chunk_sample_counts( + track_id, + imported_track, + )? + } else { + build_capped_duration_chunk_sample_counts( + track_id, + sample_durations.iter().copied(), + auto_flat_interleave_target_ticks(imported_track.timescale), + )? + } + } else { + build_capped_duration_chunk_sample_counts( + track_id, + sample_durations.iter().copied(), + auto_flat_interleave_target_ticks(imported_track.timescale), + )? + }; + if imported_track.mux_policy.flat_chunking_mode + != FlatChunkingMode::AutoWithoutTerminalVideoSplit + && sample_entry_box_type(&imported_track.sample_entry_box) + != Some(FourCc::from_bytes(*b"vp08")) + { + split_terminal_short_video_chunk_sample_counts( + &sample_durations, + &mut chunk_sample_counts, + ); + } + coordination_directives.push(TrackCoordinationDirective::new( + track_id, + chunk_sample_counts, + )); + } + } + + let fragmented_decode_time_gaps = if request.output_layout() == MuxOutputLayout::Fragmented + { + imported_track_source_key(imported_track) + .and_then(|(source_index, source_track_id)| { + sources.fragmented_decode_time_gaps(source_index, source_track_id) + }) + .or_else(|| { + imported_mp4_carry.map(|carry| carry.fragmented_decode_time_gaps.as_slice()) + }) + .unwrap_or(&[]) + } else { + &[] + }; + let mut next_fragmented_decode_time_gap = 0_usize; + for (sample_index, sample) in imported_track.samples.iter().enumerate() { + while fragmented_decode_time_gaps + .get(next_fragmented_decode_time_gap) + .is_some_and(|gap| gap.sample_index == sample_index) + { + let gap = &fragmented_decode_time_gaps[next_fragmented_decode_time_gap]; + let gap_ticks = i64::try_from(gap.delta) + .map_err(|_| MuxError::LayoutOverflow("fragmented decode-time gap"))?; + let gap_movie = scale_track_time_to_movie( + track_id, + gap_ticks, + imported_track.timescale, + movie_timescale, + allow_inexact_movie_scaling, + )?; + let gap_movie = u64::try_from(gap_movie) + .map_err(|_| MuxError::LayoutOverflow("fragmented decode-time gap"))?; + decode_time = decode_time + .checked_add(gap_movie) + .ok_or(MuxError::LayoutOverflow("fragmented decode-time gap"))?; + next_fragmented_decode_time_gap += 1; + } + let sample_description_index = sample_description_indices + .and_then(|indices| indices.get(sample_index).copied()) + .unwrap_or(1); + if sample_description_index == 0 + || usize::try_from(sample_description_index) + .ok() + .is_none_or(|index| index > normalized_sample_entry_boxes.len()) + { + return Err(MuxError::UnsupportedTrackImport { + spec: imported_track_relation_error_spec(imported_track), + message: format!( + "track {track_id} uses sample description index {sample_description_index} with {} sample entries", + normalized_sample_entry_boxes.len() + ), + }); + } + let duration = scale_track_time_to_movie( + track_id, + i64::from(sample.duration), + imported_track.timescale, + movie_timescale, + allow_inexact_movie_scaling, + )? as u32; + let composition_time_offset = scale_track_time_to_movie( + track_id, + i64::from(sample.composition_time_offset), + imported_track.timescale, + movie_timescale, + allow_inexact_movie_scaling, + )? as i32; + staged_items.push( + MuxStagedMediaItem::new( + sample.source_index, + track_id, + decode_time, + duration, + sample.data_offset, + sample.data_size, + ) + .with_composition_time_offset(composition_time_offset) + .with_sync_sample(sample.is_sync_sample) + .with_sample_description_index(sample_description_index), + ); + decode_time = decode_time + .checked_add(u64::from(duration)) + .ok_or(MuxError::LayoutOverflow("track decode timeline"))?; + } + + let config = match imported_track.kind { + MuxTrackKind::Audio => MuxTrackConfig::new_audio( + track_id, + imported_track.timescale, + normalized_sample_entry_box.clone(), + ), + MuxTrackKind::Video => MuxTrackConfig::new_video( + track_id, + imported_track.timescale, + imported_track.width, + imported_track.height, + normalized_sample_entry_box.clone(), + ), + MuxTrackKind::Text => MuxTrackConfig::new_text( + track_id, + imported_track.timescale, + imported_track.width, + imported_track.height, + normalized_sample_entry_box.clone(), + ), + MuxTrackKind::Subtitle => MuxTrackConfig::new_subtitle( + track_id, + imported_track.timescale, + imported_track.width, + imported_track.height, + normalized_sample_entry_box.clone(), + ), + } + .with_language(imported_track.language) + .with_handler_name(imported_track.handler_name.clone()) + .with_tkhd_flags( + imported_track + .mux_policy + .header_policy() + .unwrap_or_else(|| default_imported_track_header_policy(imported_track.kind)) + .tkhd_flags, + ) + .with_alternate_group( + imported_track + .mux_policy + .header_policy() + .unwrap_or_else(|| default_imported_track_header_policy(imported_track.kind)) + .alternate_group, + ) + .with_volume( + imported_track + .mux_policy + .header_policy() + .unwrap_or_else(|| default_imported_track_header_policy(imported_track.kind)) + .volume, + ) + .with_matrix( + imported_track + .mux_policy + .header_policy() + .unwrap_or_else(|| default_imported_track_header_policy(imported_track.kind)) + .matrix, + ) + .with_sync_sample_table_mode(sync_sample_table_mode_for_imported_track( + imported_track, + request.output_layout(), + request.preserve_flat_authority_layout(), + )) + .with_stts_run_encoding_mode(stts_run_encoding_mode_for_imported_track(imported_track)) + .with_stsc_run_encoding_mode(stsc_run_encoding_mode_for_imported_track(imported_track)) + .with_sample_entry_boxes(normalized_sample_entry_boxes.clone()); + let config = if imported_track.kind.is_video() + && (request.output_layout() == MuxOutputLayout::Fragmented + || (request.output_layout() == MuxOutputLayout::Flat + && request.preserve_flat_authority_layout())) + { + if let Some((track_width_fixed_16_16, track_height_fixed_16_16)) = + super::mp4::fragmented_visual_tkhd_dimensions_fixed_16_16( + &normalized_sample_entry_box, + )? + { + config.with_tkhd_dimensions_fixed_16_16( + track_width_fixed_16_16, + track_height_fixed_16_16, + ) + } else { + config + } + } else { + config + }; + let config = if let Some(edit_media_time) = + imported_track.source_edit_media_time.or_else(|| { + derived_fragmented_imported_edit_media_time(imported_track, request.output_layout()) + }) { + if request.output_layout() == MuxOutputLayout::Flat || edit_media_time != 0 { + config.with_edit_media_time(edit_media_time) + } else { + config + } + } else { + config + }; + let suppress_fragmented_imported_roll_grouping = + imported_track_suppresses_fragmented_roll_grouping( + imported_track, + request.output_layout(), + ); + let config = if !suppress_fragmented_imported_roll_grouping + && let Some(sample_roll_distance) = imported_track.sample_roll_distance + { + config.with_sample_roll_distance(sample_roll_distance) + } else { + config + }; + let config = config.with_emit_roll_sbgp( + imported_track.mux_policy.emit_roll_sbgp() + && !suppress_fragmented_imported_roll_grouping, + ); + let carry_flat_authority_creation_times = !imported_track_uses_speex_family(imported_track); + let config = config + .with_flat_source_track_creation_time( + carry_flat_authority_creation_times + .then(|| { + imported_track + .mux_policy + .header_policy() + .and_then(|policy| policy.source_track_creation_time) + }) + .flatten(), + ) + .with_flat_source_track_modification_time( + (request.preserve_flat_authority_layout() && imported_track.kind.is_video()) + .then(|| { + imported_track + .mux_policy + .header_policy() + .and_then(|policy| policy.source_track_modification_time) + }) + .flatten(), + ) + .with_flat_source_media_creation_time( + carry_flat_authority_creation_times + .then(|| { + imported_track + .mux_policy + .header_policy() + .and_then(|policy| policy.source_media_creation_time) + }) + .flatten(), + ); + let config = config.with_flat_source_media_modification_time( + (request.preserve_flat_authority_layout() && imported_track.kind.is_video()) + .then(|| { + imported_track + .mux_policy + .header_policy() + .and_then(|policy| policy.source_media_modification_time) + }) + .flatten(), + ); + let config = config.with_omit_flat_iods( + imported_track_uses_speex_family(imported_track) + && imported_track.mux_policy.header_policy().is_some(), + ); + let config = if let Some(flat_timing_override) = flat_timing_override_for_imported_track( + imported_track, + movie_timescale, + request.preserve_flat_authority_layout(), + ) { + config.with_flat_timing_override(flat_timing_override) + } else { + config + }; + let config = if let Some(flat_audio_profile_level_indication) = imported_track + .mux_policy + .flat_audio_profile_level_indication() + { + config.with_flat_audio_profile_level_indication(flat_audio_profile_level_indication) + } else { + config + }; + let config = if request.output_layout() == MuxOutputLayout::Fragmented + && let Some(decode_time_offset) = imported_track + .mux_policy + .header_policy() + .and_then(|policy| policy.source_media_decode_time_offset) + { + config.with_fragmented_decode_time_offset(decode_time_offset) + } else { + config + }; + let config = if let Some(fragmented_reference_group_fragment_counts) = + fragmented_reference_group_fragment_counts + { + config.with_fragmented_reference_group_fragment_counts( + fragmented_reference_group_fragment_counts, + ) + } else { + config + }; + let config = if let Some(flat_stsc_override) = preserved_flat_stsc_override { + config.with_flat_stsc_override(flat_stsc_override) + } else { + config + }; + let config = config + .with_preserved_flat_stbl_boxes(preserved_flat_stbl_boxes) + .with_preserved_flat_trak_boxes(preserved_flat_trak_boxes); + track_configs.push(config); + } + + let use_chunk_ordinal_interleave = (request.preserve_flat_authority_layout() + && request.output_layout() == MuxOutputLayout::Flat + && video_count == 1 + && audio_track_count == 1 + && preserved_authority_video_alignment.is_some()) + || (request.output_layout() == MuxOutputLayout::Flat + && auto_flat_interleave_target.is_some() + && video_count > 1); + let interleave_policy = if use_chunk_ordinal_interleave { + MuxInterleavePolicy::ChunkOrdinalThenSource + } else { + MuxInterleavePolicy::DecodeTime + }; + let plan = plan_staged_media_items_with_coordination( + staged_items, + interleave_policy, + coordination_directives, + )?; + Ok(PreparedMuxRequest { + output_layout: request.output_layout(), + file_config, + track_configs, + fragmented_single_sidx_reference, + plan, + source_specs: sources.specs, + }) +} + +fn auto_flat_interleave_target_ticks(movie_timescale: u32) -> u64 { + u64::from(movie_timescale) + .saturating_mul(AUTO_FLAT_INTERLEAVE_MILLISECONDS) + .div_ceil(1_000) + .max(1) +} + +fn default_fragmented_reference_group_target_ticks(movie_timescale: u32) -> u64 { + u64::from(movie_timescale) + .saturating_mul(DEFAULT_FRAGMENTED_REFERENCE_GROUP_SECONDS) + .max(1) +} + +#[derive(Default)] +struct SourceCatalog { + specs: Vec, + files: BTreeMap, + flat_source_encoding_metadata: BTreeMap, + flat_source_encoder_metadata: BTreeMap, + flat_chunk_sample_counts_by_source: BTreeMap>, + fragmented_decode_time_gaps_by_source_track: + BTreeMap<(usize, u32), Vec>, +} + +impl SourceCatalog { + fn add_file(&mut self, path: &Path) -> Result { + let absolute = absolute_path(path)?; + if let Some(existing) = self.files.get(&absolute) { + return Ok(*existing); + } + let index = self.specs.len(); + self.specs.push(SourceSpec::File(absolute.clone())); + self.files.insert(absolute, index); + Ok(index) + } + + fn add_segmented(&mut self, mut spec: SegmentedMuxSourceSpec) -> Result { + spec.path = absolute_path(&spec.path)?; + let index = self.specs.len(); + self.specs.push(SourceSpec::Segmented(spec)); + Ok(index) + } + + fn replace_with_segmented( + &mut self, + source_index: usize, + mut spec: SegmentedMuxSourceSpec, + ) -> Result<(), MuxError> { + spec.path = absolute_path(&spec.path)?; + let Some(slot) = self.specs.get_mut(source_index) else { + return Err(MuxError::MissingSourceIndex { + source_index, + source_count: self.specs.len(), + }); + }; + *slot = SourceSpec::Segmented(spec); + Ok(()) + } + + fn flat_source_encoding_metadata(&self, source_index: usize) -> Option<&str> { + self.flat_source_encoding_metadata + .get(&source_index) + .map(String::as_str) + } + + fn set_flat_source_encoder_metadata(&mut self, source_index: usize, metadata: String) { + self.flat_source_encoder_metadata + .insert(source_index, metadata); + } + + fn set_flat_chunk_sample_counts(&mut self, source_index: usize, chunk_sample_counts: Vec) { + self.flat_chunk_sample_counts_by_source + .insert(source_index, chunk_sample_counts); + } + + fn set_fragmented_decode_time_gaps( + &mut self, + source_index: usize, + source_track_id: u32, + gaps: Vec, + ) { + if gaps.is_empty() { + self.fragmented_decode_time_gaps_by_source_track + .remove(&(source_index, source_track_id)); + } else { + self.fragmented_decode_time_gaps_by_source_track + .insert((source_index, source_track_id), gaps); + } + } + + fn flat_source_encoder_metadata(&self, source_index: usize) -> Option<&str> { + self.flat_source_encoder_metadata + .get(&source_index) + .map(String::as_str) + } + + fn flat_chunk_sample_counts(&self, source_index: usize) -> Option<&[u32]> { + self.flat_chunk_sample_counts_by_source + .get(&source_index) + .map(Vec::as_slice) + } + + fn fragmented_decode_time_gaps( + &self, + source_index: usize, + source_track_id: u32, + ) -> Option<&[FragmentedDecodeTimeGap]> { + self.fragmented_decode_time_gaps_by_source_track + .get(&(source_index, source_track_id)) + .map(Vec::as_slice) + } +} + +struct PathSourceMetadata { + file_config: Option, + tracks: Vec, + carries_by_track_id: BTreeMap, + source_override: Option, +} + +struct ContainerSourceMetadata { + file_config: Option, + tracks: Vec, +} + +fn remap_candidate_source_indices( + track: &mut TrackCandidate, + source_index_map: &BTreeMap, +) -> Result<(), MuxError> { + for sample in &mut track.samples { + sample.source_index = + *source_index_map + .get(&sample.source_index) + .ok_or(MuxError::MissingSourceIndex { + source_index: sample.source_index, + source_count: source_index_map.len(), + })?; + } + Ok(()) +} + +fn materialize_parsed_nhml_source( + parsed: ParsedNhmlSource, + sources: &mut SourceCatalog, +) -> Result { + let mut source_index_map = BTreeMap::::new(); + for (xml_source_index, spec) in parsed.source_specs { + let source_index = match spec { + ParsedNhmlSourceSpec::File(path) => sources.add_file(&path)?, + ParsedNhmlSourceSpec::Segmented(spec) => sources.add_segmented(spec)?, + }; + source_index_map.insert(xml_source_index, source_index); + } + let mut tracks = parsed.tracks; + for track in &mut tracks { + remap_candidate_source_indices(track, &source_index_map)?; + } + Ok(ContainerSourceMetadata { + file_config: None, + tracks, + }) +} + +fn materialize_parsed_dash_source( + manifest_path: &Path, + parsed: ParsedDashSource, + sources: &mut SourceCatalog, +) -> Result { + let period_count = parsed.periods.len(); + let mut merged_tracks = Vec::new(); + let mut authority_file_config = None::; + let mut saw_authority_file_config = false; + let mut authority_file_config_compatible = true; + for period in parsed.periods { + let mut period_tracks = Vec::new(); + for spec in period.sources { + let source_index = sources.add_segmented(spec.clone())?; + let mut reader = SyncMuxSource::open(&SourceSpec::Segmented(spec))?; + let parsed = parse_mp4_source_sync(manifest_path, source_index, &mut reader)?; + merge_dash_file_config( + &mut authority_file_config, + &mut saw_authority_file_config, + &mut authority_file_config_compatible, + parsed.file_config.as_ref(), + ); + period_tracks.extend(parsed.tracks); + } + merge_dash_period_tracks( + manifest_path, + &mut merged_tracks, + period_tracks, + period.start_millis, + )?; + } + for track in &mut merged_tracks { + track.mux_policy = track.mux_policy.with_strip_single_sample_dts_btrt(true); + if period_count > 1 && track_candidate_uses_dts_family(track) { + track.mux_policy = track + .mux_policy + .with_stts_run_encoding_mode(SttsRunEncodingMode::PreservePerSample); + } + normalize_local_dash_track_header_policy(track); + } + Ok(ContainerSourceMetadata { + file_config: authority_file_config.map(normalize_local_dash_authority_file_config), + tracks: merged_tracks, + }) +} + +#[cfg(feature = "async")] +async fn materialize_parsed_dash_source_async( + manifest_path: &Path, + parsed: ParsedDashSource, + sources: &mut SourceCatalog, +) -> Result { + let period_count = parsed.periods.len(); + let mut merged_tracks = Vec::new(); + let mut authority_file_config = None::; + let mut saw_authority_file_config = false; + let mut authority_file_config_compatible = true; + for period in parsed.periods { + let mut period_tracks = Vec::new(); + for spec in period.sources { + let source_index = sources.add_segmented(spec.clone())?; + let mut reader = AsyncMuxSource::open(&SourceSpec::Segmented(spec)).await?; + let parsed = parse_mp4_source_async(manifest_path, source_index, &mut reader).await?; + merge_dash_file_config( + &mut authority_file_config, + &mut saw_authority_file_config, + &mut authority_file_config_compatible, + parsed.file_config.as_ref(), + ); + period_tracks.extend(parsed.tracks); + } + merge_dash_period_tracks( + manifest_path, + &mut merged_tracks, + period_tracks, + period.start_millis, + )?; + } + for track in &mut merged_tracks { + track.mux_policy = track.mux_policy.with_strip_single_sample_dts_btrt(true); + if period_count > 1 && track_candidate_uses_dts_family(track) { + track.mux_policy = track + .mux_policy + .with_stts_run_encoding_mode(SttsRunEncodingMode::PreservePerSample); + } + normalize_local_dash_track_header_policy(track); + } + Ok(ContainerSourceMetadata { + file_config: authority_file_config.map(normalize_local_dash_authority_file_config), + tracks: merged_tracks, + }) +} + +fn normalize_local_dash_authority_file_config(file_config: MuxFileConfig) -> MuxFileConfig { + file_config + .with_minor_version(1) + .with_keep_flat_free_box(true) + .with_auto_flat_profile(true) + .with_keep_flat_authority_brands(true) + .with_flat_source_encoding_metadata(Some(LOCAL_DASH_FLAT_TOOL_METADATA_VALUE.to_string())) + .with_preserve_auto_flat_movie_timescale(true) +} + +fn normalize_local_dash_track_header_policy(track: &mut TrackCandidate) { + if track.kind != MuxTrackKind::Audio { + return; + } + let Some(mut header_policy) = track.mux_policy.header_policy() else { + return; + }; + if header_policy.alternate_group == 0 { + header_policy.alternate_group = 1; + track.mux_policy = track.mux_policy.with_header_policy(header_policy); + } +} + +fn merge_dash_file_config( + authority_file_config: &mut Option, + saw_authority_file_config: &mut bool, + authority_file_config_compatible: &mut bool, + candidate: Option<&MuxFileConfig>, +) { + if !*authority_file_config_compatible { + return; + } + let Some(candidate) = candidate else { + return; + }; + if !*saw_authority_file_config { + *authority_file_config = Some(candidate.clone()); + *saw_authority_file_config = true; + return; + } + if authority_file_config.as_ref() != Some(candidate) { + *authority_file_config = None; + *authority_file_config_compatible = false; + } +} + +fn merge_dash_period_tracks( + manifest_path: &Path, + merged_tracks: &mut Vec, + period_tracks: Vec, + period_start_millis: u64, +) -> Result<(), MuxError> { + if period_tracks.is_empty() { + return Ok(()); + } + if merged_tracks.is_empty() { + *merged_tracks = period_tracks; + return Ok(()); + } + if merged_tracks.len() != period_tracks.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: manifest_path.display().to_string(), + message: format!( + "multi-period local MPD import requires the same compatible track count in each period; the first period resolved to {} track{} but a later period resolved to {}", + merged_tracks.len(), + if merged_tracks.len() == 1 { "" } else { "s" }, + period_tracks.len() + ), + }); + } + for (track_index, (merged_track, period_track)) in + merged_tracks.iter_mut().zip(period_tracks).enumerate() + { + ensure_dash_period_track_compatible( + manifest_path, + track_index, + merged_track, + &period_track, + )?; + if track_candidate_uses_dts_family(merged_track) { + merge_dash_period_track_samples_with_start( + manifest_path, + merged_track, + &period_track, + period_start_millis, + )?; + } else { + merged_track.samples.extend(period_track.samples); + } + } + Ok(()) +} + +fn ensure_dash_period_track_compatible( + manifest_path: &Path, + track_index: usize, + merged_track: &TrackCandidate, + period_track: &TrackCandidate, +) -> Result<(), MuxError> { + let track_number = track_index + 1; + let incompatible = merged_track.kind != period_track.kind + || merged_track.timescale != period_track.timescale + || merged_track.language != period_track.language + || merged_track.handler_name != period_track.handler_name + || merged_track.mux_policy != period_track.mux_policy + || merged_track.width != period_track.width + || merged_track.height != period_track.height + || merged_track.sample_entry_box != period_track.sample_entry_box + || merged_track.source_edit_media_time != period_track.source_edit_media_time; + if incompatible { + return Err(MuxError::UnsupportedTrackImport { + spec: manifest_path.display().to_string(), + message: format!( + "multi-period local MPD import requires one stable authored track shape per track position; track {} changed across periods and cannot be merged truthfully on the current path-only ingest surface", + track_number + ), + }); + } + Ok(()) +} + +#[derive(Clone, Copy)] +struct DashRequestedSampleSpan { + start: u64, + end: u64, + sample: CandidateSample, +} + +fn merge_dash_period_track_samples_with_start( + manifest_path: &Path, + merged_track: &mut TrackCandidate, + period_track: &TrackCandidate, + period_start_millis: u64, +) -> Result<(), MuxError> { + let period_start_ticks = + scale_dash_period_start_millis(period_start_millis, merged_track.timescale)?; + let mut spans = dash_requested_sample_spans(&merged_track.samples, 0)?; + spans.extend(dash_requested_sample_spans( + &period_track.samples, + period_start_ticks, + )?); + spans.sort_by_key(|span| span.start); + + let Some(merged_end) = spans.iter().map(|span| span.end).max() else { + merged_track.samples.clear(); + return Ok(()); + }; + + let mut adjusted_starts = Vec::with_capacity(spans.len()); + for span in &spans { + let adjusted = adjusted_starts + .last() + .copied() + .map_or(span.start, |previous: u64| { + span.start.max(previous.saturating_add(1)) + }); + adjusted_starts.push(adjusted); + } + + let Some(last_start) = adjusted_starts.last().copied() else { + merged_track.samples.clear(); + return Ok(()); + }; + if last_start >= merged_end { + return Err(MuxError::UnsupportedTrackImport { + spec: manifest_path.display().to_string(), + message: "multi-period local MPD DTS-family import resolved more overlapping samples than can fit in the merged period timeline on the current path-only ingest surface".to_string(), + }); + } + + let mut merged_samples = Vec::with_capacity(spans.len()); + for (index, span) in spans.into_iter().enumerate() { + let next_start = adjusted_starts + .get(index + 1) + .copied() + .unwrap_or(merged_end); + let duration = u32::try_from(next_start - adjusted_starts[index]) + .map_err(|_| MuxError::LayoutOverflow("dash merged sample duration"))?; + let mut sample = span.sample; + sample.duration = duration; + merged_samples.push(sample); + } + merged_track.samples = merged_samples; + Ok(()) +} + +fn dash_requested_sample_spans( + samples: &[CandidateSample], + timeline_start: u64, +) -> Result, MuxError> { + let mut spans = Vec::with_capacity(samples.len()); + let mut decode_time = timeline_start; + for sample in samples { + let end = decode_time + .checked_add(u64::from(sample.duration)) + .ok_or(MuxError::LayoutOverflow("dash requested sample span"))?; + spans.push(DashRequestedSampleSpan { + start: decode_time, + end, + sample: *sample, + }); + decode_time = end; + } + Ok(spans) +} + +fn scale_dash_period_start_millis( + period_start_millis: u64, + timescale: u32, +) -> Result { + period_start_millis + .checked_mul(u64::from(timescale)) + .ok_or(MuxError::LayoutOverflow("dash period start scaling")) + .map(|scaled| scaled / 1000) +} + +fn materialize_composite_tracks( + sources: &mut SourceCatalog, + composite_tracks: Vec, +) -> Result { + let mut tracks = Vec::with_capacity(composite_tracks.len()); + for composite in composite_tracks { + let source_index = sources.add_segmented(composite.source_spec)?; + let mut track = composite.track; + assign_candidate_source_index(&mut track, source_index); + tracks.push(track); + } + Ok(ContainerSourceMetadata { + file_config: None, + tracks, + }) +} + +fn materialize_transport_stream_source( + sources: &mut SourceCatalog, + scanned: super::demux::TransportStreamScanResult, +) -> Result { + let super::demux::TransportStreamScanResult { + composite_tracks, + flat_chunk_sample_counts_by_track_id, + } = scanned; + let mut tracks = Vec::with_capacity(composite_tracks.len()); + for composite in composite_tracks { + let track_id = composite.track.track_id; + let source_index = sources.add_segmented(composite.source_spec)?; + if let Some(chunk_sample_counts) = flat_chunk_sample_counts_by_track_id.get(&track_id) { + sources.set_flat_chunk_sample_counts(source_index, chunk_sample_counts.clone()); + } + let mut track = composite.track; + assign_candidate_source_index(&mut track, source_index); + tracks.push(track); + } + Ok(ContainerSourceMetadata { + file_config: None, + tracks, + }) +} + +fn capture_selected_mp4_track_carries( + selected_tracks: &[ImportedTrack], + carries_by_track_id: &BTreeMap, + selected_carries: &mut SelectedImportedMp4CarryMap, +) { + for selected_track in selected_tracks { + let Some(source_index) = imported_track_single_source_index(selected_track) else { + continue; + }; + let Some(source_track_id) = selected_track + .mux_policy + .header_policy() + .and_then(|policy| policy.source_track_id) + else { + continue; + }; + let Some(carry) = carries_by_track_id.get(&source_track_id) else { + continue; + }; + selected_carries.insert((source_index, source_track_id), carry.clone()); + } +} + +fn imported_track_single_source_index(imported_track: &ImportedTrack) -> Option { + let source_index = imported_track.samples.first()?.source_index; + imported_track + .samples + .iter() + .all(|sample| sample.source_index == source_index) + .then_some(source_index) +} + +fn merge_flat_destination_append_tracks( + imported_tracks: &mut Vec, + selected_carries: &mut SelectedImportedMp4CarryMap, +) -> Result<(), MuxError> { + let Some(destination_source_index) = imported_tracks + .iter() + .filter_map(imported_track_single_source_index) + .min() + else { + return Ok(()); + }; + + let mut index = 0_usize; + while index < imported_tracks.len() { + if imported_track_single_source_index(&imported_tracks[index]) + == Some(destination_source_index) + { + index += 1; + continue; + } + + let compatible_targets = imported_tracks + .iter() + .enumerate() + .filter_map(|(candidate_index, candidate)| { + (candidate_index != index + && imported_track_single_source_index(candidate) + == Some(destination_source_index)) + .then(|| { + flat_destination_append_mode(candidate, &imported_tracks[index]) + .map(|mode| (candidate_index, mode)) + }) + .flatten() + }) + .collect::>(); + + match compatible_targets.as_slice() { + [] => { + index += 1; + } + [(target_index, append_mode)] => { + let target_index = *target_index; + let append_mode = *append_mode; + let incoming = imported_tracks.remove(index); + let adjusted_target_index = if target_index > index { + target_index - 1 + } else { + target_index + }; + append_imported_track_samples( + &mut imported_tracks[adjusted_target_index], + incoming, + selected_carries, + append_mode, + ); + } + _ => { + return Err(MuxError::InvalidDestinationMode { + mode: MuxDestinationMode::UpdateOrCreateDestination.label(), + message: format!( + "destination update found multiple compatible append targets for one {} track", + flat_destination_append_kind_label(imported_tracks[index].kind) + ), + }); + } + } + } + Ok(()) +} + +const fn flat_destination_append_kind_label(kind: MuxTrackKind) -> &'static str { + match kind { + MuxTrackKind::Audio => "audio", + MuxTrackKind::Video => "video", + MuxTrackKind::Text => "text", + MuxTrackKind::Subtitle => "subtitle", + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FlatDestinationAppendMode { + MergeSamples, + AppendSampleEntries, +} + +fn flat_destination_append_mode( + destination: &ImportedTrack, + incoming: &ImportedTrack, +) -> Option { + if incoming.kind.is_video() { + return None; + } + if !(destination.kind == incoming.kind + && destination.timescale == incoming.timescale + && destination.language == incoming.language + && destination.width == incoming.width + && destination.height == incoming.height + && destination.source_edit_media_time.is_none() + && incoming.source_edit_media_time.is_none()) + { + return None; + } + flat_destination_append_sample_entry_mode( + destination.kind, + &destination.sample_entry_box, + &incoming.sample_entry_box, + ) +} + +fn flat_destination_append_sample_entry_mode( + kind: MuxTrackKind, + destination: &[u8], + incoming: &[u8], +) -> Option { + if destination == incoming { + return Some(FlatDestinationAppendMode::MergeSamples); + } + if sample_entry_box_type(destination) != sample_entry_box_type(incoming) { + return None; + } + match kind { + MuxTrackKind::Audio => { + let Ok(destination_entry) = super::mp4::decode_audio_sample_entry(destination) else { + return None; + }; + let Ok(incoming_entry) = super::mp4::decode_audio_sample_entry(incoming) else { + return None; + }; + if !(destination_entry.sample_entry.box_type == incoming_entry.sample_entry.box_type + && destination_entry.channel_count == incoming_entry.channel_count + && destination_entry.sample_size == incoming_entry.sample_size + && destination_entry.sample_rate == incoming_entry.sample_rate) + { + return None; + } + } + MuxTrackKind::Video => { + let Ok(destination_entry) = + super::mp4::decode_typed_box::(destination) + else { + return None; + }; + let Ok(incoming_entry) = super::mp4::decode_typed_box::(incoming) + else { + return None; + }; + if !(destination_entry.sample_entry.box_type == incoming_entry.sample_entry.box_type + && destination_entry.width == incoming_entry.width + && destination_entry.height == incoming_entry.height + && destination_entry.depth == incoming_entry.depth) + { + return None; + } + } + MuxTrackKind::Text | MuxTrackKind::Subtitle => return None, + } + if sample_entry_children_without_noncritical_boxes(destination, kind) + == sample_entry_children_without_noncritical_boxes(incoming, kind) + { + return Some(FlatDestinationAppendMode::MergeSamples); + } + (sample_entry_codec_critical_children(destination, kind) + == sample_entry_codec_critical_children(incoming, kind)) + .then_some(FlatDestinationAppendMode::AppendSampleEntries) +} + +fn sample_entry_children_without_noncritical_boxes( + sample_entry_box: &[u8], + kind: MuxTrackKind, +) -> Option>> { + let child_boxes = match kind { + MuxTrackKind::Audio => super::mp4::audio_sample_entry_immediate_children(sample_entry_box), + MuxTrackKind::Video => super::mp4::visual_sample_entry_immediate_children(sample_entry_box), + MuxTrackKind::Text | MuxTrackKind::Subtitle => return None, + } + .ok()?; + Some( + child_boxes + .into_iter() + .filter(|child_box| { + sample_entry_box_type(child_box) != Some(FourCc::from_bytes(*b"btrt")) + }) + .collect(), + ) +} + +fn sample_entry_codec_critical_children( + sample_entry_box: &[u8], + kind: MuxTrackKind, +) -> Option>> { + let child_boxes = match kind { + MuxTrackKind::Audio => super::mp4::audio_sample_entry_immediate_children(sample_entry_box), + MuxTrackKind::Video => super::mp4::visual_sample_entry_immediate_children(sample_entry_box), + MuxTrackKind::Text | MuxTrackKind::Subtitle => return None, + } + .ok()?; + Some( + child_boxes + .into_iter() + .filter(|child_box| { + sample_entry_box_type(child_box).is_some_and(sample_entry_child_is_codec_critical) + }) + .collect(), + ) +} + +fn sample_entry_child_is_codec_critical(box_type: FourCc) -> bool { + matches!( + box_type.as_bytes().as_ref(), + b"avcC" + | b"hvcC" + | b"vvcC" + | b"av1C" + | b"esds" + | b"dfLa" + | b"dOps" + | b"dac3" + | b"dec3" + | b"dac4" + | b"ddts" + | b"udts" + | b"dmlp" + | b"mhaC" + | b"iacb" + | b"alac" + | b"damr" + | b"dqcp" + | b"d263" + | b"dvcC" + | b"dvvC" + | b"vpcC" + | b"pcmC" + ) +} + +fn append_imported_track_samples( + destination: &mut ImportedTrack, + incoming: ImportedTrack, + selected_carries: &mut SelectedImportedMp4CarryMap, + append_mode: FlatDestinationAppendMode, +) { + if append_mode == FlatDestinationAppendMode::AppendSampleEntries { + mark_destination_append_extends_sample_entries(destination, &incoming, selected_carries); + } else { + mark_destination_append_regenerates_sample_tables(destination, selected_carries); + } + destination.samples.extend(incoming.samples); + if let Some(header_policy) = destination.mux_policy.header_policy.as_mut() { + header_policy.source_media_duration = imported_sample_media_duration(&destination.samples); + header_policy.source_edit_segment_duration = None; + } +} + +fn mark_destination_append_extends_sample_entries( + destination: &ImportedTrack, + incoming: &ImportedTrack, + selected_carries: &mut SelectedImportedMp4CarryMap, +) { + let destination_sample_count = destination.samples.len(); + let incoming_sample_count = incoming.samples.len(); + let Some(destination_source_index) = imported_track_single_source_index(destination) else { + return; + }; + let Some(destination_track_id) = destination + .mux_policy + .header_policy() + .and_then(|policy| policy.source_track_id) + else { + return; + }; + let incoming_carry = imported_track_selected_mp4_carry(incoming, selected_carries).cloned(); + let Some(destination_carry) = + selected_carries.get_mut(&(destination_source_index, destination_track_id)) + else { + return; + }; + + let mut sample_entry_boxes = destination_carry + .sample_entry_boxes + .clone() + .unwrap_or_else(|| vec![destination.sample_entry_box.clone()]); + let sample_entry_offset = u32::try_from(sample_entry_boxes.len()).unwrap_or(u32::MAX); + let incoming_sample_entry_boxes = incoming_carry + .as_ref() + .and_then(|carry| carry.sample_entry_boxes.clone()) + .unwrap_or_else(|| vec![incoming.sample_entry_box.clone()]); + sample_entry_boxes.extend(incoming_sample_entry_boxes); + destination_carry.sample_entry_boxes = Some(sample_entry_boxes); + + let mut sample_description_indices = destination_carry + .sample_description_indices + .clone() + .unwrap_or_else(|| vec![1; destination_sample_count]); + let incoming_indices = incoming_carry + .as_ref() + .and_then(|carry| carry.sample_description_indices.clone()) + .unwrap_or_else(|| vec![1; incoming_sample_count]) + .into_iter() + .map(|index| sample_entry_offset.saturating_add(index)) + .collect::>(); + sample_description_indices.extend(incoming_indices); + destination_carry.sample_description_indices = Some(sample_description_indices); + + let mut chunk_sample_counts = destination_carry + .flat_chunk_sample_counts + .clone() + .unwrap_or_else(|| vec![u32::try_from(destination_sample_count).unwrap_or(u32::MAX)]); + let incoming_chunk_sample_counts = incoming_carry + .and_then(|carry| carry.flat_chunk_sample_counts) + .unwrap_or_else(|| vec![u32::try_from(incoming_sample_count).unwrap_or(u32::MAX)]); + chunk_sample_counts.extend(incoming_chunk_sample_counts); + destination_carry.flat_chunk_sample_counts = Some(chunk_sample_counts); + destination_carry.flat_stsc = None; + destination_carry.fragmented_decode_time_gaps.clear(); + destination_carry.source_had_empty_stts = false; + destination_carry.source_sync_samples = None; + destination_carry.preserved_flat_stbl_boxes.clear(); + destination_carry + .preserved_flat_trak_boxes + .retain(|box_bytes| box_header_type(box_bytes) != Some(EDTS)); +} + +fn mark_destination_append_regenerates_sample_tables( + destination: &ImportedTrack, + selected_carries: &mut SelectedImportedMp4CarryMap, +) { + let Some(source_index) = imported_track_single_source_index(destination) else { + return; + }; + let Some(source_track_id) = destination + .mux_policy + .header_policy() + .and_then(|policy| policy.source_track_id) + else { + return; + }; + let Some(carry) = selected_carries.get_mut(&(source_index, source_track_id)) else { + return; + }; + carry.flat_chunk_sample_counts = None; + carry.flat_stsc = None; + carry.sample_description_indices = None; + carry.fragmented_decode_time_gaps.clear(); + carry.source_had_empty_stts = false; + carry.source_sync_samples = None; + carry.preserved_flat_stbl_boxes.clear(); + carry + .preserved_flat_trak_boxes + .retain(|box_bytes| box_header_type(box_bytes) != Some(EDTS)); +} + +fn imported_track_selected_mp4_carry<'a>( + imported_track: &ImportedTrack, + selected_carries: &'a SelectedImportedMp4CarryMap, +) -> Option<&'a ImportedMp4TrackCarry> { + let source_track_id = imported_track + .mux_policy + .header_policy() + .and_then(|policy| policy.source_track_id)?; + let source_index = imported_track_single_source_index(imported_track).or_else(|| { + imported_track + .samples + .first() + .map(|sample| sample.source_index) + })?; + selected_carries.get(&(source_index, source_track_id)) +} + +fn normalized_imported_sample_entry_boxes( + imported_mp4_carry: Option<&ImportedMp4TrackCarry>, + normalized_sample_entry_box: Vec, +) -> Vec> { + let normalized_sample_entry_box = + sample_entry_box_with_self_contained_data_reference(normalized_sample_entry_box); + let Some(sample_entry_boxes) = + imported_mp4_carry.and_then(|carry| carry.sample_entry_boxes.as_ref()) + else { + return vec![normalized_sample_entry_box]; + }; + if sample_entry_boxes.len() <= 1 { + return vec![normalized_sample_entry_box]; + } + let mut normalized = sample_entry_boxes + .iter() + .cloned() + .map(sample_entry_box_with_self_contained_data_reference) + .collect::>(); + normalized[0] = normalized_sample_entry_box; + normalized +} + +fn sample_description_indices_use_multiple_entries(indices: &[u32]) -> bool { + let Some(first_index) = indices.first().copied() else { + return false; + }; + indices.iter().copied().any(|index| index != first_index) +} + +fn sample_entry_box_with_self_contained_data_reference(mut sample_entry_box: Vec) -> Vec { + if sample_entry_box.len() >= 16 { + sample_entry_box[14..16].copy_from_slice(&1_u16.to_be_bytes()); + } + sample_entry_box +} + +fn restore_preserved_imported_source_sync_samples( + imported_track: &mut ImportedTrack, + imported_mp4_carry: Option<&ImportedMp4TrackCarry>, +) { + if !imported_track.kind.is_video() || !imported_track_uses_avc_family(imported_track) { + return; + } + if imported_track + .mux_policy + .header_policy() + .is_some_and(|header_policy| header_policy.source_stss_first_only) + { + return; + } + let Some(source_sync_samples) = + imported_mp4_carry.and_then(|carry| carry.source_sync_samples.as_ref()) + else { + return; + }; + if source_sync_samples.len() != imported_track.samples.len() { + return; + } + for (sample, source_is_sync_sample) in imported_track + .samples + .iter_mut() + .zip(source_sync_samples.iter().copied()) + { + sample.is_sync_sample = source_is_sync_sample; + } +} + +fn reconcile_flat_imported_fragment_decode_time_gaps( + imported_tracks: &mut [ImportedTrack], + selected_carries: &SelectedImportedMp4CarryMap, + sources: &SourceCatalog, +) -> Result<(), MuxError> { + for imported_track in imported_tracks { + let Some(gaps) = + imported_track_fragmented_decode_time_gaps(imported_track, selected_carries, sources) + else { + continue; + }; + for gap in gaps { + let Some(previous_sample_index) = gap.sample_index.checked_sub(1) else { + continue; + }; + let delta = u32::try_from(gap.delta) + .map_err(|_| MuxError::LayoutOverflow("fragmented decode-time gap"))?; + let Some(previous_sample) = imported_track.samples.get_mut(previous_sample_index) + else { + continue; + }; + previous_sample.duration = previous_sample + .duration + .checked_add(delta) + .ok_or(MuxError::LayoutOverflow("fragmented decode-time gap"))?; + } + } + Ok(()) +} + +fn imported_track_fragmented_decode_time_gaps<'a>( + imported_track: &ImportedTrack, + selected_carries: &'a SelectedImportedMp4CarryMap, + sources: &'a SourceCatalog, +) -> Option<&'a [FragmentedDecodeTimeGap]> { + imported_track_source_key(imported_track) + .and_then(|(source_index, source_track_id)| { + sources.fragmented_decode_time_gaps(source_index, source_track_id) + }) + .or_else(|| { + imported_track_selected_mp4_carry(imported_track, selected_carries) + .map(|carry| carry.fragmented_decode_time_gaps.as_slice()) + }) + .filter(|gaps| !gaps.is_empty()) +} + +fn record_mp4_fragmented_decode_time_gaps( + sources: &mut SourceCatalog, + source_index: usize, + metadata: &PathSourceMetadata, +) { + for (source_track_id, carry) in &metadata.carries_by_track_id { + sources.set_fragmented_decode_time_gaps( + source_index, + *source_track_id, + carry.fragmented_decode_time_gaps.clone(), + ); + } +} + +fn load_mp4_source_sync<'a>( + path: &Path, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a PathSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + let source_index = sources.add_file(&absolute)?; + let mut reader = File::open(&absolute)?; + let metadata = parse_mp4_source_sync(&absolute, source_index, &mut reader)?; + if let Some(source_override) = metadata.source_override.clone() { + sources.replace_with_segmented(source_index, source_override)?; + } + record_mp4_fragmented_decode_time_gaps(sources, source_index, &metadata); + cache.insert(absolute.clone(), metadata); + } + Ok(cache.get(&absolute).unwrap()) +} + +fn load_selected_mp4_source_sync( + path: &Path, + selector: MuxMp4TrackSelector, + sources: &mut SourceCatalog, +) -> Result { + let absolute = absolute_path(path)?; + let source_index = sources.add_file(&absolute)?; + let mut reader = File::open(&absolute)?; + let metadata = + parse_mp4_source_sync_with_selector(&absolute, source_index, &mut reader, Some(selector))?; + if let Some(source_override) = metadata.source_override.clone() { + sources.replace_with_segmented(source_index, source_override)?; + } + record_mp4_fragmented_decode_time_gaps(sources, source_index, &metadata); + Ok(metadata) +} + +#[cfg(feature = "async")] +async fn load_mp4_source_async<'a>( + path: &Path, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a PathSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + let source_index = sources.add_file(&absolute)?; + let mut reader = TokioFile::open(&absolute).await?; + let metadata = parse_mp4_source_async(&absolute, source_index, &mut reader).await?; + if let Some(source_override) = metadata.source_override.clone() { + sources.replace_with_segmented(source_index, source_override)?; + } + record_mp4_fragmented_decode_time_gaps(sources, source_index, &metadata); + cache.insert(absolute.clone(), metadata); + } + Ok(cache.get(&absolute).unwrap()) +} + +#[cfg(feature = "async")] +async fn load_selected_mp4_source_async( + path: &Path, + selector: MuxMp4TrackSelector, + sources: &mut SourceCatalog, +) -> Result { + let absolute = absolute_path(path)?; + let source_index = sources.add_file(&absolute)?; + let mut reader = TokioFile::open(&absolute).await?; + let metadata = + parse_mp4_source_async_with_selector(&absolute, source_index, &mut reader, Some(selector)) + .await?; + if let Some(source_override) = metadata.source_override.clone() { + sources.replace_with_segmented(source_index, source_override)?; + } + record_mp4_fragmented_decode_time_gaps(sources, source_index, &metadata); + Ok(metadata) +} + +fn load_avi_source_sync<'a>( + path: &Path, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a ContainerSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + let source_index = sources.add_file(&absolute)?; + let scanned = + scan_avi_source_sync(&absolute, &absolute.display().to_string(), source_index)?; + let mut tracks = scanned.tracks; + if !scanned.composite_tracks.is_empty() { + tracks.extend(materialize_composite_tracks(sources, scanned.composite_tracks)?.tracks); + } + cache.insert( + absolute.clone(), + ContainerSourceMetadata { + file_config: None, + tracks, + }, + ); + } + Ok(cache.get(&absolute).unwrap()) +} + +fn load_nhml_source_sync<'a>( + path: &Path, + kind: DetectedNhmlSidecarKind, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a ContainerSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + let parsed = parse_nhml_source_sync(&absolute, kind)?; + cache.insert( + absolute.clone(), + materialize_parsed_nhml_source(parsed, sources)?, + ); + } + Ok(cache.get(&absolute).unwrap()) +} + +fn load_dash_source_sync<'a>( + path: &Path, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a ContainerSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + let parsed = parse_dash_source_sync(&absolute)?; + cache.insert( + absolute.clone(), + materialize_parsed_dash_source(&absolute, parsed, sources)?, + ); + } + Ok(cache.get(&absolute).unwrap()) +} + +fn load_program_stream_source_sync<'a>( + path: &Path, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a ContainerSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + cache.insert( + absolute.clone(), + materialize_composite_tracks( + sources, + scan_program_stream_sync(&absolute, &absolute.display().to_string())?, + )?, + ); + } + Ok(cache.get(&absolute).unwrap()) +} + +fn load_saf_source_sync<'a>( + path: &Path, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a ContainerSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + let source_index = sources.add_file(&absolute)?; + let tracks = super::demux::scan_saf_source_sync( + &absolute, + &absolute.display().to_string(), + source_index, + )?; + cache.insert( + absolute.clone(), + ContainerSourceMetadata { + file_config: None, + tracks, + }, + ); + } + Ok(cache.get(&absolute).unwrap()) +} + +fn load_transport_stream_source_sync<'a>( + path: &Path, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a ContainerSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + let scanned = scan_transport_stream_sync(&absolute, &absolute.display().to_string())?; + cache.insert( + absolute.clone(), + materialize_transport_stream_source(sources, scanned)?, + ); + } + Ok(cache.get(&absolute).unwrap()) +} + +fn load_vobsub_source_sync<'a>( + path: &Path, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a ContainerSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + cache.insert( + absolute.clone(), + materialize_composite_tracks( + sources, + scan_vobsub_source_sync(&absolute, &absolute.display().to_string())?, + )?, + ); + } + Ok(cache.get(&absolute).unwrap()) +} + +#[cfg(feature = "async")] +async fn load_avi_source_async<'a>( + path: &Path, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a ContainerSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + let source_index = sources.add_file(&absolute)?; + let scanned = + scan_avi_source_async(&absolute, &absolute.display().to_string(), source_index).await?; + let mut tracks = scanned.tracks; + if !scanned.composite_tracks.is_empty() { + tracks.extend(materialize_composite_tracks(sources, scanned.composite_tracks)?.tracks); + } + cache.insert( + absolute.clone(), + ContainerSourceMetadata { + file_config: None, + tracks, + }, + ); + } + Ok(cache.get(&absolute).unwrap()) +} + +#[cfg(feature = "async")] +async fn load_nhml_source_async<'a>( + path: &Path, + kind: DetectedNhmlSidecarKind, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a ContainerSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + let parsed = parse_nhml_source_async(&absolute, kind).await?; + cache.insert( + absolute.clone(), + materialize_parsed_nhml_source(parsed, sources)?, + ); + } + Ok(cache.get(&absolute).unwrap()) +} + +#[cfg(feature = "async")] +async fn load_dash_source_async<'a>( + path: &Path, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a ContainerSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + let parsed = parse_dash_source_async(&absolute).await?; + let metadata = materialize_parsed_dash_source_async(&absolute, parsed, sources).await?; + cache.insert(absolute.clone(), metadata); + } + Ok(cache.get(&absolute).unwrap()) +} + +#[cfg(feature = "async")] +async fn load_vobsub_source_async<'a>( + path: &Path, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a ContainerSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + cache.insert( + absolute.clone(), + materialize_composite_tracks( + sources, + scan_vobsub_source_async(&absolute, &absolute.display().to_string()).await?, + )?, + ); + } + Ok(cache.get(&absolute).unwrap()) +} + +#[cfg(feature = "async")] +async fn load_program_stream_source_async<'a>( + path: &Path, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a ContainerSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + cache.insert( + absolute.clone(), + materialize_composite_tracks( + sources, + scan_program_stream_async(&absolute, &absolute.display().to_string()).await?, + )?, + ); + } + Ok(cache.get(&absolute).unwrap()) +} + +#[cfg(feature = "async")] +async fn load_saf_source_async<'a>( + path: &Path, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a ContainerSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + let source_index = sources.add_file(&absolute)?; + let tracks = super::demux::scan_saf_source_async( + &absolute, + &absolute.display().to_string(), + source_index, + ) + .await?; + cache.insert( + absolute.clone(), + ContainerSourceMetadata { + file_config: None, + tracks, + }, + ); + } + Ok(cache.get(&absolute).unwrap()) +} + +#[cfg(feature = "async")] +async fn load_transport_stream_source_async<'a>( + path: &Path, + cache: &'a mut BTreeMap, + sources: &mut SourceCatalog, +) -> Result<&'a ContainerSourceMetadata, MuxError> { + let absolute = absolute_path(path)?; + if !cache.contains_key(&absolute) { + let scanned = + scan_transport_stream_async(&absolute, &absolute.display().to_string()).await?; + cache.insert( + absolute.clone(), + materialize_transport_stream_source(sources, scanned)?, + ); + } + Ok(cache.get(&absolute).unwrap()) +} + +fn parse_mp4_source_sync( + path: &Path, + source_index: usize, + reader: &mut R, +) -> Result +where + R: Read + Seek, +{ + parse_mp4_source_sync_with_selector(path, source_index, reader, None) +} + +fn parse_mp4_source_sync_with_selector( + path: &Path, + source_index: usize, + reader: &mut R, + selector: Option, +) -> Result +where + R: Read + Seek, +{ + let source_file_size = reader.seek(SeekFrom::End(0))?; + reader.seek(SeekFrom::Start(0))?; + let mut file_config = probe_file_config_sync(reader)?; + let mut source_movie_timescale = file_config.movie_timescale(); + let mut compressed_root_cursor = None::>>; + let fragmented_hint = !extract_box(reader, None, BoxPath::from([MOOF]))?.is_empty(); + let mut track_infos = match extract_box(reader, None, BoxPath::from([MOOV, TRAK])) { + Ok(track_infos) => track_infos, + Err(error) => { + if let Some(root_bytes) = + crate::probe::extract_compressed_movie_root_bytes_sync(reader)? + { + let mut cursor = Cursor::new(root_bytes); + let fallback_file_config = probe_file_config_sync(&mut cursor)?; + let fallback_track_infos = + extract_box(&mut cursor, None, BoxPath::from([MOOV, TRAK]))?; + if !fallback_track_infos.is_empty() || fallback_file_config.movie_timescale() != 0 { + file_config = fallback_file_config; + source_movie_timescale = file_config.movie_timescale(); + compressed_root_cursor = Some(cursor); + fallback_track_infos + } else { + return Err(error.into()); + } + } else { + return Err(error.into()); + } + } + }; + if compressed_root_cursor.is_none() + && (track_infos.is_empty() || source_movie_timescale == 0) + && let Some(root_bytes) = crate::probe::extract_compressed_movie_root_bytes_sync(reader)? + { + let mut cursor = Cursor::new(root_bytes); + let fallback_file_config = probe_file_config_sync(&mut cursor)?; + let fallback_track_infos = extract_box(&mut cursor, None, BoxPath::from([MOOV, TRAK]))?; + if !fallback_track_infos.is_empty() || fallback_file_config.movie_timescale() != 0 { + file_config = fallback_file_config; + source_movie_timescale = file_config.movie_timescale(); + track_infos = fallback_track_infos; + compressed_root_cursor = Some(cursor); + } + } + let mut tracks = Vec::new(); + let mut carries_by_track_id = BTreeMap::new(); + let mut source_segments = Vec::::new(); + let mut uses_external_data_reference = false; + if let Some(metadata_reader) = compressed_root_cursor.as_mut() { + for trak_info in track_infos { + if let Some(selector) = selector + && !track_may_match_selector_sync(metadata_reader, &trak_info, selector)? + { + continue; + } + let components = extract_track_candidate_components_sync( + path, + fragmented_hint, + metadata_reader, + &trak_info, + )?; + if let Some(parsed_track) = finish_parsed_track_candidate_sync( + path, + source_index, + fragmented_hint, + source_movie_timescale, + source_file_size, + reader, + components, + )? { + source_segments.extend(parsed_track.source_segments.iter().cloned()); + uses_external_data_reference |= parsed_track.uses_external_data_reference; + carries_by_track_id.insert(parsed_track.track.track_id, parsed_track.carry); + tracks.push(parsed_track.track); + } + } + } else { + for trak_info in track_infos { + if let Some(selector) = selector + && !track_may_match_selector_sync(reader, &trak_info, selector)? + { + continue; + } + if let Some(parsed_track) = parse_track_candidate_sync( + path, + source_index, + fragmented_hint, + source_movie_timescale, + source_file_size, + reader, + &trak_info, + )? { + source_segments.extend(parsed_track.source_segments.iter().cloned()); + uses_external_data_reference |= parsed_track.uses_external_data_reference; + carries_by_track_id.insert(parsed_track.track.track_id, parsed_track.carry); + tracks.push(parsed_track.track); + } + } + } + populate_empty_fragmented_track_samples_sync( + path, + source_index, + reader, + &mut tracks, + &mut carries_by_track_id, + )?; + Ok(PathSourceMetadata { + file_config: Some(file_config), + tracks, + carries_by_track_id, + source_override: if uses_external_data_reference { + Some(build_mp4_import_source_override( + path, + source_segments, + source_file_size, + )?) + } else { + None + }, + }) +} + +struct ParsedMp4Track { + track: TrackCandidate, + carry: ImportedMp4TrackCarry, + source_segments: Vec, + uses_external_data_reference: bool, +} + +struct ParsedTrackCandidateComponents { + tkhd: Tkhd, + mdhd: Mdhd, + hdlr: Option, + sample_entry: ExtractedBox, + sample_entry_box: Vec, + sample_entry_boxes: Vec>, + data_references: Vec, + elst: Option, + elst_box_size: Option, + sample_roll_distance: Option, + emit_roll_sbgp: bool, + preserved_flat_stbl_boxes: Vec>, + preserved_flat_trak_boxes: Vec>, + stts: Option, + ctts: Option, + stsc: Option, + sample_sizes: Option>, + stco: Option, + co64: Option, + stss: Option, +} + +type SelectedImportedMp4CarryMap = BTreeMap<(usize, u32), ImportedMp4TrackCarry>; + +#[cfg(feature = "async")] +async fn parse_mp4_source_async( + path: &Path, + source_index: usize, + reader: &mut R, +) -> Result +where + R: AsyncReadSeek, +{ + parse_mp4_source_async_with_selector(path, source_index, reader, None).await +} + +#[cfg(feature = "async")] +async fn parse_mp4_source_async_with_selector( + path: &Path, + source_index: usize, + reader: &mut R, + selector: Option, +) -> Result +where + R: AsyncReadSeek, +{ + let source_file_size = reader.seek(SeekFrom::End(0)).await?; + reader.seek(SeekFrom::Start(0)).await?; + let mut file_config = probe_file_config_async(reader).await?; + let mut source_movie_timescale = file_config.movie_timescale(); + let mut compressed_root_cursor = None::>>; + let fragmented_hint = !extract_box_async(reader, None, BoxPath::from([MOOF])) + .await? + .is_empty(); + let mut track_infos = match extract_box_async(reader, None, BoxPath::from([MOOV, TRAK])).await { + Ok(track_infos) => track_infos, + Err(error) => { + if let Some(root_bytes) = + crate::probe::extract_compressed_movie_root_bytes_async(reader).await? + { + let mut cursor = Cursor::new(root_bytes); + let fallback_file_config = probe_file_config_sync(&mut cursor)?; + let fallback_track_infos = + extract_box(&mut cursor, None, BoxPath::from([MOOV, TRAK]))?; + if !fallback_track_infos.is_empty() || fallback_file_config.movie_timescale() != 0 { + file_config = fallback_file_config; + source_movie_timescale = file_config.movie_timescale(); + compressed_root_cursor = Some(cursor); + fallback_track_infos + } else { + return Err(error.into()); + } + } else { + return Err(error.into()); + } + } + }; + if compressed_root_cursor.is_none() + && (track_infos.is_empty() || source_movie_timescale == 0) + && let Some(root_bytes) = + crate::probe::extract_compressed_movie_root_bytes_async(reader).await? + { + let mut cursor = Cursor::new(root_bytes); + let fallback_file_config = probe_file_config_sync(&mut cursor)?; + let fallback_track_infos = extract_box(&mut cursor, None, BoxPath::from([MOOV, TRAK]))?; + if !fallback_track_infos.is_empty() || fallback_file_config.movie_timescale() != 0 { + file_config = fallback_file_config; + source_movie_timescale = file_config.movie_timescale(); + track_infos = fallback_track_infos; + compressed_root_cursor = Some(cursor); + } + } + let mut tracks = Vec::new(); + let mut carries_by_track_id = BTreeMap::new(); + let mut source_segments = Vec::::new(); + let mut uses_external_data_reference = false; + if let Some(metadata_reader) = compressed_root_cursor.as_mut() { + for trak_info in track_infos { + if let Some(selector) = selector + && !track_may_match_selector_sync(metadata_reader, &trak_info, selector)? + { + continue; + } + let components = extract_track_candidate_components_sync( + path, + fragmented_hint, + metadata_reader, + &trak_info, + )?; + if let Some(parsed_track) = finish_parsed_track_candidate_async( + path, + source_index, + fragmented_hint, + source_movie_timescale, + source_file_size, + reader, + components, + ) + .await? + { + source_segments.extend(parsed_track.source_segments.iter().cloned()); + uses_external_data_reference |= parsed_track.uses_external_data_reference; + carries_by_track_id.insert(parsed_track.track.track_id, parsed_track.carry); + tracks.push(parsed_track.track); + } + } + } else { + for trak_info in track_infos { + if let Some(selector) = selector + && !track_may_match_selector_async(reader, &trak_info, selector).await? + { + continue; + } + if let Some(parsed_track) = parse_track_candidate_async( + path, + source_index, + fragmented_hint, + source_movie_timescale, + source_file_size, + reader, + &trak_info, + ) + .await? + { + source_segments.extend(parsed_track.source_segments.iter().cloned()); + uses_external_data_reference |= parsed_track.uses_external_data_reference; + carries_by_track_id.insert(parsed_track.track.track_id, parsed_track.carry); + tracks.push(parsed_track.track); + } + } + } + populate_empty_fragmented_track_samples_async( + path, + source_index, + reader, + &mut tracks, + &mut carries_by_track_id, + ) + .await?; + Ok(PathSourceMetadata { + file_config: Some(file_config), + tracks, + carries_by_track_id, + source_override: if uses_external_data_reference { + Some(build_mp4_import_source_override( + path, + source_segments, + source_file_size, + )?) + } else { + None + }, + }) +} + +fn track_may_match_selector_sync( + reader: &mut R, + trak_info: &HeaderInfo, + selector: MuxMp4TrackSelector, +) -> Result +where + R: Read + Seek, +{ + match selector { + MuxMp4TrackSelector::TrackId { track_id } => { + let tkhd = extract_required_single_as_sync::<_, Tkhd>( + reader, + trak_info, + BoxPath::from([TKHD]), + "tkhd", + )?; + Ok(tkhd.track_id == track_id) + } + MuxMp4TrackSelector::Audio { .. } => { + track_matches_handler_selector_sync(reader, trak_info, &[SOUN]) + } + MuxMp4TrackSelector::Video => { + track_matches_handler_selector_sync(reader, trak_info, &[VIDE]) + } + MuxMp4TrackSelector::Text { .. } => { + track_matches_handler_selector_sync(reader, trak_info, &[TEXT, SUBT, SUBP]) + } + } +} + +fn track_matches_handler_selector_sync( + reader: &mut R, + trak_info: &HeaderInfo, + accepted_handler_types: &[FourCc], +) -> Result +where + R: Read + Seek, +{ + let hdlr = + extract_optional_single_as_sync::<_, Hdlr>(reader, trak_info, BoxPath::from([MDIA, HDLR]))?; + Ok(hdlr + .map(|hdlr| accepted_handler_types.contains(&hdlr.handler_type)) + .unwrap_or(true)) +} + +#[cfg(feature = "async")] +async fn track_may_match_selector_async( + reader: &mut R, + trak_info: &HeaderInfo, + selector: MuxMp4TrackSelector, +) -> Result +where + R: AsyncReadSeek, +{ + match selector { + MuxMp4TrackSelector::TrackId { track_id } => { + let tkhd = extract_required_single_as_async::<_, Tkhd>( + reader, + trak_info, + BoxPath::from([TKHD]), + "tkhd", + ) + .await?; + Ok(tkhd.track_id == track_id) + } + MuxMp4TrackSelector::Audio { .. } => { + track_matches_handler_selector_async(reader, trak_info, &[SOUN]).await + } + MuxMp4TrackSelector::Video => { + track_matches_handler_selector_async(reader, trak_info, &[VIDE]).await + } + MuxMp4TrackSelector::Text { .. } => { + track_matches_handler_selector_async(reader, trak_info, &[TEXT, SUBT, SUBP]).await + } + } +} + +#[cfg(feature = "async")] +async fn track_matches_handler_selector_async( + reader: &mut R, + trak_info: &HeaderInfo, + accepted_handler_types: &[FourCc], +) -> Result +where + R: AsyncReadSeek, +{ + let hdlr = + extract_optional_single_as_async::<_, Hdlr>(reader, trak_info, BoxPath::from([MDIA, HDLR])) + .await?; + Ok(hdlr + .map(|hdlr| accepted_handler_types.contains(&hdlr.handler_type)) + .unwrap_or(true)) +} + +fn populate_empty_fragmented_track_samples_sync( + path: &Path, + source_index: usize, + reader: &mut R, + tracks: &mut [TrackCandidate], + carries_by_track_id: &mut BTreeMap, +) -> Result<(), MuxError> +where + R: Read + Seek, +{ + if tracks.iter().all(|track| !track.samples.is_empty()) { + return Ok(()); + } + + let moof_infos = extract_box(reader, None, BoxPath::from([MOOF]))?; + if moof_infos.is_empty() { + return Ok(()); + } + let trex_by_track_id = + extract_box_as::<_, Trex>(reader, None, BoxPath::from([MOOV, MVEX, TREX]))? + .into_iter() + .map(|trex| (trex.track_id, trex)) + .collect::>(); + + for track in tracks.iter_mut().filter(|track| track.samples.is_empty()) { + let reconcile_fragment_durations = + sample_entry_box_type(&track.sample_entry_box) != Some(FourCc::from_bytes(*b"ac-3")); + let fragment_samples = collect_fragment_candidate_samples_sync( + path, + source_index, + reader, + track.track_id, + &moof_infos, + trex_by_track_id.get(&track.track_id), + reconcile_fragment_durations, + )?; + if !fragment_samples.samples.is_empty() { + if let Some(first_base_decode_time) = fragment_samples.first_base_decode_time + && let Some(mut header_policy) = track.mux_policy.header_policy() + { + header_policy.source_media_decode_time_offset = Some(first_base_decode_time); + track.mux_policy = track.mux_policy.with_header_policy(header_policy); + } + let carry = carries_by_track_id.entry(track.track_id).or_default(); + carry.sample_description_indices = Some(fragment_samples.sample_description_indices); + carry.fragmented_decode_time_gaps = fragment_samples.fragmented_decode_time_gaps; + track.samples = fragment_samples.samples; + } + } + Ok(()) +} + +fn collect_fragment_candidate_samples_sync( + path: &Path, + source_index: usize, + reader: &mut R, + track_id: u32, + moof_infos: &[HeaderInfo], + trex: Option<&Trex>, + reconcile_fragment_durations: bool, +) -> Result +where + R: Read + Seek, +{ + let mut fragment_batches = Vec::::new(); + for moof_info in moof_infos { + let traf_infos = extract_box(reader, Some(moof_info), BoxPath::from([TRAF]))?; + for traf_info in traf_infos { + let tfhd = extract_required_single_as_sync::<_, Tfhd>( + reader, + &traf_info, + BoxPath::from([TFHD]), + "tfhd", + )?; + if tfhd.track_id != track_id { + continue; + } + let tfdt = extract_optional_single_as_sync::<_, Tfdt>( + reader, + &traf_info, + BoxPath::from([FourCc::from_bytes(*b"tfdt")]), + )?; + let truns = extract_box_as::<_, Trun>(reader, Some(&traf_info), BoxPath::from([TRUN]))?; + let trun_infos = extract_box(reader, Some(&traf_info), BoxPath::from([TRUN]))?; + if tfdt.is_none() && truns.iter().any(|trun| trun.sample_count != 0) { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} has a non-empty fragmented run without tfdt decode time" + ), + }); + } + let context = FragmentRunContext { + path, + source_index, + track_id, + moof_offset: moof_info.offset(), + trex, + }; + let mut fragment_samples = Vec::new(); + let mut fragment_sample_description_indices = Vec::new(); + collect_fragment_candidate_samples_from_runs( + &context, + &tfhd, + &truns, + &trun_infos, + &mut fragment_samples, + &mut fragment_sample_description_indices, + )?; + if !fragment_samples.is_empty() { + fragment_batches.push(ImportedFragmentBatch { + base_decode_time: tfdt.as_ref().map(fragment_tfdt_base_decode_time), + samples: fragment_samples, + sample_description_indices: fragment_sample_description_indices, + }); + } + } + } + if reconcile_fragment_durations { + reconcile_imported_fragment_sample_durations(path, track_id, &mut fragment_batches)?; + } + let first_base_decode_time = fragment_batches + .iter() + .find_map(|batch| batch.base_decode_time); + let fragmented_decode_time_gaps = + collect_fragmented_decode_time_gaps(path, track_id, &fragment_batches)?; + let mut samples = Vec::new(); + let mut sample_description_indices = Vec::new(); + for batch in fragment_batches { + sample_description_indices.extend(batch.sample_description_indices); + samples.extend(batch.samples); + } + Ok(ImportedFragmentSamples { + samples, + sample_description_indices, + first_base_decode_time, + fragmented_decode_time_gaps, + }) +} + +#[cfg(feature = "async")] +async fn populate_empty_fragmented_track_samples_async( + path: &Path, + source_index: usize, + reader: &mut R, + tracks: &mut [TrackCandidate], + carries_by_track_id: &mut BTreeMap, +) -> Result<(), MuxError> +where + R: AsyncReadSeek, +{ + if tracks.iter().all(|track| !track.samples.is_empty()) { + return Ok(()); + } + + let moof_infos = extract_box_async(reader, None, BoxPath::from([MOOF])).await?; + if moof_infos.is_empty() { + return Ok(()); + } + let trex_by_track_id = + extract_box_as_async::<_, Trex>(reader, None, BoxPath::from([MOOV, MVEX, TREX])) + .await? + .into_iter() + .map(|trex| (trex.track_id, trex)) + .collect::>(); + + for track in tracks.iter_mut().filter(|track| track.samples.is_empty()) { + let reconcile_fragment_durations = + sample_entry_box_type(&track.sample_entry_box) != Some(FourCc::from_bytes(*b"ac-3")); + let fragment_samples = collect_fragment_candidate_samples_async( + path, + source_index, + reader, + track.track_id, + &moof_infos, + trex_by_track_id.get(&track.track_id), + reconcile_fragment_durations, + ) + .await?; + if !fragment_samples.samples.is_empty() { + if let Some(first_base_decode_time) = fragment_samples.first_base_decode_time + && let Some(mut header_policy) = track.mux_policy.header_policy() + { + header_policy.source_media_decode_time_offset = Some(first_base_decode_time); + track.mux_policy = track.mux_policy.with_header_policy(header_policy); + } + let carry = carries_by_track_id.entry(track.track_id).or_default(); + carry.sample_description_indices = Some(fragment_samples.sample_description_indices); + carry.fragmented_decode_time_gaps = fragment_samples.fragmented_decode_time_gaps; + track.samples = fragment_samples.samples; + } + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn collect_fragment_candidate_samples_async( + path: &Path, + source_index: usize, + reader: &mut R, + track_id: u32, + moof_infos: &[HeaderInfo], + trex: Option<&Trex>, + reconcile_fragment_durations: bool, +) -> Result +where + R: AsyncReadSeek, +{ + let mut fragment_batches = Vec::::new(); + for moof_info in moof_infos { + let traf_infos = extract_box_async(reader, Some(moof_info), BoxPath::from([TRAF])).await?; + for traf_info in traf_infos { + let tfhd = extract_required_single_as_async::<_, Tfhd>( + reader, + &traf_info, + BoxPath::from([TFHD]), + "tfhd", + ) + .await?; + if tfhd.track_id != track_id { + continue; + } + let tfdt = extract_optional_single_as_async::<_, Tfdt>( + reader, + &traf_info, + BoxPath::from([FourCc::from_bytes(*b"tfdt")]), + ) + .await?; + let truns = + extract_box_as_async::<_, Trun>(reader, Some(&traf_info), BoxPath::from([TRUN])) + .await?; + let trun_infos = + extract_box_async(reader, Some(&traf_info), BoxPath::from([TRUN])).await?; + if tfdt.is_none() && truns.iter().any(|trun| trun.sample_count != 0) { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} has a non-empty fragmented run without tfdt decode time" + ), + }); + } + let context = FragmentRunContext { + path, + source_index, + track_id, + moof_offset: moof_info.offset(), + trex, + }; + let mut fragment_samples = Vec::new(); + let mut fragment_sample_description_indices = Vec::new(); + collect_fragment_candidate_samples_from_runs( + &context, + &tfhd, + &truns, + &trun_infos, + &mut fragment_samples, + &mut fragment_sample_description_indices, + )?; + if !fragment_samples.is_empty() { + fragment_batches.push(ImportedFragmentBatch { + base_decode_time: tfdt.as_ref().map(fragment_tfdt_base_decode_time), + samples: fragment_samples, + sample_description_indices: fragment_sample_description_indices, + }); + } + } + } + if reconcile_fragment_durations { + reconcile_imported_fragment_sample_durations(path, track_id, &mut fragment_batches)?; + } + let first_base_decode_time = fragment_batches + .iter() + .find_map(|batch| batch.base_decode_time); + let fragmented_decode_time_gaps = + collect_fragmented_decode_time_gaps(path, track_id, &fragment_batches)?; + let mut samples = Vec::new(); + let mut sample_description_indices = Vec::new(); + for batch in fragment_batches { + sample_description_indices.extend(batch.sample_description_indices); + samples.extend(batch.samples); + } + Ok(ImportedFragmentSamples { + samples, + sample_description_indices, + first_base_decode_time, + fragmented_decode_time_gaps, + }) +} + +fn fragment_tfdt_base_decode_time(tfdt: &Tfdt) -> u64 { + if tfdt.version() == 1 { + tfdt.base_media_decode_time_v1 + } else { + u64::from(tfdt.base_media_decode_time_v0) + } +} + +fn collect_fragmented_decode_time_gaps( + path: &Path, + track_id: u32, + fragment_batches: &[ImportedFragmentBatch], +) -> Result, MuxError> { + let mut gaps = Vec::new(); + let mut sample_index = 0_usize; + let mut previous_base_decode_time = None::; + let mut previous_decode_end = None::; + for batch in fragment_batches { + if let Some(base_decode_time) = batch.base_decode_time { + if let Some(previous_base_decode_time) = previous_base_decode_time + && base_decode_time < previous_base_decode_time + { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} exposes descending fragmented tfdt decode times" + ), + }); + } + if let Some(previous_decode_end) = previous_decode_end + && base_decode_time > previous_decode_end + { + gaps.push(FragmentedDecodeTimeGap { + sample_index, + delta: base_decode_time - previous_decode_end, + }); + } + let batch_duration = batch.samples.iter().try_fold(0_u64, |duration, sample| { + duration + .checked_add(u64::from(sample.duration)) + .ok_or(MuxError::LayoutOverflow("fragmented decode-time gap")) + })?; + previous_base_decode_time = Some(base_decode_time); + previous_decode_end = Some( + base_decode_time + .checked_add(batch_duration) + .ok_or(MuxError::LayoutOverflow("fragmented decode-time gap"))?, + ); + } + sample_index = sample_index + .checked_add(batch.samples.len()) + .ok_or(MuxError::LayoutOverflow("fragmented decode-time gap"))?; + } + Ok(gaps) +} + +fn reconcile_imported_fragment_sample_durations( + path: &Path, + track_id: u32, + fragment_batches: &mut [ImportedFragmentBatch], +) -> Result<(), MuxError> { + for index in 0..fragment_batches.len().saturating_sub(1) { + let Some(current_base_decode_time) = fragment_batches[index].base_decode_time else { + continue; + }; + let Some(next_base_decode_time) = fragment_batches[index + 1].base_decode_time else { + continue; + }; + if next_base_decode_time < current_base_decode_time { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} exposes descending fragmented tfdt decode times" + ), + }); + } + let expected_fragment_duration = next_base_decode_time - current_base_decode_time; + let actual_fragment_duration = fragment_batches[index] + .samples + .iter() + .map(|sample| u64::from(sample.duration)) + .sum::(); + if expected_fragment_duration == actual_fragment_duration { + continue; + } + let delta = i128::from(expected_fragment_duration) - i128::from(actual_fragment_duration); + let Some(last_sample) = fragment_batches[index].samples.last_mut() else { + continue; + }; + let adjusted_duration = i128::from(last_sample.duration) + delta; + if adjusted_duration <= 0 || adjusted_duration > i128::from(u32::MAX) { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} exposes fragmented tfdt/trun timing that cannot be reconciled" + ), + }); + } + last_sample.duration = adjusted_duration as u32; + } + Ok(()) +} + +fn collect_fragment_candidate_samples_from_runs( + context: &FragmentRunContext<'_>, + tfhd: &Tfhd, + truns: &[Trun], + trun_infos: &[HeaderInfo], + output: &mut Vec, + sample_description_indices: &mut Vec, +) -> Result<(), MuxError> { + let path = context.path; + let track_id = context.track_id; + if truns.len() != trun_infos.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} exposes misaligned fragmented run metadata"), + }); + } + let sample_description_index = if tfhd.flags() & TFHD_SAMPLE_DESCRIPTION_INDEX_PRESENT != 0 { + tfhd.sample_description_index + } else { + context + .trex + .map(|trex| trex.default_sample_description_index) + .unwrap_or(1) + }; + if sample_description_index == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} uses fragmented sample description index 0"), + }); + } + + let base_data_offset = if tfhd.flags() & TFHD_BASE_DATA_OFFSET_PRESENT != 0 { + tfhd.base_data_offset + } else { + context.moof_offset + }; + let mut next_offset = None::; + + for (trun, trun_info) in truns.iter().zip(trun_infos.iter()) { + let sample_count = usize::try_from(trun.sample_count).map_err(|_| { + MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} exposes a fragmented run whose sample count does not fit in usize" + ), + } + })?; + validate_fragment_trun_layout(path, track_id, trun, trun_info, sample_count)?; + + let mut current_offset = if trun.flags() & TRUN_DATA_OFFSET_PRESENT != 0 { + let absolute = i128::from(base_data_offset) + i128::from(trun.data_offset); + if absolute < 0 || absolute > i128::from(u64::MAX) { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} computed an invalid fragmented data offset at trun {}", + trun_info.offset() + ), + }); + } + absolute as u64 + } else if let Some(next_offset) = next_offset { + next_offset + } else if tfhd.flags() & TFHD_DEFAULT_BASE_IS_MOOF != 0 { + context.moof_offset + } else { + base_data_offset + }; + + for sample_index in 0..sample_count { + let sample_size = effective_fragment_sample_size( + path, + track_id, + tfhd, + context.trex, + trun, + trun_info, + sample_index, + )?; + let sample_duration = effective_fragment_sample_duration( + path, + track_id, + tfhd, + context.trex, + trun, + trun_info, + sample_index, + )?; + let sample_flags = + effective_fragment_sample_flags(tfhd, context.trex, trun, sample_index) + .unwrap_or(0); + let composition_time_offset = if trun.flags() + & TRUN_SAMPLE_COMPOSITION_TIME_OFFSET_PRESENT + != 0 + { + let offset = trun.sample_composition_time_offset(sample_index); + i32::try_from(offset).map_err(|_| MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} fragmented run at {} exposes composition offset {} that does not fit in i32", + trun_info.offset(), + offset + ), + })? + } else { + 0 + }; + + output.push(CandidateSample { + source_index: context.source_index, + data_offset: current_offset, + data_size: sample_size, + duration: sample_duration, + composition_time_offset, + is_sync_sample: sample_flags & NON_KEY_SAMPLE_FLAGS == 0, + }); + sample_description_indices.push(sample_description_index); + current_offset = current_offset + .checked_add(u64::from(sample_size)) + .ok_or(MuxError::LayoutOverflow("fragmented sample offset"))?; + } + next_offset = Some(current_offset); + } + + Ok(()) +} + +fn validate_fragment_trun_layout( + path: &Path, + track_id: u32, + trun: &Trun, + trun_info: &HeaderInfo, + sample_count: usize, +) -> Result<(), MuxError> { + let per_sample_fields_present = trun.flags() + & (TRUN_SAMPLE_DURATION_PRESENT + | TRUN_SAMPLE_SIZE_PRESENT + | TRUN_SAMPLE_FLAGS_PRESENT + | TRUN_SAMPLE_COMPOSITION_TIME_OFFSET_PRESENT) + != 0; + if per_sample_fields_present && trun.entries.len() != sample_count { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} fragmented run at {} declares {} samples but carries {} entries", + trun_info.offset(), + trun.sample_count, + trun.entries.len() + ), + }); + } + if !per_sample_fields_present && !trun.entries.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} fragmented run at {} carries unexpected inline sample entries", + trun_info.offset() + ), + }); + } + Ok(()) +} + +fn effective_fragment_sample_size( + path: &Path, + track_id: u32, + tfhd: &Tfhd, + trex: Option<&Trex>, + trun: &Trun, + trun_info: &HeaderInfo, + sample_index: usize, +) -> Result { + if trun.flags() & TRUN_SAMPLE_SIZE_PRESENT != 0 { + return trun + .entries + .get(sample_index) + .map(|entry| entry.sample_size) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} fragmented run at {} is missing sample size entry {}", + trun_info.offset(), + sample_index + 1 + ), + }); + } + if tfhd.flags() & TFHD_DEFAULT_SAMPLE_SIZE_PRESENT != 0 { + return Ok(tfhd.default_sample_size); + } + if let Some(trex) = trex { + return Ok(trex.default_sample_size); + } + Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} requires fragmented sample-size defaults from tfhd or trex" + ), + }) +} + +fn effective_fragment_sample_duration( + path: &Path, + track_id: u32, + tfhd: &Tfhd, + trex: Option<&Trex>, + trun: &Trun, + trun_info: &HeaderInfo, + sample_index: usize, +) -> Result { + if trun.flags() & TRUN_SAMPLE_DURATION_PRESENT != 0 { + return trun + .entries + .get(sample_index) + .map(|entry| entry.sample_duration) + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} fragmented run at {} is missing sample duration entry {}", + trun_info.offset(), + sample_index + 1 + ), + }); + } + if tfhd.flags() & TFHD_DEFAULT_SAMPLE_DURATION_PRESENT != 0 { + return Ok(tfhd.default_sample_duration); + } + if let Some(trex) = trex { + return Ok(trex.default_sample_duration); + } + Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} requires fragmented sample-duration defaults from tfhd or trex" + ), + }) +} + +fn effective_fragment_sample_flags( + tfhd: &Tfhd, + trex: Option<&Trex>, + trun: &Trun, + sample_index: usize, +) -> Option { + if trun.flags() & TRUN_SAMPLE_FLAGS_PRESENT != 0 { + return trun + .entries + .get(sample_index) + .map(|entry| entry.sample_flags); + } + if sample_index == 0 && trun.flags() & TRUN_FIRST_SAMPLE_FLAGS_PRESENT != 0 { + return Some(trun.first_sample_flags); + } + if tfhd.flags() & TFHD_DEFAULT_SAMPLE_FLAGS_PRESENT != 0 { + return Some(tfhd.default_sample_flags); + } + trex.map(|trex| trex.default_sample_flags) +} + +fn select_mp4_track( + tracks: &[TrackCandidate], + selector: MuxMp4TrackSelector, + spec: String, + preserve_track_id: bool, +) -> Result { + let selected = match selector { + MuxMp4TrackSelector::Video => tracks.iter().find(|track| track.kind.is_video()), + MuxMp4TrackSelector::Audio { occurrence } => tracks + .iter() + .filter(|track| track.kind.is_audio()) + .nth(usize::try_from(occurrence.saturating_sub(1)).unwrap_or(usize::MAX)), + MuxMp4TrackSelector::Text { occurrence } => tracks + .iter() + .filter(|track| track.kind.is_textual()) + .nth(usize::try_from(occurrence.saturating_sub(1)).unwrap_or(usize::MAX)), + MuxMp4TrackSelector::TrackId { track_id } => { + tracks.iter().find(|track| track.track_id == track_id) + } + } + .ok_or_else(|| MuxError::MissingTrackSelection { spec: spec.clone() })?; + + let is_selected_container_mpeg_video = + preserve_track_id && sample_entry_is_mpeg_video(&selected.sample_entry_box); + let mux_policy = if is_selected_container_mpeg_video { + selected.mux_policy.with_terminal_flat_video_chunk_split() + } else { + selected.mux_policy + }; + let sample_entry_box = if preserve_track_id { + normalize_selected_container_sample_entry_box(&selected.sample_entry_box)? + } else { + selected.sample_entry_box.clone() + }; + + Ok(ImportedTrack { + kind: selected.kind, + timescale: selected.timescale, + language: selected.language, + handler_name: selected.handler_name.clone(), + mux_policy, + width: selected.width, + height: selected.height, + sample_entry_box, + source_edit_media_time: selected.source_edit_media_time, + sample_roll_distance: selected.mux_policy.sample_roll_distance(), + samples: selected + .samples + .iter() + .map(|sample| ImportedSample { + source_index: 0, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect(), + } + .with_source_index_from_candidate(selected, preserve_track_id)) +} + +fn normalize_selected_container_sample_entry_box( + sample_entry_box: &[u8], +) -> Result, MuxError> { + if !sample_entry_is_mpeg_video(sample_entry_box) { + return Ok(sample_entry_box.to_vec()); + } + + let child_boxes = super::mp4::visual_sample_entry_immediate_children(sample_entry_box)?; + let mut retained_children = Vec::with_capacity(child_boxes.len()); + let mut stripped_square_pasp = false; + for child_box in child_boxes { + if sample_entry_box_type(&child_box) == Some(FourCc::from_bytes(*b"pasp")) { + let pasp = super::mp4::decode_typed_box::(&child_box)?; + if pasp.h_spacing == 1 && pasp.v_spacing == 1 { + stripped_square_pasp = true; + continue; + } + } + retained_children.push(child_box); + } + + if stripped_square_pasp { + super::mp4::replace_visual_sample_entry_immediate_children( + sample_entry_box, + &retained_children, + ) + } else { + Ok(sample_entry_box.to_vec()) + } +} + +fn sample_entry_is_mpeg_video(sample_entry_box: &[u8]) -> bool { + sample_entry_box_type(sample_entry_box) == Some(FourCc::from_bytes(*b"mp4v")) + && [0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x6A] + .iter() + .any(|object_type| sample_entry_carries_oti(sample_entry_box, *object_type)) +} + +fn select_container_tracks( + tracks: &[TrackCandidate], + selector: Option, + spec: String, + preserve_track_id: bool, +) -> Result, MuxError> { + match selector { + Some(selector) => Ok(vec![select_mp4_track( + tracks, + selector, + spec, + preserve_track_id, + )?]), + None => { + let selected = tracks + .iter() + .filter(|track| { + matches!( + track.kind, + MuxTrackKind::Video + | MuxTrackKind::Audio + | MuxTrackKind::Text + | MuxTrackKind::Subtitle + ) + }) + .map(|track| { + ImportedTrack { + kind: track.kind, + timescale: track.timescale, + language: track.language, + handler_name: track.handler_name.clone(), + mux_policy: track.mux_policy, + width: track.width, + height: track.height, + sample_entry_box: track.sample_entry_box.clone(), + source_edit_media_time: track.source_edit_media_time, + sample_roll_distance: track.mux_policy.sample_roll_distance(), + samples: track + .samples + .iter() + .map(|sample| ImportedSample { + source_index: sample.source_index, + data_offset: sample.data_offset, + data_size: sample.data_size, + duration: sample.duration, + composition_time_offset: sample.composition_time_offset, + is_sync_sample: sample.is_sync_sample, + }) + .collect(), + } + .with_source_index_from_candidate(track, preserve_track_id) + }) + .collect::>(); + if selected.is_empty() { + return Err(MuxError::MissingTrackSelection { spec }); + } + Ok(selected) + } + } +} + +trait ImportedTrackExt { + fn with_source_index_from_candidate( + self, + candidate: &TrackCandidate, + preserve_track_id: bool, + ) -> Self; +} + +impl ImportedTrackExt for ImportedTrack { + fn with_source_index_from_candidate( + mut self, + candidate: &TrackCandidate, + preserve_track_id: bool, + ) -> Self { + if preserve_track_id { + self.mux_policy = self.mux_policy.with_preferred_track_id(candidate.track_id); + } + for (sample, source) in self.samples.iter_mut().zip(candidate.samples.iter()) { + sample.source_index = source.source_index; + } + self + } +} + +fn parse_track_candidate_sync( + path: &Path, + source_index: usize, + fragmented_hint: bool, + source_movie_timescale: u32, + source_file_size: u64, + reader: &mut R, + trak_info: &HeaderInfo, +) -> Result, MuxError> +where + R: Read + Seek, +{ + let components = + extract_track_candidate_components_sync(path, fragmented_hint, reader, trak_info)?; + finish_parsed_track_candidate_sync( + path, + source_index, + fragmented_hint, + source_movie_timescale, + source_file_size, + reader, + components, + ) +} + +#[cfg(feature = "async")] +async fn parse_track_candidate_async( + path: &Path, + source_index: usize, + fragmented_hint: bool, + source_movie_timescale: u32, + source_file_size: u64, + reader: &mut R, + trak_info: &HeaderInfo, +) -> Result, MuxError> +where + R: AsyncReadSeek, +{ + let components = + extract_track_candidate_components_async(path, fragmented_hint, reader, trak_info).await?; + finish_parsed_track_candidate_async( + path, + source_index, + fragmented_hint, + source_movie_timescale, + source_file_size, + reader, + components, + ) + .await +} + +fn extract_track_candidate_components_sync( + path: &Path, + fragmented_hint: bool, + reader: &mut R, + trak_info: &HeaderInfo, +) -> Result +where + R: Read + Seek, +{ + let tkhd = extract_required_single_as_sync::<_, Tkhd>( + reader, + trak_info, + BoxPath::from([TKHD]), + "tkhd", + )?; + let mdhd = extract_required_single_as_sync::<_, Mdhd>( + reader, + trak_info, + BoxPath::from([MDIA, MDHD]), + "mdhd", + )?; + let hdlr = + extract_optional_single_as_sync::<_, Hdlr>(reader, trak_info, BoxPath::from([MDIA, HDLR]))?; + let stsd_info = extract_required_single_info_sync( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, STSD]), + "stsd", + )?; + let stbl_info = extract_required_single_info_sync( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL]), + "stbl", + )?; + let stsd = extract_required_single_as_sync::<_, crate::boxes::iso14496_12::Stsd>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, STSD]), + "stsd", + )?; + let (mut sample_entries, sample_entry_boxes) = + extract_stsd_sample_entries_sync(path, reader, &stsd_info, &stsd, tkhd.track_id)?; + let data_references = extract_data_references_sync(path, reader, trak_info, tkhd.track_id)?; + let sample_entry = sample_entries.remove(0); + let sample_entry_box = + sample_entry_boxes + .first() + .cloned() + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {} is missing a sample-entry payload", tkhd.track_id), + })?; + let elst = + extract_optional_single_as_sync::<_, Elst>(reader, trak_info, BoxPath::from([EDTS, ELST]))?; + let elst_box_size = extract_box(reader, Some(trak_info), BoxPath::from([EDTS, ELST]))? + .into_iter() + .next() + .map(|info| info.size()); + let sgpd = extract_optional_single_as_sync::<_, Sgpd>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, FourCc::from_bytes(*b"sgpd")]), + )?; + let sbgp = extract_optional_single_as_sync::<_, Sbgp>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, FourCc::from_bytes(*b"sbgp")]), + )?; + let preserved_flat_stbl_boxes = extract_preserved_flat_stbl_boxes_sync(reader, &stbl_info)?; + let mut preserved_flat_trak_boxes = + extract_box_bytes(reader, Some(trak_info), BoxPath::from([EDTS]))?; + preserved_flat_trak_boxes.extend(extract_box_bytes( + reader, + Some(trak_info), + BoxPath::from([TREF]), + )?); + preserved_flat_trak_boxes.extend(extract_box_bytes( + reader, + Some(trak_info), + BoxPath::from([UDTA]), + )?); + + let (stts, ctts, stsc, sample_sizes, stco, co64, stss) = if fragmented_hint { + (None, None, None, None, None, None, None) + } else { + let stsz = extract_optional_single_as_sync::<_, Stsz>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, STSZ]), + )?; + let stz2_boxes = extract_box_bytes(reader, Some(&stbl_info), BoxPath::from([STZ2]))?; + let sample_sizes = match (stsz, stz2_boxes.as_slice()) { + (Some(stsz), []) => expand_sample_sizes(&stsz, path, tkhd.track_id)?, + (None, [stz2_bytes]) => parse_compact_sample_sizes(stz2_bytes, path, tkhd.track_id)?, + (Some(_), [_]) => { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {} exposes both stsz and stz2 sample size tables", + tkhd.track_id + ), + }); + } + (Some(_), stz2_boxes) => { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {} exposes stsz plus {} compact sample size tables", + tkhd.track_id, + stz2_boxes.len() + ), + }); + } + (None, []) => { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {} is missing a sample size table", tkhd.track_id), + }); + } + (None, stz2_boxes) => { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {} exposes {} compact sample size tables", + tkhd.track_id, + stz2_boxes.len() + ), + }); + } + }; + ( + Some(extract_required_single_as_sync::<_, Stts>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, STTS]), + "stts", + )?), + extract_optional_single_as_sync::<_, Ctts>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, CTTS]), + )?, + Some(extract_required_single_as_sync::<_, Stsc>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, STSC]), + "stsc", + )?), + Some(sample_sizes), + extract_optional_single_as_sync::<_, Stco>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, STCO]), + )?, + extract_optional_single_as_sync::<_, Co64>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, CO64]), + )?, + extract_optional_single_as_sync::<_, Stss>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, STSS]), + )?, + ) + }; + + Ok(ParsedTrackCandidateComponents { + tkhd, + mdhd, + hdlr, + sample_entry, + sample_entry_box, + sample_entry_boxes, + data_references, + elst, + elst_box_size, + sample_roll_distance: extracted_sample_roll_distance(sgpd.as_ref()), + emit_roll_sbgp: extracted_roll_sbgp_present(sbgp.as_ref()), + preserved_flat_stbl_boxes, + preserved_flat_trak_boxes, + stts, + ctts, + stsc, + sample_sizes, + stco, + co64, + stss, + }) +} + +#[cfg(feature = "async")] +async fn extract_track_candidate_components_async( + path: &Path, + fragmented_hint: bool, + reader: &mut R, + trak_info: &HeaderInfo, +) -> Result +where + R: AsyncReadSeek, +{ + let tkhd = extract_required_single_as_async::<_, Tkhd>( + reader, + trak_info, + BoxPath::from([TKHD]), + "tkhd", + ) + .await?; + let mdhd = extract_required_single_as_async::<_, Mdhd>( + reader, + trak_info, + BoxPath::from([MDIA, MDHD]), + "mdhd", + ) + .await?; + let hdlr = + extract_optional_single_as_async::<_, Hdlr>(reader, trak_info, BoxPath::from([MDIA, HDLR])) + .await?; + let stsd_info = extract_required_single_info_async( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, STSD]), + "stsd", + ) + .await?; + let stbl_info = extract_required_single_info_async( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL]), + "stbl", + ) + .await?; + let stsd = extract_required_single_as_async::<_, crate::boxes::iso14496_12::Stsd>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, STSD]), + "stsd", + ) + .await?; + let (mut sample_entries, sample_entry_boxes) = + extract_stsd_sample_entries_async(path, reader, &stsd_info, &stsd, tkhd.track_id).await?; + let data_references = + extract_data_references_async(path, reader, trak_info, tkhd.track_id).await?; + let sample_entry = sample_entries.remove(0); + let sample_entry_box = + sample_entry_boxes + .first() + .cloned() + .ok_or_else(|| MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {} is missing a sample-entry payload", tkhd.track_id), + })?; + let elst = + extract_optional_single_as_async::<_, Elst>(reader, trak_info, BoxPath::from([EDTS, ELST])) + .await?; + let elst_box_size = extract_box_async(reader, Some(trak_info), BoxPath::from([EDTS, ELST])) + .await? + .into_iter() + .next() + .map(|info| info.size()); + let sgpd = extract_optional_single_as_async::<_, Sgpd>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, FourCc::from_bytes(*b"sgpd")]), + ) + .await?; + let sbgp = extract_optional_single_as_async::<_, Sbgp>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, FourCc::from_bytes(*b"sbgp")]), + ) + .await?; + let preserved_flat_stbl_boxes = + extract_preserved_flat_stbl_boxes_async(reader, &stbl_info).await?; + let mut preserved_flat_trak_boxes = + extract_box_bytes_async(reader, Some(trak_info), BoxPath::from([EDTS])).await?; + preserved_flat_trak_boxes + .extend(extract_box_bytes_async(reader, Some(trak_info), BoxPath::from([TREF])).await?); + preserved_flat_trak_boxes + .extend(extract_box_bytes_async(reader, Some(trak_info), BoxPath::from([UDTA])).await?); + + let (stts, ctts, stsc, sample_sizes, stco, co64, stss) = if fragmented_hint { + (None, None, None, None, None, None, None) + } else { + let stsz = extract_optional_single_as_async::<_, Stsz>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, STSZ]), + ) + .await?; + let stz2_boxes = + extract_box_bytes_async(reader, Some(&stbl_info), BoxPath::from([STZ2])).await?; + let sample_sizes = match (stsz, stz2_boxes.as_slice()) { + (Some(stsz), []) => expand_sample_sizes(&stsz, path, tkhd.track_id)?, + (None, [stz2_bytes]) => parse_compact_sample_sizes(stz2_bytes, path, tkhd.track_id)?, + (Some(_), [_]) => { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {} exposes both stsz and stz2 sample size tables", + tkhd.track_id + ), + }); + } + (Some(_), stz2_boxes) => { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {} exposes stsz plus {} compact sample size tables", + tkhd.track_id, + stz2_boxes.len() + ), + }); + } + (None, []) => { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {} is missing a sample size table", tkhd.track_id), + }); + } + (None, stz2_boxes) => { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {} exposes {} compact sample size tables", + tkhd.track_id, + stz2_boxes.len() + ), + }); + } + }; + ( + Some( + extract_required_single_as_async::<_, Stts>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, STTS]), + "stts", + ) + .await?, + ), + extract_optional_single_as_async::<_, Ctts>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, CTTS]), + ) + .await?, + Some( + extract_required_single_as_async::<_, Stsc>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, STSC]), + "stsc", + ) + .await?, + ), + Some(sample_sizes), + extract_optional_single_as_async::<_, Stco>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, STCO]), + ) + .await?, + extract_optional_single_as_async::<_, Co64>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, CO64]), + ) + .await?, + extract_optional_single_as_async::<_, Stss>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, STBL, STSS]), + ) + .await?, + ) + }; + + Ok(ParsedTrackCandidateComponents { + tkhd, + mdhd, + hdlr, + sample_entry, + sample_entry_box, + sample_entry_boxes, + data_references, + elst, + elst_box_size, + sample_roll_distance: extracted_sample_roll_distance(sgpd.as_ref()), + emit_roll_sbgp: extracted_roll_sbgp_present(sbgp.as_ref()), + preserved_flat_stbl_boxes, + preserved_flat_trak_boxes, + stts, + ctts, + stsc, + sample_sizes, + stco, + co64, + stss, + }) +} + +fn finish_parsed_track_candidate_sync( + path: &Path, + source_index: usize, + fragmented_hint: bool, + source_movie_timescale: u32, + source_file_size: u64, + reader: &mut R, + components: ParsedTrackCandidateComponents, +) -> Result, MuxError> +where + R: Read + Seek, +{ + if fragmented_hint { + return build_track_candidate_from_components( + path, + components.tkhd, + components.mdhd, + components.hdlr, + &components.sample_entry, + components.sample_entry_box, + components.sample_entry_boxes, + components.elst, + false, + source_movie_timescale, + components.sample_roll_distance, + components.emit_roll_sbgp, + false, + true, + None, + None, + None, + None, + components.preserved_flat_stbl_boxes, + components.preserved_flat_trak_boxes, + Vec::new(), + ); + } + + let track_id = components.tkhd.track_id; + parse_track_candidate_from_components( + path, + reader, + source_index, + components.tkhd, + components.mdhd, + components.hdlr, + &components.sample_entry, + components.sample_entry_box, + components.sample_entry_boxes, + components.data_references, + components.stts.ok_or(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} is missing stts timing entries"), + })?, + components.ctts, + components.elst, + components.elst_box_size, + source_movie_timescale, + source_file_size, + components.sample_roll_distance, + components.emit_roll_sbgp, + components.stsc.ok_or(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} is missing stsc chunk entries"), + })?, + components + .sample_sizes + .ok_or(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} is missing sample sizes"), + })?, + components.stco, + components.co64, + components.stss, + components.preserved_flat_stbl_boxes, + components.preserved_flat_trak_boxes, + ) +} + +#[cfg(feature = "async")] +async fn finish_parsed_track_candidate_async( + path: &Path, + source_index: usize, + fragmented_hint: bool, + source_movie_timescale: u32, + source_file_size: u64, + reader: &mut R, + components: ParsedTrackCandidateComponents, +) -> Result, MuxError> +where + R: AsyncReadSeek, +{ + if fragmented_hint { + return build_track_candidate_from_components( + path, + components.tkhd, + components.mdhd, + components.hdlr, + &components.sample_entry, + components.sample_entry_box, + components.sample_entry_boxes, + components.elst, + false, + source_movie_timescale, + components.sample_roll_distance, + components.emit_roll_sbgp, + false, + true, + None, + None, + None, + None, + components.preserved_flat_stbl_boxes, + components.preserved_flat_trak_boxes, + Vec::new(), + ); + } + + let track_id = components.tkhd.track_id; + parse_track_candidate_from_components_async( + path, + reader, + source_index, + components.tkhd, + components.mdhd, + components.hdlr, + &components.sample_entry, + components.sample_entry_box, + components.sample_entry_boxes, + components.data_references, + components.stts.ok_or(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} is missing stts timing entries"), + })?, + components.ctts, + components.elst, + components.elst_box_size, + source_movie_timescale, + source_file_size, + components.sample_roll_distance, + components.emit_roll_sbgp, + components.stsc.ok_or(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} is missing stsc chunk entries"), + })?, + components + .sample_sizes + .ok_or(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} is missing sample sizes"), + })?, + components.stco, + components.co64, + components.stss, + components.preserved_flat_stbl_boxes, + components.preserved_flat_trak_boxes, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +fn parse_track_candidate_from_components( + path: &Path, + reader: &mut R, + source_index: usize, + tkhd: Tkhd, + mdhd: Mdhd, + hdlr: Option, + sample_entry: &ExtractedBox, + sample_entry_box: Vec, + sample_entry_boxes: Vec>, + data_references: Vec, + stts: Stts, + ctts: Option, + elst: Option, + elst_box_size: Option, + source_movie_timescale: u32, + source_file_size: u64, + sample_roll_distance: Option, + emit_roll_sbgp: bool, + stsc: Stsc, + mut sample_sizes: Vec, + stco: Option, + co64: Option, + stss: Option, + preserved_flat_stbl_boxes: Vec>, + preserved_flat_trak_boxes: Vec>, +) -> Result, MuxError> +where + R: Read + Seek, +{ + let sample_entry_type = sample_entry.info.box_type(); + let mut sample_durations = + expand_sample_durations(&stts, sample_sizes.len(), path, tkhd.track_id)?; + let mut composition_offsets = + expand_composition_offsets(ctts.as_ref(), sample_sizes.len(), path, tkhd.track_id)?; + let chunk_offsets = select_chunk_offsets(stco.as_ref(), co64.as_ref(), path, tkhd.track_id)?; + let mut flat_chunk_sample_counts = + expand_chunk_sample_counts(&stsc, chunk_offsets.len(), path, tkhd.track_id)?; + let (mut sample_offsets, mut sample_description_indices) = + expand_sample_offsets_and_description_indices( + &stsc, + &sample_sizes, + &chunk_offsets, + path, + tkhd.track_id, + )?; + let mut sync_samples = expand_sync_samples( + stss.as_ref(), + sample_entry_type, + sample_sizes.len(), + path, + tkhd.track_id, + )?; + let mut source_sync_samples = stss.as_ref().map(|_| sync_samples.clone()); + let sample_data_references = sample_data_references_for_description_indices( + path, + tkhd.track_id, + &sample_entry_boxes, + &data_references, + &sample_description_indices, + )?; + let uses_external_data_reference = sample_data_references + .iter() + .any(|reference| matches!(reference, ImportedDataReference::LocalFile(_))); + + if uses_external_data_reference { + validate_imported_sample_data_references( + path, + tkhd.track_id, + &sample_offsets, + &sample_sizes, + &sample_data_references, + source_file_size, + )?; + } else { + let available_sample_count = imported_sample_prefix_len_within_source_file( + &sample_offsets, + &sample_sizes, + source_file_size, + ); + if available_sample_count < sample_sizes.len() { + sample_sizes.truncate(available_sample_count); + sample_durations.truncate(available_sample_count); + composition_offsets.truncate(available_sample_count); + sample_offsets.truncate(available_sample_count); + sample_description_indices.truncate(available_sample_count); + sync_samples.truncate(available_sample_count); + if let Some(source_sync_samples) = source_sync_samples.as_mut() { + source_sync_samples.truncate(available_sample_count); + } + trim_flat_chunk_sample_counts_to_sample_count( + &mut flat_chunk_sample_counts, + available_sample_count, + )?; + } + + if should_drop_truncated_terminal_imported_sample( + sample_offsets.last().copied(), + sample_sizes.last().copied(), + source_file_size, + ) { + sample_sizes.pop(); + sample_durations.pop(); + composition_offsets.pop(); + sample_offsets.pop(); + sample_description_indices.pop(); + sync_samples.pop(); + if let Some(source_sync_samples) = source_sync_samples.as_mut() { + source_sync_samples.pop(); + } + if let Some(last_chunk_sample_count) = flat_chunk_sample_counts.last_mut() { + if *last_chunk_sample_count > 1 { + *last_chunk_sample_count -= 1; + } else { + flat_chunk_sample_counts.pop(); + } + } + } + } + if !uses_external_data_reference { + supplement_imported_mp4_avc_sync_samples_sync( + reader, + sample_entry_type, + &sample_entry_box, + stss.as_ref(), + &sample_offsets, + &sample_sizes, + &mut sync_samples, + )?; + supplement_imported_mp4_hevc_sync_samples_sync( + reader, + sample_entry_type, + &sample_entry_box, + &sample_offsets, + &sample_sizes, + &mut sync_samples, + )?; + } + let synthesized_speex_elst_tail = synthesize_imported_speex_elst_tail_sync( + reader, + sample_entry, + elst.as_ref(), + elst_box_size, + &mut sample_offsets, + &mut sample_sizes, + &mut sample_durations, + &mut composition_offsets, + &mut sample_description_indices, + &mut sync_samples, + )?; + let sample_data_references = sample_data_references_for_description_indices( + path, + tkhd.track_id, + &sample_entry_boxes, + &data_references, + &sample_description_indices, + )?; + let resolved_sample_offsets = resolved_sample_logical_offsets( + &sample_offsets, + &sample_sizes, + &sample_data_references, + source_file_size, + )?; + let source_segments = sample_source_segments( + path, + tkhd.track_id, + &resolved_sample_offsets, + &sample_offsets, + &sample_sizes, + &sample_data_references, + )?; + + let mut samples = Vec::with_capacity(sample_sizes.len()); + for index in 0..sample_sizes.len() { + samples.push(CandidateSample { + source_index, + data_offset: resolved_sample_offsets[index], + data_size: sample_sizes[index], + duration: sample_durations[index], + composition_time_offset: composition_offsets[index], + is_sync_sample: sync_samples[index], + }); + } + + let mut parsed = build_track_candidate_from_components( + path, + tkhd, + mdhd, + hdlr, + sample_entry, + sample_entry_box, + sample_entry_boxes, + elst, + synthesized_speex_elst_tail, + source_movie_timescale, + sample_roll_distance, + emit_roll_sbgp, + stss.as_ref() + .is_some_and(|stss| stss.entry_count == 1 && stss.sample_number.as_slice() == [1]), + stts.entry_count == 0, + Some(flat_chunk_sample_counts), + Some(stsc), + Some(sample_description_indices), + source_sync_samples, + preserved_flat_stbl_boxes, + preserved_flat_trak_boxes, + samples, + )?; + if let Some(parsed) = parsed.as_mut() { + parsed.source_segments = source_segments; + parsed.uses_external_data_reference = uses_external_data_reference; + } + Ok(parsed) +} + +#[cfg(feature = "async")] +#[allow(clippy::too_many_arguments)] +async fn parse_track_candidate_from_components_async( + path: &Path, + reader: &mut R, + source_index: usize, + tkhd: Tkhd, + mdhd: Mdhd, + hdlr: Option, + sample_entry: &ExtractedBox, + sample_entry_box: Vec, + sample_entry_boxes: Vec>, + data_references: Vec, + stts: Stts, + ctts: Option, + elst: Option, + elst_box_size: Option, + source_movie_timescale: u32, + source_file_size: u64, + sample_roll_distance: Option, + emit_roll_sbgp: bool, + stsc: Stsc, + mut sample_sizes: Vec, + stco: Option, + co64: Option, + stss: Option, + preserved_flat_stbl_boxes: Vec>, + preserved_flat_trak_boxes: Vec>, +) -> Result, MuxError> +where + R: AsyncReadSeek, +{ + let sample_entry_type = sample_entry.info.box_type(); + let mut sample_durations = + expand_sample_durations(&stts, sample_sizes.len(), path, tkhd.track_id)?; + let mut composition_offsets = + expand_composition_offsets(ctts.as_ref(), sample_sizes.len(), path, tkhd.track_id)?; + let chunk_offsets = select_chunk_offsets(stco.as_ref(), co64.as_ref(), path, tkhd.track_id)?; + let mut flat_chunk_sample_counts = + expand_chunk_sample_counts(&stsc, chunk_offsets.len(), path, tkhd.track_id)?; + let (mut sample_offsets, mut sample_description_indices) = + expand_sample_offsets_and_description_indices( + &stsc, + &sample_sizes, + &chunk_offsets, + path, + tkhd.track_id, + )?; + let mut sync_samples = expand_sync_samples( + stss.as_ref(), + sample_entry_type, + sample_sizes.len(), + path, + tkhd.track_id, + )?; + let mut source_sync_samples = stss.as_ref().map(|_| sync_samples.clone()); + let sample_data_references = sample_data_references_for_description_indices( + path, + tkhd.track_id, + &sample_entry_boxes, + &data_references, + &sample_description_indices, + )?; + let uses_external_data_reference = sample_data_references + .iter() + .any(|reference| matches!(reference, ImportedDataReference::LocalFile(_))); + + if uses_external_data_reference { + validate_imported_sample_data_references_async( + path, + tkhd.track_id, + &sample_offsets, + &sample_sizes, + &sample_data_references, + source_file_size, + ) + .await?; + } else { + let available_sample_count = imported_sample_prefix_len_within_source_file( + &sample_offsets, + &sample_sizes, + source_file_size, + ); + if available_sample_count < sample_sizes.len() { + sample_sizes.truncate(available_sample_count); + sample_durations.truncate(available_sample_count); + composition_offsets.truncate(available_sample_count); + sample_offsets.truncate(available_sample_count); + sample_description_indices.truncate(available_sample_count); + sync_samples.truncate(available_sample_count); + if let Some(source_sync_samples) = source_sync_samples.as_mut() { + source_sync_samples.truncate(available_sample_count); + } + trim_flat_chunk_sample_counts_to_sample_count( + &mut flat_chunk_sample_counts, + available_sample_count, + )?; + } + + if should_drop_truncated_terminal_imported_sample( + sample_offsets.last().copied(), + sample_sizes.last().copied(), + source_file_size, + ) { + sample_sizes.pop(); + sample_durations.pop(); + composition_offsets.pop(); + sample_offsets.pop(); + sample_description_indices.pop(); + sync_samples.pop(); + if let Some(source_sync_samples) = source_sync_samples.as_mut() { + source_sync_samples.pop(); + } + if let Some(last_chunk_sample_count) = flat_chunk_sample_counts.last_mut() { + if *last_chunk_sample_count > 1 { + *last_chunk_sample_count -= 1; + } else { + flat_chunk_sample_counts.pop(); + } + } + } + } + if !uses_external_data_reference { + supplement_imported_mp4_avc_sync_samples_async( + reader, + sample_entry_type, + &sample_entry_box, + stss.as_ref(), + &sample_offsets, + &sample_sizes, + &mut sync_samples, + ) + .await?; + supplement_imported_mp4_hevc_sync_samples_async( + reader, + sample_entry_type, + &sample_entry_box, + &sample_offsets, + &sample_sizes, + &mut sync_samples, + ) + .await?; + } + let synthesized_speex_elst_tail = synthesize_imported_speex_elst_tail_async( + reader, + sample_entry, + elst.as_ref(), + elst_box_size, + &mut sample_offsets, + &mut sample_sizes, + &mut sample_durations, + &mut composition_offsets, + &mut sample_description_indices, + &mut sync_samples, + ) + .await?; + let sample_data_references = sample_data_references_for_description_indices( + path, + tkhd.track_id, + &sample_entry_boxes, + &data_references, + &sample_description_indices, + )?; + let resolved_sample_offsets = resolved_sample_logical_offsets( + &sample_offsets, + &sample_sizes, + &sample_data_references, + source_file_size, + )?; + let source_segments = sample_source_segments( + path, + tkhd.track_id, + &resolved_sample_offsets, + &sample_offsets, + &sample_sizes, + &sample_data_references, + )?; + + let mut samples = Vec::with_capacity(sample_sizes.len()); + for index in 0..sample_sizes.len() { + samples.push(CandidateSample { + source_index, + data_offset: resolved_sample_offsets[index], + data_size: sample_sizes[index], + duration: sample_durations[index], + composition_time_offset: composition_offsets[index], + is_sync_sample: sync_samples[index], + }); + } + + let mut parsed = build_track_candidate_from_components( + path, + tkhd, + mdhd, + hdlr, + sample_entry, + sample_entry_box, + sample_entry_boxes, + elst, + synthesized_speex_elst_tail, + source_movie_timescale, + sample_roll_distance, + emit_roll_sbgp, + stss.as_ref() + .is_some_and(|stss| stss.entry_count == 1 && stss.sample_number.as_slice() == [1]), + stts.entry_count == 0, + Some(flat_chunk_sample_counts), + Some(stsc), + Some(sample_description_indices), + source_sync_samples, + preserved_flat_stbl_boxes, + preserved_flat_trak_boxes, + samples, + )?; + if let Some(parsed) = parsed.as_mut() { + parsed.source_segments = source_segments; + parsed.uses_external_data_reference = uses_external_data_reference; + } + Ok(parsed) +} + +#[allow(clippy::too_many_arguments)] +fn build_track_candidate_from_components( + path: &Path, + tkhd: Tkhd, + mdhd: Mdhd, + hdlr: Option, + sample_entry: &ExtractedBox, + sample_entry_box: Vec, + sample_entry_boxes: Vec>, + elst: Option, + synthesized_speex_elst_tail: bool, + source_movie_timescale: u32, + sample_roll_distance: Option, + emit_roll_sbgp: bool, + source_stss_first_only: bool, + source_had_empty_stts: bool, + flat_chunk_sample_counts: Option>, + flat_stsc: Option, + sample_description_indices: Option>, + source_sync_samples: Option>, + preserved_flat_stbl_boxes: Vec>, + preserved_flat_trak_boxes: Vec>, + samples: Vec, +) -> Result, MuxError> { + let sample_entry_type = sample_entry.info.box_type(); + let kind = if let Some(hdlr) = hdlr.as_ref() { + match hdlr.handler_type { + VIDE => MuxTrackKind::Video, + SOUN => MuxTrackKind::Audio, + TEXT => MuxTrackKind::Text, + SUBT | SUBP => MuxTrackKind::Subtitle, + _ => return Ok(None), + } + } else { + let Some(kind) = infer_track_kind_from_sample_entry_type(sample_entry_type) else { + return Ok(None); + }; + kind + }; + if matches!(sample_entry_type, ENCV | ENCA) { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {} uses protected sample entry `{sample_entry_type}`; decrypt before muxing", + tkhd.track_id + ), + }); + } + + let (width, height) = match kind { + MuxTrackKind::Audio => (0, 0), + MuxTrackKind::Video | MuxTrackKind::Text | MuxTrackKind::Subtitle => ( + fixed_16_16_to_u16(tkhd.width), + fixed_16_16_to_u16(tkhd.height), + ), + }; + let language = decode_mdhd_language(mdhd.language); + let mut mux_policy = + imported_track_mux_policy_for_sample_entry_type(sample_entry_type, &sample_entry_box, kind); + if let Some(sample_roll_distance) = sample_roll_distance { + mux_policy = mux_policy.with_sample_roll_distance(sample_roll_distance); + } + mux_policy = mux_policy.with_emit_roll_sbgp(emit_roll_sbgp); + + let source_edit_media_time = if synthesized_speex_elst_tail { + None + } else { + elst.as_ref() + .and_then(imported_track_source_edit_media_time_from_elst) + }; + let source_edit_segment_duration = elst + .as_ref() + .and_then(imported_track_source_edit_segment_duration_from_elst); + let default_header_policy = default_imported_track_header_policy(kind); + let (tkhd_flags, alternate_group, volume, matrix) = if synthesized_speex_elst_tail + && kind == MuxTrackKind::Audio + && sample_entry_type == FourCc::from_bytes(*b"spex") + { + ( + default_header_policy.tkhd_flags, + default_header_policy.alternate_group, + default_header_policy.volume, + default_header_policy.matrix, + ) + } else { + (tkhd.flags(), tkhd.alternate_group, tkhd.volume, tkhd.matrix) + }; + + Ok(Some(ParsedMp4Track { + track: TrackCandidate { + track_id: tkhd.track_id, + kind, + timescale: mdhd.timescale, + language, + handler_name: hdlr + .and_then(|value| (!value.name.is_empty()).then_some(value.name)) + .unwrap_or_else(|| default_handler_name_for_kind(kind).to_string()), + mux_policy: mux_policy.with_header_policy(ImportedTrackHeaderPolicy { + tkhd_flags, + alternate_group, + volume, + matrix, + source_track_id: Some(tkhd.track_id), + source_track_creation_time: Some(tkhd.creation_time()), + source_track_modification_time: Some(tkhd.modification_time()), + source_media_creation_time: Some(mdhd.creation_time()), + source_media_modification_time: Some(mdhd.modification_time()), + source_movie_timescale: Some(source_movie_timescale), + source_media_duration: Some(mdhd.duration()), + source_edit_segment_duration, + source_media_decode_time_offset: None, + source_stss_first_only, + }), + width, + height, + sample_entry_box, + source_edit_media_time, + samples, + }, + carry: ImportedMp4TrackCarry { + flat_chunk_sample_counts, + flat_stsc, + sample_entry_boxes: Some(sample_entry_boxes), + sample_description_indices, + fragmented_decode_time_gaps: Vec::new(), + source_had_empty_stts, + source_sync_samples, + preserved_flat_stbl_boxes, + preserved_flat_trak_boxes, + }, + source_segments: Vec::new(), + uses_external_data_reference: false, + })) +} + +fn should_drop_truncated_terminal_imported_sample( + sample_offset: Option, + sample_size: Option, + source_file_size: u64, +) -> bool { + let (Some(sample_offset), Some(sample_size)) = (sample_offset, sample_size) else { + return false; + }; + if sample_offset >= source_file_size { + return false; + } + sample_offset.saturating_add(u64::from(sample_size)) > source_file_size +} + +fn imported_sample_prefix_len_within_source_file( + sample_offsets: &[u64], + sample_sizes: &[u32], + source_file_size: u64, +) -> usize { + sample_offsets + .iter() + .copied() + .zip(sample_sizes.iter().copied()) + .position(|(sample_offset, sample_size)| { + sample_offset + .checked_add(u64::from(sample_size)) + .is_none_or(|sample_end| sample_end > source_file_size) + }) + .unwrap_or(sample_sizes.len()) +} + +fn resolved_sample_logical_offsets( + sample_offsets: &[u64], + sample_sizes: &[u32], + data_references: &[ImportedDataReference], + source_file_size: u64, +) -> Result, MuxError> { + let mut resolved = sample_offsets.to_vec(); + let mut next_external_offset = source_file_size; + for ((resolved_offset, sample_size), data_reference) in resolved + .iter_mut() + .zip(sample_sizes.iter().copied()) + .zip(data_references.iter()) + { + if matches!(data_reference, ImportedDataReference::LocalFile(_)) { + *resolved_offset = next_external_offset; + next_external_offset = next_external_offset + .checked_add(u64::from(sample_size)) + .ok_or(MuxError::LayoutOverflow( + "external data-reference logical offset", + ))?; + } + } + Ok(resolved) +} + +fn sample_source_segments( + path: &Path, + track_id: u32, + logical_offsets: &[u64], + source_offsets: &[u64], + sample_sizes: &[u32], + data_references: &[ImportedDataReference], +) -> Result, MuxError> { + if source_offsets.len() != sample_sizes.len() + || source_offsets.len() != data_references.len() + || source_offsets.len() != logical_offsets.len() + { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} exposes inconsistent sample source metadata"), + }); + } + + let mut segments = Vec::new(); + for (((logical_offset, source_offset), sample_size), data_reference) in logical_offsets + .iter() + .copied() + .zip(source_offsets.iter().copied()) + .zip(sample_sizes.iter().copied()) + .zip(data_references.iter()) + { + if sample_size == 0 { + continue; + } + let data = match data_reference { + ImportedDataReference::SelfContained => SegmentedMuxSourceSegmentData::FileRange { + source_offset, + size: sample_size, + }, + ImportedDataReference::LocalFile(path) => { + SegmentedMuxSourceSegmentData::ExternalFileRange { + path: path.clone(), + source_offset, + size: sample_size, + } + } + }; + segments.push(SegmentedMuxSourceSegment { + logical_offset, + data, + }); + } + Ok(segments) +} + +fn validate_imported_sample_data_references( + path: &Path, + track_id: u32, + sample_offsets: &[u64], + sample_sizes: &[u32], + data_references: &[ImportedDataReference], + source_file_size: u64, +) -> Result<(), MuxError> { + for ((sample_offset, sample_size), data_reference) in sample_offsets + .iter() + .copied() + .zip(sample_sizes.iter().copied()) + .zip(data_references.iter()) + { + let sample_end = sample_offset + .checked_add(u64::from(sample_size)) + .ok_or(MuxError::LayoutOverflow("data-reference sample range"))?; + match data_reference { + ImportedDataReference::SelfContained => { + if sample_end > source_file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} has a self-contained sample outside the source file" + ), + }); + } + } + ImportedDataReference::LocalFile(reference_path) => { + let size = std::fs::metadata(reference_path) + .map_err(|error| { + mux_io_at_path("inspect referenced media", reference_path, error) + })? + .len(); + if sample_end > size { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} has a referenced sample outside the referenced media" + ), + }); + } + } + } + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn validate_imported_sample_data_references_async( + path: &Path, + track_id: u32, + sample_offsets: &[u64], + sample_sizes: &[u32], + data_references: &[ImportedDataReference], + source_file_size: u64, +) -> Result<(), MuxError> { + for ((sample_offset, sample_size), data_reference) in sample_offsets + .iter() + .copied() + .zip(sample_sizes.iter().copied()) + .zip(data_references.iter()) + { + let sample_end = sample_offset + .checked_add(u64::from(sample_size)) + .ok_or(MuxError::LayoutOverflow("data-reference sample range"))?; + match data_reference { + ImportedDataReference::SelfContained => { + if sample_end > source_file_size { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} has a self-contained sample outside the source file" + ), + }); + } + } + ImportedDataReference::LocalFile(reference_path) => { + let size = tokio::fs::metadata(reference_path) + .await + .map_err(|error| { + mux_io_at_path("inspect referenced media", reference_path, error) + })? + .len(); + if sample_end > size { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} has a referenced sample outside the referenced media" + ), + }); + } + } + } + } + Ok(()) +} + +fn build_mp4_import_source_override( + path: &Path, + mut segments: Vec, + source_file_size: u64, +) -> Result { + segments.sort_by_key(|segment| segment.logical_offset); + let mut normalized = Vec::::with_capacity(segments.len()); + let mut total_size = source_file_size; + for segment in segments { + if let Some(previous) = normalized.last() + && previous.logical_end() > segment.logical_offset + { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: "sample data-reference ranges overlap after import resolution".to_string(), + }); + } + total_size = total_size.max(segment.logical_end()); + normalized.push(segment); + } + Ok(SegmentedMuxSourceSpec { + path: path.to_path_buf(), + segments: normalized, + total_size, + }) +} + +fn trim_flat_chunk_sample_counts_to_sample_count( + flat_chunk_sample_counts: &mut Vec, + sample_count: usize, +) -> Result<(), MuxError> { + let mut remaining = u32::try_from(sample_count) + .map_err(|_| MuxError::LayoutOverflow("imported flat chunk sample count trim"))?; + let mut trimmed = Vec::with_capacity(flat_chunk_sample_counts.len()); + for &count in flat_chunk_sample_counts.iter() { + if remaining == 0 { + break; + } + let kept = count.min(remaining); + trimmed.push(kept); + remaining -= kept; + } + *flat_chunk_sample_counts = trimmed; + Ok(()) +} + +fn imported_track_source_edit_media_time_from_elst(elst: &Elst) -> Option { + if elst.entry_count == 0 { + return None; + } + for index in 0..usize::try_from(elst.entry_count).ok()? { + let media_time = elst.media_time(index); + if media_time >= 0 { + return Some(media_time as u64); + } + } + None +} + +fn imported_track_source_edit_segment_duration_from_elst(elst: &Elst) -> Option { + if elst.entry_count == 0 { + return None; + } + let mut total = 0_u64; + for index in 0..usize::try_from(elst.entry_count).ok()? { + total = total.checked_add(elst.segment_duration(index))?; + } + Some(total) +} + +fn imported_track_elst_trailing_bytes(elst: &Elst, box_size: u64) -> Option { + let per_entry_size = if elst.version() == 1 { 20_u64 } else { 12_u64 }; + let expected_size = + 16_u64.checked_add(u64::from(elst.entry_count).checked_mul(per_entry_size)?)?; + box_size + .checked_sub(expected_size) + .and_then(|trailing| u32::try_from(trailing).ok()) + .filter(|trailing| *trailing > 8) +} + +#[allow(clippy::too_many_arguments)] +fn synthesize_imported_speex_elst_tail_sync( + reader: &mut R, + sample_entry: &ExtractedBox, + elst: Option<&Elst>, + elst_box_size: Option, + sample_offsets: &mut Vec, + sample_sizes: &mut Vec, + sample_durations: &mut Vec, + composition_offsets: &mut Vec, + sample_description_indices: &mut Vec, + sync_samples: &mut Vec, +) -> Result +where + R: Read + Seek, +{ + if sample_entry.info.box_type() != FourCc::from_bytes(*b"spex") { + return Ok(false); + } + let Some(elst) = elst else { + return Ok(false); + }; + let Some(elst_box_size) = elst_box_size else { + return Ok(false); + }; + let Some(trailing_bytes) = imported_track_elst_trailing_bytes(elst, elst_box_size) else { + return Ok(false); + }; + if sample_durations.last().copied() != Some(0) { + return Ok(false); + } + + let skip_info = extract_box( + reader, + Some(&sample_entry.info), + BoxPath::from([FourCc::from_bytes(*b"skip")]), + )? + .into_iter() + .next(); + let Some(skip_info) = skip_info else { + return Ok(false); + }; + let skip_size = u32::try_from(skip_info.size()) + .map_err(|_| MuxError::LayoutOverflow("imported speex skip sample size"))?; + let synthetic_edit_entry_count = usize::try_from((u64::from(trailing_bytes) - 8) / 12) + .map_err(|_| MuxError::LayoutOverflow("imported speex synthetic edit count"))?; + if synthetic_edit_entry_count < 2 { + return Ok(false); + } + + let removed_terminal_sample_offset = sample_offsets.pop(); + let removed_terminal_sample_size = sample_sizes.pop(); + let removed_terminal_sample_description_index = sample_description_indices.pop(); + sample_durations.pop(); + composition_offsets.pop(); + sync_samples.pop(); + + let synthetic_sample_offset = removed_terminal_sample_offset.unwrap_or(skip_info.offset()); + let synthetic_sample_size = removed_terminal_sample_size.unwrap_or(skip_size); + let synthetic_sample_description_index = removed_terminal_sample_description_index.unwrap_or(1); + let repeated_tail_sample_offset = sample_offsets + .first() + .copied() + .unwrap_or(synthetic_sample_offset); + let repeated_tail_sample_size = sample_sizes + .first() + .copied() + .unwrap_or(synthetic_sample_size); + let repeated_tail_sample_description_index = sample_description_indices + .first() + .copied() + .unwrap_or(synthetic_sample_description_index); + + sample_offsets.push(synthetic_sample_offset); + sample_sizes.push(synthetic_sample_size); + sample_durations.push(trailing_bytes); + composition_offsets.push(0); + sample_description_indices.push(synthetic_sample_description_index); + sync_samples.push(true); + + for _ in 0..synthetic_edit_entry_count.saturating_sub(2) { + sample_offsets.push(repeated_tail_sample_offset); + sample_sizes.push(repeated_tail_sample_size); + sample_durations.push(1); + composition_offsets.push(0); + sample_description_indices.push(repeated_tail_sample_description_index); + sync_samples.push(true); + } + + sample_offsets.push(repeated_tail_sample_offset); + sample_sizes.push(repeated_tail_sample_size); + sample_durations.push(0); + composition_offsets.push(0); + sample_description_indices.push(repeated_tail_sample_description_index); + sync_samples.push(true); + + Ok(true) +} + +#[cfg(feature = "async")] +#[allow(clippy::too_many_arguments)] +async fn synthesize_imported_speex_elst_tail_async( + reader: &mut R, + sample_entry: &ExtractedBox, + elst: Option<&Elst>, + elst_box_size: Option, + sample_offsets: &mut Vec, + sample_sizes: &mut Vec, + sample_durations: &mut Vec, + composition_offsets: &mut Vec, + sample_description_indices: &mut Vec, + sync_samples: &mut Vec, +) -> Result +where + R: AsyncReadSeek, +{ + if sample_entry.info.box_type() != FourCc::from_bytes(*b"spex") { + return Ok(false); + } + let Some(elst) = elst else { + return Ok(false); + }; + let Some(elst_box_size) = elst_box_size else { + return Ok(false); + }; + let Some(trailing_bytes) = imported_track_elst_trailing_bytes(elst, elst_box_size) else { + return Ok(false); + }; + if sample_durations.last().copied() != Some(0) { + return Ok(false); + } + + let skip_info = extract_box_async( + reader, + Some(&sample_entry.info), + BoxPath::from([FourCc::from_bytes(*b"skip")]), + ) + .await? + .into_iter() + .next(); + let Some(skip_info) = skip_info else { + return Ok(false); + }; + let skip_size = u32::try_from(skip_info.size()) + .map_err(|_| MuxError::LayoutOverflow("imported speex skip sample size"))?; + let synthetic_edit_entry_count = usize::try_from((u64::from(trailing_bytes) - 8) / 12) + .map_err(|_| MuxError::LayoutOverflow("imported speex synthetic edit count"))?; + if synthetic_edit_entry_count < 2 { + return Ok(false); + } + + let removed_terminal_sample_offset = sample_offsets.pop(); + let removed_terminal_sample_size = sample_sizes.pop(); + let removed_terminal_sample_description_index = sample_description_indices.pop(); + sample_durations.pop(); + composition_offsets.pop(); + sync_samples.pop(); + + let synthetic_sample_offset = removed_terminal_sample_offset.unwrap_or(skip_info.offset()); + let synthetic_sample_size = removed_terminal_sample_size.unwrap_or(skip_size); + let synthetic_sample_description_index = removed_terminal_sample_description_index.unwrap_or(1); + let repeated_tail_sample_offset = sample_offsets + .first() + .copied() + .unwrap_or(synthetic_sample_offset); + let repeated_tail_sample_size = sample_sizes + .first() + .copied() + .unwrap_or(synthetic_sample_size); + let repeated_tail_sample_description_index = sample_description_indices + .first() + .copied() + .unwrap_or(synthetic_sample_description_index); + + sample_offsets.push(synthetic_sample_offset); + sample_sizes.push(synthetic_sample_size); + sample_durations.push(trailing_bytes); + composition_offsets.push(0); + sample_description_indices.push(synthetic_sample_description_index); + sync_samples.push(true); + + for _ in 0..synthetic_edit_entry_count.saturating_sub(2) { + sample_offsets.push(repeated_tail_sample_offset); + sample_sizes.push(repeated_tail_sample_size); + sample_durations.push(1); + composition_offsets.push(0); + sample_description_indices.push(repeated_tail_sample_description_index); + sync_samples.push(true); + } + + sample_offsets.push(repeated_tail_sample_offset); + sample_sizes.push(repeated_tail_sample_size); + sample_durations.push(0); + composition_offsets.push(0); + sample_description_indices.push(repeated_tail_sample_description_index); + sync_samples.push(true); + + Ok(true) +} + +fn fixed_16_16_to_u16(value: u32) -> u16 { + u16::try_from(value >> 16).unwrap_or(u16::MAX) +} + +fn imported_track_mux_policy_for_sample_entry_type( + sample_entry_type: FourCc, + sample_entry_box: &[u8], + kind: MuxTrackKind, +) -> ImportedTrackMuxPolicy { + if sample_entry_type == FourCc::from_bytes(*b"iamf") { + return imported_iamf_mux_policy(kind); + } + let mut policy = ImportedTrackMuxPolicy::DEFAULT; + if sample_entry_type == FourCc::from_bytes(*b"hev1") + || sample_entry_type == FourCc::from_bytes(*b"hvc1") + { + if !sample_entry_carries_dolby_vision_config(sample_entry_box) { + policy.sync_sample_table_mode = SyncSampleTableMode::ForceFirstOnly; + } + policy.stsc_run_encoding_mode = StscRunEncodingMode::PreserveTerminalBoundary; + } + if sample_entry_type == FourCc::from_bytes(*b"vp08") { + policy.stsc_run_encoding_mode = StscRunEncodingMode::PreserveTerminalBoundary; + } + if sample_entry_type == FourCc::from_bytes(*b"text") + || sample_entry_type == FourCc::from_bytes(*b"tx3g") + { + policy.stsc_run_encoding_mode = StscRunEncodingMode::PreserveTerminalBoundary; + } + if sample_entry_type == FourCc::from_bytes(*b"mp4a") + || sample_entry_type == FourCc::from_bytes(*b"ac-3") + || sample_entry_type == FourCc::from_bytes(*b"ec-3") + { + policy.stsc_run_encoding_mode = StscRunEncodingMode::PreserveTerminalBoundary; + } + if sample_entry_type == FourCc::from_bytes(*b"vp09") + || sample_entry_type == FourCc::from_bytes(*b"wvtt") + || sample_entry_type == FourCc::from_bytes(*b"mha1") + || sample_entry_type == FourCc::from_bytes(*b"mha2") + || sample_entry_type == FourCc::from_bytes(*b"mhm1") + || sample_entry_type == FourCc::from_bytes(*b"mhm2") + || sample_entry_type == FourCc::from_bytes(*b"alac") + || sample_entry_type == FourCc::from_bytes(*b"ipcm") + || sample_entry_type == FourCc::from_bytes(*b"fpcm") + { + policy.stsc_run_encoding_mode = StscRunEncodingMode::PreserveTerminalBoundary; + } + policy +} + +fn imported_iamf_mux_policy(kind: MuxTrackKind) -> ImportedTrackMuxPolicy { + let mut policy = ImportedTrackMuxPolicy::DEFAULT; + if kind.is_audio() { + policy.stsc_run_encoding_mode = StscRunEncodingMode::PreserveTerminalBoundary; + } + policy +} + +fn split_terminal_short_video_chunk_sample_counts( + sample_durations: &[u32], + chunk_sample_counts: &mut Vec, +) { + if sample_durations.len() < 2 || chunk_sample_counts.is_empty() { + return; + } + let last_duration = *sample_durations.last().unwrap(); + let previous_duration = sample_durations[sample_durations.len() - 2]; + if last_duration >= previous_duration { + return; + } + let Some(last_chunk_sample_count) = chunk_sample_counts.last_mut() else { + return; + }; + if *last_chunk_sample_count <= 1 { + return; + } + *last_chunk_sample_count -= 1; + chunk_sample_counts.push(1); +} + +fn split_terminal_short_audio_chunk_sample_counts( + sample_durations: &[u32], + chunk_sample_counts: &mut Vec, +) { + if sample_durations.len() < 2 || chunk_sample_counts.is_empty() { + return; + } + let last_duration = *sample_durations.last().unwrap(); + let previous_duration = sample_durations[sample_durations.len() - 2]; + if last_duration >= previous_duration { + return; + } + let Some(last_chunk_sample_count) = chunk_sample_counts.last_mut() else { + return; + }; + if *last_chunk_sample_count <= 1 { + return; + } + *last_chunk_sample_count -= 1; + chunk_sample_counts.push(1); +} + +fn extracted_sample_roll_distance(sgpd: Option<&Sgpd>) -> Option { + let sgpd = sgpd?; + let grouping_type = sgpd.grouping_type.as_bytes(); + if grouping_type != b"roll" && grouping_type != b"prol" { + return None; + } + if let Some(sample_roll_distance) = sgpd.roll_distances.first().copied() { + return Some(sample_roll_distance); + } + sgpd.roll_distances_l + .first() + .map(|entry| entry.roll_distance) +} + +fn extracted_roll_sbgp_present(sbgp: Option<&Sbgp>) -> bool { + let Some(sbgp) = sbgp else { + return false; + }; + let grouping_type = sbgp.grouping_type.to_be_bytes(); + grouping_type == *b"roll" || grouping_type == *b"prol" +} + +fn infer_track_kind_from_sample_entry_type(sample_entry_type: FourCc) -> Option { + if [ + ENCA, + FourCc::from_bytes(*b"mp4a"), + FourCc::from_bytes(*b".mp3"), + FourCc::from_bytes(*b"alaw"), + FourCc::from_bytes(*b"ulaw"), + FourCc::from_bytes(*b"Opus"), + FourCc::from_bytes(*b"spex"), + FourCc::from_bytes(*b"samr"), + FourCc::from_bytes(*b"sawb"), + FourCc::from_bytes(*b"sqcp"), + FourCc::from_bytes(*b"sevc"), + FourCc::from_bytes(*b"ssmv"), + FourCc::from_bytes(*b"ac-3"), + FourCc::from_bytes(*b"ec-3"), + FourCc::from_bytes(*b"ac-4"), + FourCc::from_bytes(*b"alac"), + FourCc::from_bytes(*b"mlpa"), + FourCc::from_bytes(*b"dtsc"), + FourCc::from_bytes(*b"dtse"), + FourCc::from_bytes(*b"dtsh"), + FourCc::from_bytes(*b"dtsl"), + FourCc::from_bytes(*b"dtsm"), + FourCc::from_bytes(*b"dts-"), + FourCc::from_bytes(*b"dtsx"), + FourCc::from_bytes(*b"dtsy"), + FourCc::from_bytes(*b"fLaC"), + FourCc::from_bytes(*b"iamf"), + FourCc::from_bytes(*b"mha1"), + FourCc::from_bytes(*b"mha2"), + FourCc::from_bytes(*b"mhm1"), + FourCc::from_bytes(*b"mhm2"), + FourCc::from_bytes(*b"ipcm"), + FourCc::from_bytes(*b"fpcm"), + ] + .contains(&sample_entry_type) + { + Some(MuxTrackKind::Audio) + } else if [ + ENCV, + FourCc::from_bytes(*b"avc1"), + FourCc::from_bytes(*b"hev1"), + FourCc::from_bytes(*b"hvc1"), + FourCc::from_bytes(*b"dvhe"), + FourCc::from_bytes(*b"dvh1"), + FourCc::from_bytes(*b"vvc1"), + FourCc::from_bytes(*b"vvi1"), + FourCc::from_bytes(*b"avs3"), + FourCc::from_bytes(*b"av01"), + FourCc::from_bytes(*b"jpeg"), + FourCc::from_bytes(*b"mjpg"), + FourCc::from_bytes(*b"mpeg"), + FourCc::from_bytes(*b"mp4v"), + FourCc::from_bytes(*b"s263"), + FourCc::from_bytes(*b"h263"), + FourCc::from_bytes(*b"png "), + FourCc::from_bytes(*b"vp08"), + FourCc::from_bytes(*b"vp09"), + FourCc::from_bytes(*b"vp10"), + ] + .contains(&sample_entry_type) + { + Some(MuxTrackKind::Video) + } else if [ + FourCc::from_bytes(*b"text"), + FourCc::from_bytes(*b"tx3g"), + FourCc::from_bytes(*b"sbtt"), + FourCc::from_bytes(*b"wvtt"), + ] + .contains(&sample_entry_type) + { + Some(MuxTrackKind::Text) + } else if [ + FourCc::from_bytes(*b"stpp"), + FourCc::from_bytes(*b"dvbs"), + FourCc::from_bytes(*b"dvbt"), + FourCc::from_bytes(*b"mp4s"), + ] + .contains(&sample_entry_type) + { + Some(MuxTrackKind::Subtitle) + } else { + None + } +} + +const fn default_handler_name_for_kind(kind: MuxTrackKind) -> &'static str { + match kind { + MuxTrackKind::Audio => "SoundHandler", + MuxTrackKind::Video => "VideoHandler", + MuxTrackKind::Text => "TextHandler", + MuxTrackKind::Subtitle => "SubtitleHandler", + } +} + +pub(in crate::mux) fn direct_ingest_handler_name(codec_label: &str) -> String { + let kind = match codec_label { + "h263" | "h264" | "h265" | "vvc" | "av1" | "vp8" | "vp9" | "vp10" | "mp4v" | "mpeg2v" + | "avs3" | "ogg-theora" | "jpeg" | "png" | "bmp" | "prores" | "y4m" | "rawvideo" + | "j2k" => MuxTrackKind::Video, + "vobsub" => MuxTrackKind::Subtitle, + _ => MuxTrackKind::Audio, + }; + default_handler_name_for_kind(kind).to_string() +} + +pub(in crate::mux) fn direct_ingest_mux_policy( + codec_label: &str, + kind: MuxTrackKind, +) -> ImportedTrackMuxPolicy { + let mut policy = ImportedTrackMuxPolicy::DEFAULT; + if kind.is_audio() || codec_label == "vobsub" { + policy.stsc_run_encoding_mode = StscRunEncodingMode::PreserveTerminalBoundary; + } + if codec_label == "iamf" { + policy.sync_sample_table_mode = SyncSampleTableMode::ForceEmpty; + } + policy +} + +pub(in crate::mux) fn direct_ingest_mux_policy_with_preferred_track_id( + codec_label: &str, + kind: MuxTrackKind, + preferred_track_id: u32, +) -> ImportedTrackMuxPolicy { + direct_ingest_mux_policy(codec_label, kind).with_preferred_track_id(preferred_track_id) +} + +fn assign_imported_track_ids( + imported_tracks: &[ImportedTrack], + preserve_imported_track_ids: bool, +) -> Result, MuxError> { + if !preserve_imported_track_ids { + return imported_tracks + .iter() + .enumerate() + .map(|(index, _)| { + u32::try_from(index + 1) + .map_err(|_| MuxError::LayoutOverflow("track identifier assignment")) + }) + .collect(); + } + + let mut preferred_counts = BTreeMap::::new(); + for track in imported_tracks { + if let Some(track_id) = imported_track_preserved_track_id(track) { + *preferred_counts.entry(track_id).or_default() += 1; + } + } + + let mut assigned = Vec::with_capacity(imported_tracks.len()); + let mut used = BTreeMap::::new(); + for (index, track) in imported_tracks.iter().enumerate() { + let preserved = imported_track_preserved_track_id(track) + .filter(|track_id| preferred_counts.get(track_id) == Some(&1)) + .filter(|track_id| { + !earlier_source_order_slot_claims_track_id( + imported_tracks, + &preferred_counts, + index, + *track_id, + ) + }); + if let Some(track_id) = preserved { + used.insert(track_id, ()); + assigned.push(track_id); + } else { + assigned.push(0); + } + } + + for (index, track_id) in assigned.iter_mut().enumerate() { + if *track_id != 0 { + continue; + } + let mut next_track_id = u32::try_from(index + 1) + .map_err(|_| MuxError::LayoutOverflow("track identifier assignment"))?; + while used.contains_key(&next_track_id) { + next_track_id = next_track_id + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("track identifier assignment"))?; + } + *track_id = next_track_id; + used.insert(next_track_id, ()); + } + + Ok(assigned) +} + +fn earlier_source_order_slot_claims_track_id( + imported_tracks: &[ImportedTrack], + preferred_counts: &BTreeMap, + index: usize, + track_id: u32, +) -> bool { + imported_tracks + .iter() + .take(index) + .enumerate() + .any(|(prior_index, prior_track)| { + let Ok(prior_source_order_track_id) = u32::try_from(prior_index + 1) else { + return false; + }; + prior_source_order_track_id == track_id + && imported_track_preserved_track_id(prior_track) + .is_none_or(|prior_track_id| preferred_counts.get(&prior_track_id) != Some(&1)) + }) +} + +fn imported_track_preserved_track_id(imported_track: &ImportedTrack) -> Option { + imported_track.mux_policy.preferred_track_id().or_else(|| { + imported_track + .mux_policy + .header_policy() + .and_then(|policy| policy.source_track_id) + }) +} + +fn imported_track_source_key(imported_track: &ImportedTrack) -> Option<(usize, u32)> { + let source_index = imported_track_single_source_index(imported_track)?; + let source_track_id = imported_track + .mux_policy + .header_policy() + .and_then(|policy| policy.source_track_id)?; + Some((source_index, source_track_id)) +} + +fn imported_source_track_id_remap( + imported_tracks: &[ImportedTrack], + assigned_track_ids: &[u32], +) -> BTreeMap<(usize, u32), u32> { + imported_tracks + .iter() + .zip(assigned_track_ids.iter().copied()) + .filter_map(|(track, assigned_track_id)| { + imported_track_source_key(track).map(|key| (key, assigned_track_id)) + }) + .collect() +} + +fn remap_preserved_flat_trak_boxes( + source_track_id_remap: &BTreeMap<(usize, u32), u32>, + source_key: Option<(usize, u32)>, + imported_track: &ImportedTrack, + boxes: Vec>, +) -> Result>, MuxError> { + boxes + .into_iter() + .map(|box_bytes| { + if box_header_type(&box_bytes) != Some(TREF) { + return Ok(box_bytes); + } + let Some((source_index, source_track_id)) = source_key else { + return Err(MuxError::UnsupportedTrackImport { + spec: imported_track_relation_error_spec(imported_track), + message: + "track reference box cannot be remapped because the source track identity is ambiguous" + .to_string(), + }); + }; + remap_tref_box( + source_track_id_remap, + source_index, + source_track_id, + imported_track, + &box_bytes, + ) + }) + .collect() +} + +fn remap_tref_box( + source_track_id_remap: &BTreeMap<(usize, u32), u32>, + source_index: usize, + source_track_id: u32, + imported_track: &ImportedTrack, + tref_box: &[u8], +) -> Result, MuxError> { + let outer = parse_encoded_box_info( + tref_box, + &imported_track_relation_error_spec(imported_track), + "track reference box", + )?; + if outer.box_type() != TREF || outer.size() as usize != tref_box.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: imported_track_relation_error_spec(imported_track), + message: format!("track {source_track_id} carries an invalid track reference box"), + }); + } + let payload_start = usize::try_from(outer.header_size()) + .map_err(|_| MuxError::LayoutOverflow("track reference header"))?; + let mut remapped_children = Vec::new(); + let mut cursor = Cursor::new(&tref_box[payload_start..]); + while usize::try_from(cursor.position()).unwrap_or(usize::MAX) + < tref_box.len().saturating_sub(payload_start) + { + let child_start = usize::try_from(cursor.position()) + .map_err(|_| MuxError::LayoutOverflow("track reference child offset"))?; + let child = crate::BoxInfo::read(&mut cursor).map_err(|error| { + MuxError::UnsupportedTrackImport { + spec: imported_track_relation_error_spec(imported_track), + message: format!( + "track {source_track_id} carries an invalid track reference child: {error}" + ), + } + })?; + let child_end = child_start + .checked_add( + usize::try_from(child.size()) + .map_err(|_| MuxError::LayoutOverflow("track reference child size"))?, + ) + .ok_or(MuxError::LayoutOverflow("track reference child range"))?; + let child_payload_start = child_start + .checked_add( + usize::try_from(child.header_size()) + .map_err(|_| MuxError::LayoutOverflow("track reference child header"))?, + ) + .ok_or(MuxError::LayoutOverflow("track reference child payload"))?; + let tref_payload = &tref_box[payload_start..]; + if child_end > tref_payload.len() || child_payload_start > child_end { + return Err(MuxError::UnsupportedTrackImport { + spec: imported_track_relation_error_spec(imported_track), + message: format!( + "track {source_track_id} carries a truncated track reference child" + ), + }); + } + let child_payload = &tref_payload[child_payload_start..child_end]; + if !child_payload.len().is_multiple_of(4) { + return Err(MuxError::UnsupportedTrackImport { + spec: imported_track_relation_error_spec(imported_track), + message: format!( + "track {source_track_id} carries track reference child `{}` with a non-u32 payload", + child.box_type() + ), + }); + } + let mut remapped_payload = Vec::with_capacity(child_payload.len()); + for referenced_track_id in child_payload + .chunks_exact(4) + .map(|bytes| u32::from_be_bytes(bytes.try_into().expect("four-byte chunk"))) + { + let Some(remapped_track_id) = + source_track_id_remap.get(&(source_index, referenced_track_id)) + else { + return Err(MuxError::UnsupportedTrackImport { + spec: imported_track_relation_error_spec(imported_track), + message: format!( + "track {source_track_id} references unavailable track {referenced_track_id} in `{}`", + child.box_type() + ), + }); + }; + remapped_payload.extend_from_slice(&remapped_track_id.to_be_bytes()); + } + let child_size = child + .header_size() + .checked_add( + u64::try_from(remapped_payload.len()) + .map_err(|_| MuxError::LayoutOverflow("track reference child payload"))?, + ) + .ok_or(MuxError::LayoutOverflow("track reference child size"))?; + remapped_children.extend_from_slice( + &HeaderInfo::new(child.box_type(), child_size) + .with_header_size(child.header_size()) + .encode(), + ); + remapped_children.extend_from_slice(&remapped_payload); + Seek::seek( + &mut cursor, + SeekFrom::Start( + u64::try_from(child_end) + .map_err(|_| MuxError::LayoutOverflow("track reference child seek"))?, + ), + )?; + } + let tref_size = outer + .header_size() + .checked_add( + u64::try_from(remapped_children.len()) + .map_err(|_| MuxError::LayoutOverflow("track reference payload"))?, + ) + .ok_or(MuxError::LayoutOverflow("track reference box size"))?; + let mut remapped = HeaderInfo::new(TREF, tref_size) + .with_header_size(outer.header_size()) + .encode(); + remapped.extend_from_slice(&remapped_children); + Ok(remapped) +} + +fn filter_preserved_flat_trak_boxes_for_output( + imported_track: &ImportedTrack, + movie_timescale: u32, + output_layout: MuxOutputLayout, + preserved_flat_trak_boxes: Vec>, +) -> Vec> { + let source_movie_timescale = imported_track + .mux_policy + .header_policy() + .and_then(|policy| policy.source_movie_timescale); + let preserve_flat_edts = + output_layout == MuxOutputLayout::Flat && source_movie_timescale == Some(movie_timescale); + preserved_flat_trak_boxes + .into_iter() + .filter(|box_bytes| { + box_header_type(box_bytes) != Some(EDTS) + || (preserve_flat_edts + && preserved_flat_edts_has_material_timing(imported_track, box_bytes)) + }) + .collect() +} + +fn preserved_flat_edts_has_material_timing( + imported_track: &ImportedTrack, + box_bytes: &[u8], +) -> bool { + let mut reader = Cursor::new(box_bytes); + let Ok(elst_boxes) = extract_box_as::<_, Elst>(&mut reader, None, BoxPath::from([EDTS, ELST])) + else { + return true; + }; + let [elst] = elst_boxes.as_slice() else { + return true; + }; + preserved_flat_elst_has_material_timing( + elst, + imported_track + .mux_policy + .header_policy() + .and_then(|policy| policy.source_media_duration), + ) +} + +fn preserved_flat_elst_has_material_timing( + elst: &Elst, + source_media_duration: Option, +) -> bool { + if elst.entry_count == 1 && elst.segment_duration(0) == 0 { + return false; + } + !preserved_flat_elst_is_identity(elst, source_media_duration) +} + +fn preserved_flat_elst_is_identity(elst: &Elst, source_media_duration: Option) -> bool { + let Some(source_media_duration) = source_media_duration else { + return false; + }; + let Ok(entry_count) = usize::try_from(elst.entry_count) else { + return false; + }; + if entry_count == 0 || entry_count > elst.entries.len() { + return false; + } + + let mut media_entry_count = 0_u8; + for index in 0..entry_count { + let segment_duration = elst.segment_duration(index); + let media_time = elst.media_time(index); + if segment_duration == 0 && media_time < 0 { + continue; + } + if media_time != 0 + || elst.entries[index].media_rate_integer != 1 + || segment_duration != source_media_duration + { + return false; + } + media_entry_count = media_entry_count.saturating_add(1); + } + media_entry_count == 1 +} + +fn parse_encoded_box_info( + box_bytes: &[u8], + spec: &str, + context: &'static str, +) -> Result { + let mut cursor = Cursor::new(box_bytes); + crate::BoxInfo::read(&mut cursor).map_err(|error| MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!("invalid {context}: {error}"), + }) +} + +fn imported_track_relation_error_spec(imported_track: &ImportedTrack) -> String { + imported_track + .mux_policy + .header_policy() + .and_then(|policy| policy.source_track_id) + .map(|track_id| format!("track {track_id}")) + .unwrap_or_else(|| "track".to_string()) +} + +fn generated_flat_stbl_boxes_for_imported_track( + imported_track: &ImportedTrack, + imported_mp4_carry: Option<&ImportedMp4TrackCarry>, + preserved_flat_stbl_boxes: &[Vec], + preserve_flat_authority_layout: bool, +) -> Result>, MuxError> { + let mut generated = Vec::new(); + let sample_entry_type = sample_entry_box_type(&imported_track.sample_entry_box); + let preserved_box_types = preserved_flat_stbl_boxes + .iter() + .filter_map(|box_bytes| box_bytes.get(4..8)) + .filter_map(|box_type| box_type.try_into().ok()) + .map(FourCc::from_bytes) + .collect::>(); + + if !preserved_box_types.contains(&SDTP) + && imported_mp4_carry.is_some_and(|carry| carry.source_had_empty_stts) + && imported_track_uses_avc_family(imported_track) + { + generated.push(build_imported_zero_sdtp_box(imported_track.samples.len())?); + } + + if !preserved_box_types.contains(&SDTP) + && imported_mp4_carry.is_some() + && sample_entry_type == Some(FourCc::from_bytes(*b"vp08")) + { + generated.push(build_imported_zero_sdtp_box(imported_track.samples.len())?); + } + + if !preserved_box_types.contains(&SDTP) + && imported_mp4_carry.is_some() + && matches!( + sample_entry_type, + Some(value) if value == FourCc::from_bytes(*b"wvtt") + ) + { + generated.push(build_imported_zero_sdtp_box(imported_track.samples.len())?); + } + + if !preserved_box_types.contains(&SDTP) + && imported_mp4_carry.is_some_and(|carry| carry.source_had_empty_stts) + && (sample_entry_type == Some(FourCc::from_bytes(*b"ac-3")) + || imported_track_uses_mpegh_family(imported_track)) + { + generated.push(build_imported_zero_sdtp_box(imported_track.samples.len())?); + } + + if !preserved_box_types.contains(&SDTP) + && imported_mp4_carry.is_some() + && sample_entry_type == Some(FourCc::from_bytes(*b"hev1")) + && !sample_entry_carries_box_type( + &imported_track.sample_entry_box, + FourCc::from_bytes(*b"fiel"), + ) + { + generated.push(build_imported_zero_sdtp_box(imported_track.samples.len())?); + } + + if imported_mp4_carry.is_some() + && imported_track_uses_av1_family(imported_track) + && !preserved_box_types.contains(&SDTP) + { + generated.push(build_imported_av1_sdtp_box(imported_track)?); + } + + let carries_random_access_groups = + preserved_box_types.contains(&SGPD) || preserved_box_types.contains(&SBGP); + let should_suppress_random_access_groups = + preserve_flat_authority_layout && imported_track.mux_policy.header_policy().is_some(); + let has_multiple_sync_samples = imported_track + .samples + .iter() + .filter(|sample| sample.is_sync_sample) + .count() + > 1; + + if imported_track_uses_hevc_family(imported_track) + && !sample_entry_carries_dolby_vision_config(&imported_track.sample_entry_box) + && !carries_random_access_groups + && !should_suppress_random_access_groups + && has_multiple_sync_samples + { + generated.push(build_imported_visual_random_access_sgpd_box()?); + generated.push(build_imported_visual_random_access_sbgp_box( + imported_track, + )?); + } + + if imported_track_uses_layered_hevc_family(imported_track) + && !preserved_box_types.contains(&CSLG) + && imported_track.mux_policy.header_policy().is_some() + && imported_track + .samples + .iter() + .any(|sample| sample.composition_time_offset != 0) + && let Some(cslg_box) = build_generated_imported_cslg_box(imported_track)? + { + generated.push(cslg_box); + } + + Ok(generated) +} + +fn build_generated_imported_cslg_box( + imported_track: &ImportedTrack, +) -> Result>, MuxError> { + let mut decode_time = 0_i128; + let mut saw_offset = false; + let mut least_decode_to_display_delta = i128::MAX; + let mut greatest_decode_to_display_delta = i128::MIN; + let mut composition_start_time = i128::MAX; + let mut max_presentation_end = i128::MIN; + for sample in &imported_track.samples { + let composition_offset = i128::from(sample.composition_time_offset); + saw_offset |= composition_offset != 0; + least_decode_to_display_delta = least_decode_to_display_delta.min(composition_offset); + greatest_decode_to_display_delta = greatest_decode_to_display_delta.max(composition_offset); + composition_start_time = + composition_start_time.min(decode_time.saturating_add(composition_offset)); + max_presentation_end = max_presentation_end.max( + decode_time + .saturating_add(composition_offset) + .saturating_add(i128::from(sample.duration)), + ); + decode_time = decode_time.saturating_add(i128::from(sample.duration)); + } + if !saw_offset { + return Ok(None); + } + + let composition_end_time = imported_track_flat_authority_media_duration(imported_track) + .map(i128::from) + .unwrap_or(max_presentation_end); + let composition_start_time = composition_start_time.max(0); + let mut cslg = Cslg::default(); + cslg.set_version(0); + cslg.composition_to_dts_shift_v0 = 0; + cslg.least_decode_to_display_delta_v0 = i32::try_from(least_decode_to_display_delta) + .map_err(|_| MuxError::LayoutOverflow("imported cslg least delta"))?; + cslg.greatest_decode_to_display_delta_v0 = i32::try_from(greatest_decode_to_display_delta) + .map_err(|_| MuxError::LayoutOverflow("imported cslg greatest delta"))?; + cslg.composition_start_time_v0 = i32::try_from(composition_start_time) + .map_err(|_| MuxError::LayoutOverflow("imported cslg start time"))?; + cslg.composition_end_time_v0 = i32::try_from(composition_end_time) + .map_err(|_| MuxError::LayoutOverflow("imported cslg end time"))?; + Ok(Some(super::mp4::encode_typed_box(&cslg, &[])?)) +} + +fn sample_entry_carries_dolby_vision_config(sample_entry_box: &[u8]) -> bool { + let Ok(child_boxes) = super::mp4::visual_sample_entry_immediate_children(sample_entry_box) + else { + return false; + }; + child_boxes.iter().any(|child_box| { + matches!( + sample_entry_box_type(child_box), + Some(value) + if value == FourCc::from_bytes(*b"dvcC") + || value == FourCc::from_bytes(*b"dvvC") + ) + }) +} + +fn sample_entry_carries_box_type(sample_entry_box: &[u8], target: FourCc) -> bool { + let Ok(child_boxes) = super::mp4::visual_sample_entry_immediate_children(sample_entry_box) + else { + return false; + }; + child_boxes + .iter() + .any(|child_box| sample_entry_box_type(child_box) == Some(target)) +} + +fn imported_track_uses_layered_hevc_family(imported_track: &ImportedTrack) -> bool { + imported_track_uses_hevc_family(imported_track) + && sample_entry_carries_box_type( + &imported_track.sample_entry_box, + FourCc::from_bytes(*b"lhvC"), + ) +} + +fn build_imported_zero_sdtp_box(sample_count: usize) -> Result, MuxError> { + let samples = std::iter::repeat_n( + SdtpSampleElem { + is_leading: 0, + sample_depends_on: 0, + sample_is_depended_on: 0, + sample_has_redundancy: 0, + }, + sample_count, + ) + .collect(); + let mut sdtp = Sdtp::default(); + sdtp.samples = samples; + super::mp4::encode_typed_box(&sdtp, &[]) +} + +fn build_fragmented_imported_vp08_flat_chunk_sample_counts( + track_id: u32, + imported_track: &ImportedTrack, +) -> Result, MuxError> { + let total_sample_count = imported_track.samples.len(); + if total_sample_count == 0 { + return Ok(Vec::new()); + } + + let first_chunk_sample_count = imported_track + .samples + .iter() + .enumerate() + .skip(1) + .find_map(|(sample_index, sample)| sample.is_sync_sample.then_some(sample_index)) + .unwrap_or(total_sample_count); + let first_chunk_sample_count = u32::try_from(first_chunk_sample_count) + .map_err(|_| MuxError::LayoutOverflow("flat vp08 chunk plan"))?; + let trailing_chunk_count = total_sample_count + .checked_sub(usize::try_from(first_chunk_sample_count).unwrap_or(total_sample_count)) + .ok_or(MuxError::LayoutOverflow("flat vp08 chunk plan"))?; + let mut chunk_sample_counts = Vec::with_capacity(1 + trailing_chunk_count); + chunk_sample_counts.push(first_chunk_sample_count); + chunk_sample_counts.extend(std::iter::repeat_n(1_u32, trailing_chunk_count)); + + let planned_sample_count = chunk_sample_counts + .iter() + .try_fold(0_usize, |total, chunk_sample_count| { + total.checked_add(usize::try_from(*chunk_sample_count).ok()?) + }) + .ok_or(MuxError::InvalidChunkPlan { + track_id, + message: + "fragmented flat vp08 chunk plan overflowed while validating staged sample coverage" + .to_string(), + })?; + if planned_sample_count != total_sample_count { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: format!( + "fragmented flat vp08 chunk plan covered {planned_sample_count} sample{} but the imported track carried {total_sample_count}", + if planned_sample_count == 1 { "" } else { "s" }, + ), + }); + } + + Ok(chunk_sample_counts) +} + +fn validate_imported_flat_chunk_sample_counts( + track_id: u32, + kind: MuxTrackKind, + total_sample_count: usize, + chunk_sample_counts: &[u32], +) -> Result, MuxError> { + let planned_sample_count = chunk_sample_counts + .iter() + .try_fold(0_usize, |total, chunk_sample_count| { + total.checked_add(usize::try_from(*chunk_sample_count).ok()?) + }) + .ok_or(MuxError::InvalidChunkPlan { + track_id, + message: format!( + "explicit flat {} chunk plan overflowed while validating staged sample coverage", + flat_destination_append_kind_label(kind) + ), + })?; + if planned_sample_count != total_sample_count { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: format!( + "explicit flat {} chunk plan covered {planned_sample_count} sample{} but the imported track carried {total_sample_count}", + flat_destination_append_kind_label(kind), + if planned_sample_count == 1 { "" } else { "s" }, + ), + }); + } + Ok(chunk_sample_counts.to_vec()) +} + +fn build_imported_av1_sdtp_box(imported_track: &ImportedTrack) -> Result, MuxError> { + let samples = imported_track + .samples + .iter() + .map(|sample| SdtpSampleElem { + is_leading: 0, + sample_depends_on: if sample.is_sync_sample { 2 } else { 1 }, + sample_is_depended_on: 0, + sample_has_redundancy: 0, + }) + .collect(); + let mut sdtp = Sdtp::default(); + sdtp.samples = samples; + super::mp4::encode_typed_box(&sdtp, &[]) +} + +fn build_imported_visual_random_access_sgpd_box() -> Result, MuxError> { + super::mp4::encode_typed_box(&super::mp4::build_visual_random_access_sgpd(), &[]) +} + +fn build_imported_visual_random_access_sbgp_box( + imported_track: &ImportedTrack, +) -> Result, MuxError> { + let mut entries = Vec::::new(); + let mut current_sample_number = 1_u32; + for sync_sample_number in imported_track + .samples + .iter() + .enumerate() + .filter_map(|(index, sample)| sample.is_sync_sample.then_some(index + 1)) + .skip(1) + { + let sync_sample_number = u32::try_from(sync_sample_number) + .map_err(|_| MuxError::LayoutOverflow("visual random-access sample number"))?; + let gap = sync_sample_number.saturating_sub(current_sample_number); + if gap != 0 { + entries.push(SbgpEntry { + sample_count: gap, + group_description_index: 0, + }); + } + entries.push(SbgpEntry { + sample_count: 1, + group_description_index: 1, + }); + current_sample_number = sync_sample_number.saturating_add(1); + } + super::mp4::encode_typed_box(&super::mp4::build_visual_random_access_sbgp(entries)?, &[]) +} + +fn stsd_child_is_padding(box_type: FourCc) -> bool { + matches!(box_type, FourCc::ANY | FREE | SKIP | WIDE) +} + +fn extract_stsd_sample_entries_sync( + path: &Path, + reader: &mut R, + stsd_info: &HeaderInfo, + stsd: &crate::boxes::iso14496_12::Stsd, + track_id: u32, +) -> Result<(Vec, Vec>), MuxError> +where + R: Read + Seek, +{ + let sample_entry_infos = extract_box(reader, Some(stsd_info), BoxPath::from([FourCc::ANY]))? + .into_iter() + .filter(|info| !stsd_child_is_padding(info.box_type())) + .collect::>(); + if sample_entry_infos.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {} does not expose a sample-entry payload", track_id), + }); + } + if usize::try_from(stsd.entry_count).unwrap_or(usize::MAX) != sample_entry_infos.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {} declares {} sample descriptions but exposes {} sample-entry payloads", + track_id, + stsd.entry_count, + sample_entry_infos.len() + ), + }); + } + + let sample_entries = + extract_box_with_payload(reader, Some(stsd_info), BoxPath::from([FourCc::ANY]))? + .into_iter() + .filter(|entry| !stsd_child_is_padding(entry.info.box_type())) + .collect::>(); + if sample_entries.len() != sample_entry_infos.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} exposes inconsistent sample-entry payloads"), + }); + } + let mut sample_entry_boxes = Vec::with_capacity(sample_entry_infos.len()); + for sample_entry_info in sample_entry_infos { + let sample_entry_box = read_box_bytes_sync(reader, &sample_entry_info)?; + sample_entry_boxes.push(sample_entry_box); + } + Ok((sample_entries, sample_entry_boxes)) +} + +fn extract_data_references_sync( + path: &Path, + reader: &mut R, + trak_info: &HeaderInfo, + track_id: u32, +) -> Result, MuxError> +where + R: Read + Seek, +{ + let dref_infos = extract_box( + reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, DINF, DREF]), + )?; + if dref_infos.is_empty() { + return Ok(vec![ImportedDataReference::SelfContained]); + } + let [dref_info] = dref_infos.as_slice() else { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} exposes multiple data-reference tables"), + }); + }; + let dref = extract_required_single_as_sync::<_, Dref>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, DINF, DREF]), + "dref", + )?; + let entry_infos = extract_box(reader, Some(dref_info), BoxPath::from([FourCc::ANY]))?; + if usize::try_from(dref.entry_count).unwrap_or(usize::MAX) != entry_infos.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} declares {} data references but exposes {} entries", + dref.entry_count, + entry_infos.len() + ), + }); + } + let mut references = Vec::with_capacity(entry_infos.len()); + for entry_info in entry_infos { + let entry_bytes = read_box_bytes_sync(reader, &entry_info)?; + let reference = match entry_info.box_type() { + value if value == URL => { + let url = super::mp4::decode_typed_box::(&entry_bytes)?; + if url.flags() & 1 != 0 { + ImportedDataReference::SelfContained + } else { + ImportedDataReference::LocalFile(resolve_local_data_reference_path( + path, + track_id, + &url.location, + )?) + } + } + value if value == URN => { + let urn = super::mp4::decode_typed_box::(&entry_bytes)?; + if urn.flags() & 1 != 0 { + ImportedDataReference::SelfContained + } else { + ImportedDataReference::LocalFile(resolve_local_data_reference_path( + path, + track_id, + &urn.location, + )?) + } + } + value => { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} uses unsupported data-reference entry `{value}`" + ), + }); + } + }; + references.push(reference); + } + Ok(references) +} + +#[cfg(feature = "async")] +async fn extract_stsd_sample_entries_async( + path: &Path, + reader: &mut R, + stsd_info: &HeaderInfo, + stsd: &crate::boxes::iso14496_12::Stsd, + track_id: u32, +) -> Result<(Vec, Vec>), MuxError> +where + R: AsyncReadSeek, +{ + let sample_entry_infos = + extract_box_async(reader, Some(stsd_info), BoxPath::from([FourCc::ANY])) + .await? + .into_iter() + .filter(|info| !stsd_child_is_padding(info.box_type())) + .collect::>(); + if sample_entry_infos.is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {} does not expose a sample-entry payload", track_id), + }); + } + if usize::try_from(stsd.entry_count).unwrap_or(usize::MAX) != sample_entry_infos.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {} declares {} sample descriptions but exposes {} sample-entry payloads", + track_id, + stsd.entry_count, + sample_entry_infos.len() + ), + }); + } + + let sample_entries = + extract_box_with_payload_async(reader, Some(stsd_info), BoxPath::from([FourCc::ANY])) + .await? + .into_iter() + .filter(|entry| !stsd_child_is_padding(entry.info.box_type())) + .collect::>(); + if sample_entries.len() != sample_entry_infos.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} exposes inconsistent sample-entry payloads"), + }); + } + let mut sample_entry_boxes = Vec::with_capacity(sample_entry_infos.len()); + for sample_entry_info in sample_entry_infos { + let sample_entry_box = read_box_bytes_async(reader, &sample_entry_info).await?; + sample_entry_boxes.push(sample_entry_box); + } + Ok((sample_entries, sample_entry_boxes)) +} + +#[cfg(feature = "async")] +async fn extract_data_references_async( + path: &Path, + reader: &mut R, + trak_info: &HeaderInfo, + track_id: u32, +) -> Result, MuxError> +where + R: AsyncReadSeek, +{ + let dref_infos = extract_box_async( + reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, DINF, DREF]), + ) + .await?; + if dref_infos.is_empty() { + return Ok(vec![ImportedDataReference::SelfContained]); + } + let [dref_info] = dref_infos.as_slice() else { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} exposes multiple data-reference tables"), + }); + }; + let dref = extract_required_single_as_async::<_, Dref>( + reader, + trak_info, + BoxPath::from([MDIA, MINF, DINF, DREF]), + "dref", + ) + .await?; + let entry_infos = + extract_box_async(reader, Some(dref_info), BoxPath::from([FourCc::ANY])).await?; + if usize::try_from(dref.entry_count).unwrap_or(usize::MAX) != entry_infos.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} declares {} data references but exposes {} entries", + dref.entry_count, + entry_infos.len() + ), + }); + } + let mut references = Vec::with_capacity(entry_infos.len()); + for entry_info in entry_infos { + let entry_bytes = read_box_bytes_async(reader, &entry_info).await?; + let reference = match entry_info.box_type() { + value if value == URL => { + let url = super::mp4::decode_typed_box::(&entry_bytes)?; + if url.flags() & 1 != 0 { + ImportedDataReference::SelfContained + } else { + ImportedDataReference::LocalFile(resolve_local_data_reference_path( + path, + track_id, + &url.location, + )?) + } + } + value if value == URN => { + let urn = super::mp4::decode_typed_box::(&entry_bytes)?; + if urn.flags() & 1 != 0 { + ImportedDataReference::SelfContained + } else { + ImportedDataReference::LocalFile(resolve_local_data_reference_path( + path, + track_id, + &urn.location, + )?) + } + } + value => { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} uses unsupported data-reference entry `{value}`" + ), + }); + } + }; + references.push(reference); + } + Ok(references) +} + +fn resolve_local_data_reference_path( + movie_path: &Path, + track_id: u32, + location: &str, +) -> Result { + if location.is_empty() || location.contains('\0') { + return Err(MuxError::UnsupportedTrackImport { + spec: movie_path.display().to_string(), + message: format!("track {track_id} uses an empty or invalid data-reference location"), + }); + } + let mut reference_path = if location.starts_with("file:") { + if location.contains('%') { + return Err(MuxError::UnsupportedTrackImport { + spec: movie_path.display().to_string(), + message: format!("track {track_id} uses an encoded data-reference location"), + }); + } + resolve_local_data_reference_file_uri_path(location).ok_or_else(|| { + MuxError::UnsupportedTrackImport { + spec: movie_path.display().to_string(), + message: format!("track {track_id} uses a non-local data-reference location"), + } + })? + } else if location.contains("://") { + return Err(MuxError::UnsupportedTrackImport { + spec: movie_path.display().to_string(), + message: format!("track {track_id} uses a non-local data-reference location"), + }); + } else { + if location.contains('%') { + return Err(MuxError::UnsupportedTrackImport { + spec: movie_path.display().to_string(), + message: format!("track {track_id} uses an encoded data-reference location"), + }); + } + PathBuf::from(location) + }; + if reference_path.as_os_str().is_empty() { + return Err(MuxError::UnsupportedTrackImport { + spec: movie_path.display().to_string(), + message: format!("track {track_id} uses an empty data-reference path"), + }); + } + if reference_path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(MuxError::UnsupportedTrackImport { + spec: movie_path.display().to_string(), + message: format!("track {track_id} uses an unsafe data-reference path"), + }); + } + if !reference_path.is_absolute() { + reference_path = movie_path + .parent() + .unwrap_or_else(|| Path::new("")) + .join(reference_path); + } + absolute_path(&reference_path) +} + +fn resolve_local_data_reference_file_uri_path(uri: &str) -> Option { + let rest = uri.strip_prefix("file:")?; + if let Some(path) = rest.strip_prefix("///") { + return resolve_local_data_reference_absolute_file_uri_path(path); + } + if let Some(authority_path) = rest.strip_prefix("//") { + let (authority, path) = authority_path.split_once('/')?; + if authority.eq_ignore_ascii_case("localhost") { + return resolve_local_data_reference_absolute_file_uri_path(path); + } + return resolve_local_data_reference_authority_file_uri_path(authority, path); + } + if let Some(path) = rest.strip_prefix('/') { + return resolve_local_data_reference_single_slash_file_uri_path(path); + } + None +} + +#[cfg(windows)] +fn resolve_local_data_reference_single_slash_file_uri_path(path: &str) -> Option { + if path.len() >= 2 && path.as_bytes()[1] == b':' && path.as_bytes()[0].is_ascii_alphabetic() { + Some(PathBuf::from(path)) + } else { + None + } +} + +#[cfg(not(windows))] +fn resolve_local_data_reference_single_slash_file_uri_path(path: &str) -> Option { + resolve_local_data_reference_absolute_file_uri_path(path) +} + +#[cfg(windows)] +fn resolve_local_data_reference_absolute_file_uri_path(path: &str) -> Option { + if path.len() >= 2 && path.as_bytes()[1] == b':' && path.as_bytes()[0].is_ascii_alphabetic() { + Some(PathBuf::from(path)) + } else if path.starts_with('/') { + Some(PathBuf::from(format!( + r"\\{}", + path.trim_start_matches('/').replace('/', "\\") + ))) + } else if path.is_empty() { + None + } else { + Some(PathBuf::from(path)) + } +} + +#[cfg(windows)] +fn resolve_local_data_reference_authority_file_uri_path( + authority: &str, + path: &str, +) -> Option { + if authority.is_empty() || path.is_empty() { + None + } else if authority.len() == 2 + && authority.as_bytes()[1] == b':' + && authority.as_bytes()[0].is_ascii_alphabetic() + { + Some(PathBuf::from(format!("{authority}/{path}"))) + } else { + Some(PathBuf::from(format!( + r"\\{}\{}", + authority, + path.replace('/', "\\") + ))) + } +} + +#[cfg(not(windows))] +fn resolve_local_data_reference_absolute_file_uri_path(path: &str) -> Option { + if path.is_empty() { + None + } else { + Some(PathBuf::from(format!("/{}", path.trim_start_matches('/')))) + } +} + +#[cfg(not(windows))] +fn resolve_local_data_reference_authority_file_uri_path( + _authority: &str, + _path: &str, +) -> Option { + None +} + +fn sample_entry_data_reference_index( + path: &Path, + track_id: u32, + sample_entry_box: &[u8], + sample_description_index: u32, +) -> Result { + let Some(bytes) = sample_entry_box.get(14..16) else { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} sample description {sample_description_index} is truncated before the data-reference index" + ), + }); + }; + Ok(u16::from_be_bytes([bytes[0], bytes[1]])) +} + +fn sample_data_references_for_description_indices( + path: &Path, + track_id: u32, + sample_entry_boxes: &[Vec], + data_references: &[ImportedDataReference], + sample_description_indices: &[u32], +) -> Result, MuxError> { + let mut resolved = Vec::with_capacity(sample_description_indices.len()); + for sample_description_index in sample_description_indices.iter().copied() { + let description_index = usize::try_from(sample_description_index.saturating_sub(1)) + .map_err(|_| MuxError::LayoutOverflow("sample description index conversion"))?; + let Some(sample_entry_box) = sample_entry_boxes.get(description_index) else { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} uses sample description index {sample_description_index} with {} sample entries", + sample_entry_boxes.len() + ), + }); + }; + let data_reference_index = sample_entry_data_reference_index( + path, + track_id, + sample_entry_box, + sample_description_index, + )?; + if data_reference_index == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} sample description {sample_description_index} uses data-reference index 0" + ), + }); + } + let reference_index = usize::from(data_reference_index - 1); + let Some(reference) = data_references.get(reference_index) else { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} sample description {sample_description_index} references missing data-reference {data_reference_index}" + ), + }); + }; + resolved.push(reference.clone()); + } + Ok(resolved) +} + +fn read_box_bytes_sync(reader: &mut R, info: &HeaderInfo) -> Result, MuxError> +where + R: Read + Seek, +{ + let size = + usize::try_from(info.size()).map_err(|_| MuxError::LayoutOverflow("box byte range"))?; + let mut bytes = vec![0_u8; size]; + reader.seek(SeekFrom::Start(info.offset()))?; + reader.read_exact(&mut bytes)?; + Ok(bytes) +} + +#[cfg(feature = "async")] +async fn read_box_bytes_async(reader: &mut R, info: &HeaderInfo) -> Result, MuxError> +where + R: AsyncReadSeek, +{ + let size = + usize::try_from(info.size()).map_err(|_| MuxError::LayoutOverflow("box byte range"))?; + let mut bytes = vec![0_u8; size]; + reader.seek(SeekFrom::Start(info.offset())).await?; + reader.read_exact(&mut bytes).await?; + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::{ + ImportedMp4TrackCarry, ImportedSample, ImportedTrack, ImportedTrackHeaderPolicy, + ImportedTrackMuxPolicy, MuxTrackKind, SelectedImportedMp4CarryMap, SourceCatalog, + SourceSpec, assign_imported_track_ids, choose_file_config, finish_prepared_request, + stsd_child_is_padding, + }; + use crate::FourCc; + use crate::boxes::iso14496_12::{ + Elst, ElstEntry, Stsc, StscEntry, TFHD_DEFAULT_BASE_IS_MOOF, + TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, TFHD_DEFAULT_SAMPLE_SIZE_PRESENT, + TFHD_SAMPLE_DESCRIPTION_INDEX_PRESENT, TRUN_DATA_OFFSET_PRESENT, Tfhd, Trun, + }; + use crate::codec::MutableBox; + use crate::mux::{ + MuxDurationMode, MuxFileConfig, MuxMp4TrackSelector, MuxOutputLayout, MuxRequest, + MuxTrackSpec, + }; + use crate::walk::BoxPath; + use std::collections::BTreeMap; + use std::io::Cursor; + use std::path::PathBuf; + + fn mux_fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("mux") + .join(name) + } + + #[test] + fn resolve_local_data_reference_path_keeps_plain_relative_paths_relative_to_movie() { + let movie_path = std::env::current_dir().unwrap().join("movie.mp4"); + let resolved = + super::resolve_local_data_reference_path(&movie_path, 1, "sidecar.bin").unwrap(); + + assert_eq!(resolved, movie_path.parent().unwrap().join("sidecar.bin")); + assert!( + super::resolve_local_data_reference_path(&movie_path, 1, "file:sidecar.bin").is_err() + ); + assert!( + super::resolve_local_data_reference_path(&movie_path, 1, "https://example.invalid/a") + .is_err() + ); + } + + #[cfg(unix)] + #[test] + fn resolve_local_data_reference_path_keeps_unix_file_uri_paths() { + let movie_path = PathBuf::from("/tmp/movie.mp4"); + + assert_eq!( + super::resolve_local_data_reference_path( + &movie_path, + 1, + "file:///tmp/media/segment.mdat" + ) + .unwrap(), + PathBuf::from("/tmp/media/segment.mdat") + ); + assert_eq!( + super::resolve_local_data_reference_path( + &movie_path, + 1, + "file:////tmp/media/segment.mdat" + ) + .unwrap(), + PathBuf::from("/tmp/media/segment.mdat") + ); + assert_eq!( + super::resolve_local_data_reference_path( + &movie_path, + 1, + "file://localhost/private/var/tmp/segment.mdat" + ) + .unwrap(), + PathBuf::from("/private/var/tmp/segment.mdat") + ); + assert_eq!( + super::resolve_local_data_reference_path( + &movie_path, + 1, + "file://LOCALHOST/tmp/media/segment.mdat" + ) + .unwrap(), + PathBuf::from("/tmp/media/segment.mdat") + ); + assert_eq!( + super::resolve_local_data_reference_path( + &movie_path, + 1, + "file:/tmp/media/segment.mdat" + ) + .unwrap(), + PathBuf::from("/tmp/media/segment.mdat") + ); + assert!( + super::resolve_local_data_reference_path( + &movie_path, + 1, + "file://media/assets/segment.mdat" + ) + .is_err() + ); + } + + #[cfg(windows)] + #[test] + fn resolve_local_data_reference_path_keeps_windows_file_uri_paths() { + let movie_path = PathBuf::from("C:/movie.mp4"); + + assert_eq!( + super::resolve_local_data_reference_path( + &movie_path, + 1, + "file:///C:/media/segment.mdat" + ) + .unwrap(), + PathBuf::from("C:/media/segment.mdat") + ); + assert_eq!( + super::resolve_local_data_reference_path( + &movie_path, + 1, + "file://C:/media/segment.mdat" + ) + .unwrap(), + PathBuf::from("C:/media/segment.mdat") + ); + assert_eq!( + super::resolve_local_data_reference_path( + &movie_path, + 1, + "file://LOCALHOST/C:/media/segment.mdat" + ) + .unwrap(), + PathBuf::from("C:/media/segment.mdat") + ); + assert_eq!( + super::resolve_local_data_reference_path(&movie_path, 1, "file:/C:/media/segment.mdat") + .unwrap(), + PathBuf::from("C:/media/segment.mdat") + ); + assert_eq!( + super::resolve_local_data_reference_path( + &movie_path, + 1, + "file:////media/assets/segment.mdat" + ) + .unwrap(), + PathBuf::from(r"\\media\assets\segment.mdat") + ); + assert_eq!( + super::resolve_local_data_reference_path( + &movie_path, + 1, + "file://media/assets/segment.mdat" + ) + .unwrap(), + PathBuf::from(r"\\media\assets\segment.mdat") + ); + } + + fn imported_track( + kind: MuxTrackKind, + preferred_track_id: Option, + source_index: usize, + ) -> ImportedTrack { + let mux_policy = preferred_track_id + .map(|track_id| ImportedTrackMuxPolicy::DEFAULT.with_preferred_track_id(track_id)) + .unwrap_or(ImportedTrackMuxPolicy::DEFAULT); + ImportedTrack { + kind, + timescale: 1, + language: *b"und", + handler_name: String::new(), + mux_policy, + width: 0, + height: 0, + sample_entry_box: Vec::new(), + source_edit_media_time: None, + sample_roll_distance: None, + samples: vec![ImportedSample { + source_index, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }], + } + } + + fn avc_sample_entry_box_for_sync_supplement_tests() -> Vec { + let avcc = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::AVCDecoderConfiguration { + configuration_version: 1, + profile: 100, + profile_compatibility: 0, + level: 31, + length_size_minus_one: 3, + ..crate::boxes::iso14496_12::AVCDecoderConfiguration::default() + }, + &[], + ) + .unwrap(); + crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"avc1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &avcc, + ) + .unwrap() + } + + #[test] + fn assign_imported_track_ids_uses_source_order_slots_for_unpreferred_tracks() { + let imported_tracks = vec![ + imported_track(MuxTrackKind::Video, Some(256), 0), + imported_track(MuxTrackKind::Audio, None, 1), + imported_track(MuxTrackKind::Audio, Some(448), 2), + ]; + + let assigned = assign_imported_track_ids(&imported_tracks, true).unwrap(); + + assert_eq!(assigned, vec![256, 2, 448]); + } + + #[test] + fn assign_imported_track_ids_keeps_earlier_source_order_slot() { + let imported_tracks = vec![ + imported_track(MuxTrackKind::Video, None, 0), + imported_track(MuxTrackKind::Subtitle, Some(1), 1), + ]; + + let assigned = assign_imported_track_ids(&imported_tracks, true).unwrap(); + + assert_eq!(assigned, vec![1, 2]); + } + + #[test] + fn assign_imported_track_ids_uses_sequential_order_for_fragmented_output() { + let imported_tracks = vec![ + imported_track(MuxTrackKind::Video, Some(256), 0), + imported_track(MuxTrackKind::Audio, None, 1), + imported_track(MuxTrackKind::Audio, Some(448), 2), + ]; + + let assigned = assign_imported_track_ids(&imported_tracks, false).unwrap(); + + assert_eq!(assigned, vec![1, 2, 3]); + } + + fn raw_track_reference_box(child_type: FourCc, track_ids: &[u32]) -> Vec { + let payload = track_ids + .iter() + .flat_map(|track_id| track_id.to_be_bytes()) + .collect::>(); + let child = crate::mux::mp4::encode_raw_box(child_type, &payload).unwrap(); + crate::mux::mp4::encode_raw_box(super::TREF, &child).unwrap() + } + + fn remapped_tref_child_ids(tref_box: &[u8]) -> Vec { + let child_payload = &tref_box[16..]; + child_payload + .chunks_exact(4) + .map(|bytes| u32::from_be_bytes(bytes.try_into().unwrap())) + .collect() + } + + #[test] + fn remap_preserved_flat_trak_boxes_remaps_tref_child_track_ids() { + let mut imported_track = imported_track(MuxTrackKind::Audio, None, 4); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(11), + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + let mut remap = BTreeMap::new(); + remap.insert((4, 11), 101); + remap.insert((4, 12), 102); + remap.insert((4, 13), 103); + let tref = raw_track_reference_box(FourCc::from_bytes(*b"cdsc"), &[12, 13]); + + let boxes = super::remap_preserved_flat_trak_boxes( + &remap, + super::imported_track_source_key(&imported_track), + &imported_track, + vec![tref], + ) + .unwrap(); + + assert_eq!(boxes.len(), 1); + assert_eq!(remapped_tref_child_ids(&boxes[0]), vec![102, 103]); + } + + #[test] + fn remap_preserved_flat_trak_boxes_rejects_unavailable_tref_targets() { + let mut imported_track = imported_track(MuxTrackKind::Video, None, 0); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(7), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + let mut remap = BTreeMap::new(); + remap.insert((0, 7), 1); + let tref = raw_track_reference_box(FourCc::from_bytes(*b"sync"), &[9]); + + let error = super::remap_preserved_flat_trak_boxes( + &remap, + super::imported_track_source_key(&imported_track), + &imported_track, + vec![tref], + ) + .unwrap_err(); + + assert!(error.to_string().contains("references unavailable track 9")); + } + + #[test] + fn extract_preserved_flat_stbl_boxes_keeps_side_metadata_allowlist() { + let padb = FourCc::from_bytes(*b"padb"); + let children = [ + crate::mux::mp4::encode_raw_box(super::CSLG, &[0; 4]).unwrap(), + crate::mux::mp4::encode_raw_box(super::SDTP, &[1, 2]).unwrap(), + crate::mux::mp4::encode_raw_box(super::STPS, &[0, 0, 0, 1]).unwrap(), + crate::mux::mp4::encode_raw_box(padb, &[0; 4]).unwrap(), + crate::mux::mp4::encode_raw_box(super::STDP, &[0; 4]).unwrap(), + crate::mux::mp4::encode_raw_box(super::SUBS, &[0; 8]).unwrap(), + crate::mux::mp4::encode_raw_box(super::SGPD, &[0; 8]).unwrap(), + crate::mux::mp4::encode_raw_box(super::SBGP, &[0; 8]).unwrap(), + ] + .concat(); + let stbl = crate::mux::mp4::encode_raw_box(super::STBL, &children).unwrap(); + let mut reader = Cursor::new(stbl); + let stbl_info = crate::BoxInfo::read(&mut reader).unwrap(); + + let preserved = + super::extract_preserved_flat_stbl_boxes_sync(&mut reader, &stbl_info).unwrap(); + + assert_eq!( + preserved + .iter() + .filter_map(|box_bytes| super::box_header_type(box_bytes)) + .collect::>(), + vec![ + super::CSLG, + super::SDTP, + super::STPS, + super::STDP, + super::SUBS, + super::SGPD, + super::SBGP, + ] + ); + } + + #[test] + fn collect_fragment_candidate_samples_carries_sample_description_index() { + let path = PathBuf::from("fragmented-input.mp4"); + let context = super::FragmentRunContext { + path: path.as_path(), + source_index: 0, + track_id: 1, + moof_offset: 100, + trex: None, + }; + let mut tfhd = Tfhd::default(); + tfhd.track_id = 1; + tfhd.sample_description_index = 2; + tfhd.default_sample_duration = 10; + tfhd.default_sample_size = 4; + tfhd.set_flags( + TFHD_DEFAULT_BASE_IS_MOOF + | TFHD_DEFAULT_SAMPLE_DURATION_PRESENT + | TFHD_DEFAULT_SAMPLE_SIZE_PRESENT + | TFHD_SAMPLE_DESCRIPTION_INDEX_PRESENT, + ); + let mut trun = Trun::default(); + trun.sample_count = 1; + trun.data_offset = 24; + trun.set_flags(TRUN_DATA_OFFSET_PRESENT); + let trun_info = crate::BoxInfo::new(super::TRUN, 20).with_offset(120); + let mut output = Vec::new(); + let mut sample_description_indices = Vec::new(); + + super::collect_fragment_candidate_samples_from_runs( + &context, + &tfhd, + &[trun], + &[trun_info], + &mut output, + &mut sample_description_indices, + ) + .unwrap(); + + assert_eq!(output.len(), 1); + assert_eq!(sample_description_indices, vec![2]); + } + + #[test] + fn imported_mp4_avc_nal_is_intra_slice_detects_i_slice() { + let intra_slice_nal = [0x41, 0xB8]; + + assert!(super::imported_mp4_avc_nal_is_intra_slice(&intra_slice_nal)); + } + + #[test] + fn imported_mp4_avc_sample_contains_sync_nal_accepts_invalid_length_prefixed_idr_header() { + let malformed_idr_prefixed_sample = [0xFD, 0x9D, 0x60, 0x65, 0x25, 0x66]; + + assert!(super::imported_mp4_avc_sample_contains_sync_nal( + &malformed_idr_prefixed_sample, + 4 + )); + } + + #[test] + fn stsd_child_padding_filter_accepts_only_padding_box_types() { + assert!(stsd_child_is_padding(FourCc::ANY)); + assert!(stsd_child_is_padding(FourCc::from_bytes(*b"free"))); + assert!(stsd_child_is_padding(FourCc::from_bytes(*b"skip"))); + assert!(stsd_child_is_padding(FourCc::from_bytes(*b"wide"))); + assert!(!stsd_child_is_padding(FourCc::from_bytes(*b"spex"))); + assert!(!stsd_child_is_padding(FourCc::from_bytes(*b"mp4a"))); + } + + #[test] + fn imported_mp4_avc_sync_supplement_tolerates_truncated_best_effort_reads() { + let sample_entry_box = avc_sample_entry_box_for_sync_supplement_tests(); + let mut reader = Cursor::new(vec![0_u8; 3]); + let mut sync_samples = vec![false]; + + super::supplement_imported_mp4_avc_sync_samples_sync( + &mut reader, + FourCc::from_bytes(*b"avc1"), + &sample_entry_box, + None, + &[0], + &[4], + &mut sync_samples, + ) + .unwrap(); + + assert_eq!(sync_samples, vec![false]); + } + + #[test] + fn imported_mp4_avc_sync_supplement_widens_with_first_only_source_stss() { + let sample_entry_box = avc_sample_entry_box_for_sync_supplement_tests(); + let mut reader = Cursor::new(vec![0, 0, 0, 1, 0x65]); + let mut sync_samples = vec![false]; + let mut stss = crate::boxes::iso14496_12::Stss::default(); + stss.entry_count = 1; + stss.sample_number = vec![1]; + + super::supplement_imported_mp4_avc_sync_samples_sync( + &mut reader, + FourCc::from_bytes(*b"avc1"), + &sample_entry_box, + Some(&stss), + &[0], + &[5], + &mut sync_samples, + ) + .unwrap(); + + assert_eq!(sync_samples, vec![true]); + } + + #[test] + fn imported_mp4_avc_sync_supplement_widens_without_source_stss() { + let sample_entry_box = avc_sample_entry_box_for_sync_supplement_tests(); + let mut reader = Cursor::new(vec![0, 0, 0, 1, 0x65]); + let mut sync_samples = vec![false]; + + super::supplement_imported_mp4_avc_sync_samples_sync( + &mut reader, + FourCc::from_bytes(*b"avc1"), + &sample_entry_box, + None, + &[0], + &[5], + &mut sync_samples, + ) + .unwrap(); + + assert_eq!(sync_samples, vec![true]); + } + + #[test] + fn imported_mp4_avc_sync_supplement_widens_multi_entry_source_stss() { + let sample_entry_box = avc_sample_entry_box_for_sync_supplement_tests(); + let mut reader = Cursor::new(vec![0, 0, 0, 1, 0x65]); + let mut sync_samples = vec![false]; + let mut stss = crate::boxes::iso14496_12::Stss::default(); + stss.entry_count = 2; + stss.sample_number = vec![1, 3]; + + super::supplement_imported_mp4_avc_sync_samples_sync( + &mut reader, + FourCc::from_bytes(*b"avc1"), + &sample_entry_box, + Some(&stss), + &[0], + &[5], + &mut sync_samples, + ) + .unwrap(); + + assert_eq!(sync_samples, vec![true]); + } + + #[test] + fn restore_preserved_imported_source_sync_samples_restores_explicit_avc_table() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.sample_entry_box = avc_sample_entry_box_for_sync_supplement_tests(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 1, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: false, + }, + ImportedSample { + source_index: 0, + data_offset: 2, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + let carry = ImportedMp4TrackCarry { + flat_chunk_sample_counts: None, + flat_stsc: None, + sample_entry_boxes: None, + sample_description_indices: None, + fragmented_decode_time_gaps: Vec::new(), + source_had_empty_stts: false, + source_sync_samples: Some(vec![true, false, false]), + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + }; + + super::restore_preserved_imported_source_sync_samples(&mut imported_track, Some(&carry)); + + assert_eq!( + imported_track + .samples + .iter() + .map(|sample| sample.is_sync_sample) + .collect::>(), + vec![true, false, false] + ); + } + + #[test] + fn restore_preserved_imported_source_sync_samples_keeps_first_only_widening() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.sample_entry_box = avc_sample_entry_box_for_sync_supplement_tests(); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_stss_first_only: true, + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 1, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + let carry = ImportedMp4TrackCarry { + flat_chunk_sample_counts: None, + flat_stsc: None, + sample_entry_boxes: None, + sample_description_indices: None, + fragmented_decode_time_gaps: Vec::new(), + source_had_empty_stts: false, + source_sync_samples: Some(vec![true, false]), + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + }; + + super::restore_preserved_imported_source_sync_samples(&mut imported_track, Some(&carry)); + + assert_eq!( + imported_track + .samples + .iter() + .map(|sample| sample.is_sync_sample) + .collect::>(), + vec![true, true] + ); + } + + #[test] + fn imported_track_edit_segment_duration_rescales_from_source_movie_timescale() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(2), 0); + imported_track.timescale = 44_100; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(2), + source_movie_timescale: Some(1_000), + source_edit_segment_duration: Some(2_740), + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + + assert_eq!( + super::imported_track_source_edit_segment_duration(&imported_track), + Some(120_834) + ); + } + + #[test] + fn imported_track_edit_segment_duration_ignores_zero_source_segment_span() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(2), 0); + imported_track.timescale = 90_000; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(2), + source_movie_timescale: Some(1_000), + source_edit_segment_duration: Some(0), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::imported_track_source_edit_segment_duration(&imported_track), + None + ); + } + + #[test] + fn imported_track_source_edit_media_time_from_elst_skips_leading_empty_edit() { + let mut elst = super::Elst::default(); + elst.entry_count = 2; + elst.entries = vec![ + ElstEntry { + segment_duration_v0: 5_214, + media_time_v0: -1, + media_rate_integer: 1, + ..ElstEntry::default() + }, + ElstEntry { + segment_duration_v0: 4_800, + media_time_v0: 0, + media_rate_integer: 1, + ..ElstEntry::default() + }, + ]; + + assert_eq!( + super::imported_track_source_edit_media_time_from_elst(&elst), + Some(0) + ); + } + + #[test] + fn imported_track_source_edit_segment_duration_from_elst_sums_all_entries() { + let mut elst = super::Elst::default(); + elst.entry_count = 2; + elst.entries = vec![ + ElstEntry { + segment_duration_v0: 5_214, + media_time_v0: -1, + media_rate_integer: 1, + ..ElstEntry::default() + }, + ElstEntry { + segment_duration_v0: 4_800, + media_time_v0: 0, + media_rate_integer: 1, + ..ElstEntry::default() + }, + ]; + + assert_eq!( + super::imported_track_source_edit_segment_duration_from_elst(&elst), + Some(10_014) + ); + } + + #[test] + fn preserved_flat_elst_has_material_timing_drops_identity_multi_entry_edit_list() { + let mut elst = super::Elst::default(); + elst.entry_count = 2; + elst.entries = vec![ + ElstEntry { + segment_duration_v0: 0, + media_time_v0: -1, + media_rate_integer: 1, + ..ElstEntry::default() + }, + ElstEntry { + segment_duration_v0: 24_978, + media_time_v0: 0, + media_rate_integer: 1, + ..ElstEntry::default() + }, + ]; + + assert!(!super::preserved_flat_elst_has_material_timing( + &elst, + Some(24_978) + )); + } + + #[test] + fn preserved_flat_elst_has_material_timing_keeps_leading_empty_offset() { + let mut elst = super::Elst::default(); + elst.entry_count = 2; + elst.entries = vec![ + ElstEntry { + segment_duration_v0: 5, + media_time_v0: -1, + media_rate_integer: 1, + ..ElstEntry::default() + }, + ElstEntry { + segment_duration_v0: 20, + media_time_v0: 0, + media_rate_integer: 1, + ..ElstEntry::default() + }, + ]; + + assert!(super::preserved_flat_elst_has_material_timing( + &elst, + Some(20) + )); + } + + #[test] + fn imported_track_elst_trailing_bytes_detects_inline_skip_tail() { + let mut elst = super::Elst::default(); + elst.entry_count = 2; + elst.entries = vec![ + ElstEntry { + segment_duration_v0: 610, + media_time_v0: -1, + media_rate_integer: 1, + ..ElstEntry::default() + }, + ElstEntry { + segment_duration_v0: 24_978, + media_time_v0: 0, + media_rate_integer: 1, + ..ElstEntry::default() + }, + ]; + + assert_eq!( + super::imported_track_elst_trailing_bytes(&elst, 1_224), + Some(1_184) + ); + } + + #[test] + fn imported_track_source_media_duration_preserves_source_mdhd_duration() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(7), 0); + imported_track.timescale = 11_520; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(7), + source_media_duration: Some(11_520), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::imported_track_source_media_duration(&imported_track), + Some(11_520) + ); + } + + #[test] + fn should_drop_truncated_terminal_imported_sample_preserves_complete_samples() { + assert!(!super::should_drop_truncated_terminal_imported_sample( + Some(100), + Some(50), + 200, + )); + assert!(!super::should_drop_truncated_terminal_imported_sample( + Some(100), + Some(50), + 150, + )); + } + + #[test] + fn should_drop_truncated_terminal_imported_sample_detects_truncated_tail_sample() { + assert!(super::should_drop_truncated_terminal_imported_sample( + Some(344_210), + Some(1_625), + 345_787, + )); + } + + #[test] + fn imported_sample_prefix_len_within_source_file_trims_truncated_suffix() { + assert_eq!( + super::imported_sample_prefix_len_within_source_file( + &[100, 200, 300], + &[50, 50, 50], + 320, + ), + 2 + ); + } + + #[test] + fn trim_flat_chunk_sample_counts_to_sample_count_keeps_partial_final_chunk() { + let mut counts = vec![3, 3, 3]; + + super::trim_flat_chunk_sample_counts_to_sample_count(&mut counts, 7).unwrap(); + + assert_eq!(counts, vec![3, 3, 1]); + } + + #[test] + fn choose_file_config_promotes_imported_dts_family_mp4_tracks_to_auto_flat_profile() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.sample_entry_box = { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&16_u32.to_be_bytes()); + bytes.extend_from_slice(b"dtsc"); + bytes.extend_from_slice(&[0_u8; 8]); + bytes + }; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(1), + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + let authority = MuxFileConfig::new(1000) + .with_major_brand(FourCc::from_bytes(*b"isom")) + .with_minor_version(512) + .with_compatible_brand(FourCc::from_bytes(*b"iso8")) + .with_compatible_brand(FourCc::from_bytes(*b"dtsc")); + + let file_config = choose_file_config( + 1000, + &[imported_track], + &SourceCatalog::default(), + Some(&authority), + false, + ); + + assert!(file_config.auto_flat_profile()); + assert!(file_config.allow_audio_only_iods()); + assert!(file_config.keep_flat_free_box()); + assert!(file_config.preserve_auto_flat_movie_timescale()); + assert!(file_config.keep_flat_authority_brands()); + assert_eq!(file_config.major_brand(), FourCc::from_bytes(*b"isom")); + assert_eq!(file_config.minor_version(), 1); + assert_eq!( + file_config.compatible_brands(), + [FourCc::from_bytes(*b"isom")] + ); + } + + #[test] + fn choose_file_config_uses_default_flat_movie_timescale_for_raw_dts_profiles() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.sample_entry_box = { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&16_u32.to_be_bytes()); + bytes.extend_from_slice(b"dtsc"); + bytes.extend_from_slice(&[0_u8; 8]); + bytes + }; + + let file_config = choose_file_config( + 90_000, + &[imported_track], + &SourceCatalog::default(), + None, + false, + ); + + assert!(file_config.auto_flat_profile()); + assert!(!file_config.allow_audio_only_iods()); + assert!(file_config.keep_flat_free_box()); + assert!(!file_config.preserve_auto_flat_movie_timescale()); + } + + #[test] + fn choose_file_config_rebuilds_flat_profile_for_imported_iamf_tracks() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.sample_entry_box = { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&16_u32.to_be_bytes()); + bytes.extend_from_slice(b"iamf"); + bytes.extend_from_slice(&[0_u8; 8]); + bytes + }; + imported_track.mux_policy = super::imported_iamf_mux_policy(MuxTrackKind::Audio); + let authority = MuxFileConfig::new(48_000) + .with_major_brand(FourCc::from_bytes(*b"mp42")) + .with_minor_version(0) + .with_compatible_brand(FourCc::from_bytes(*b"iso6")) + .with_compatible_brand(FourCc::from_bytes(*b"iamf")) + .with_keep_flat_authority_brands(true); + + let file_config = choose_file_config( + 48_000, + &[imported_track], + &SourceCatalog::default(), + Some(&authority), + false, + ); + + assert!(file_config.auto_flat_profile()); + assert!(!file_config.keep_flat_authority_brands()); + } + + #[test] + fn direct_iamf_policy_uses_open_ended_flat_timing_override() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.timescale = 48_000; + imported_track.sample_entry_box = { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&16_u32.to_be_bytes()); + bytes.extend_from_slice(b"iamf"); + bytes.extend_from_slice(&[0_u8; 8]); + bytes + }; + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 4, + duration: 1024, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 4, + data_size: 4, + duration: 1024, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + imported_track.mux_policy = super::direct_ingest_mux_policy("iamf", MuxTrackKind::Audio); + + let override_value = + super::flat_timing_override_for_imported_track(&imported_track, 48_000, false).unwrap(); + assert_eq!(override_value.sample_durations, vec![1, u32::MAX]); + assert_eq!(override_value.composition_offsets, vec![0, 0]); + assert_eq!(override_value.media_duration, u64::from(u32::MAX) + 1); + assert_eq!( + override_value.presentation_duration, + u64::from(u32::MAX) + 1 + ); + } + + #[test] + fn direct_pcm_policy_preserves_aifc_integer_special_case_but_not_floating_aifc() { + let aifc_pcm_samples = super::imported_pcm_samples( + 0, + 32, + 4, + 3, + FourCc::from_bytes(*b"ipcm"), + super::PcmContainerKind::Aifc, + ) + .unwrap(); + let aifc_float_samples = super::imported_pcm_samples( + 0, + 32, + 8, + 3, + FourCc::from_bytes(*b"fpcm"), + super::PcmContainerKind::Aifc, + ) + .unwrap(); + let wave_samples = super::imported_pcm_samples( + 0, + 32, + 4, + 3, + FourCc::from_bytes(*b"ipcm"), + super::PcmContainerKind::Wave, + ) + .unwrap(); + assert!(aifc_pcm_samples.iter().all(|sample| sample.duration == 0)); + assert!(aifc_float_samples.iter().all(|sample| sample.duration == 1)); + assert!(wave_samples.iter().all(|sample| sample.duration == 1)); + + let wave_policy = super::direct_pcm_mux_policy( + super::PcmContainerKind::Wave, + FourCc::from_bytes(*b"ipcm"), + ); + let aiff_policy = super::direct_pcm_mux_policy( + super::PcmContainerKind::Aiff, + FourCc::from_bytes(*b"ipcm"), + ); + let aifc_pcm_policy = super::direct_pcm_mux_policy( + super::PcmContainerKind::Aifc, + FourCc::from_bytes(*b"ipcm"), + ); + let aifc_float_policy = super::direct_pcm_mux_policy( + super::PcmContainerKind::Aifc, + FourCc::from_bytes(*b"fpcm"), + ); + + assert_eq!( + wave_policy.flat_chunking_mode, + super::FlatChunkingMode::Auto + ); + assert_eq!( + aiff_policy.flat_chunking_mode, + super::FlatChunkingMode::Auto + ); + assert_eq!( + aifc_pcm_policy.flat_chunking_mode, + super::FlatChunkingMode::OneSamplePerChunk + ); + assert_eq!( + aifc_float_policy.flat_chunking_mode, + super::FlatChunkingMode::Auto + ); + } + + fn mp4a_profile_esds( + object_type_indication: u8, + decoder_specific_info: &[u8], + ) -> crate::boxes::iso14496_14::Esds { + let mut esds = crate::boxes::iso14496_14::Esds::default(); + esds.descriptors = vec![ + crate::boxes::iso14496_14::Descriptor { + tag: crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some( + crate::boxes::iso14496_14::DecoderConfigDescriptor { + object_type_indication, + stream_type: 5, + reserved: true, + ..crate::boxes::iso14496_14::DecoderConfigDescriptor::default() + }, + ), + ..crate::boxes::iso14496_14::Descriptor::default() + }, + crate::boxes::iso14496_14::Descriptor { + tag: crate::boxes::iso14496_14::DECODER_SPECIFIC_INFO_TAG, + size: u32::try_from(decoder_specific_info.len()).unwrap(), + data: decoder_specific_info.to_vec(), + ..crate::boxes::iso14496_14::Descriptor::default() + }, + ]; + esds + } + + fn imported_mp4a_track_with_esds( + object_type_indication: u8, + decoder_specific_info: &[u8], + ) -> ImportedTrack { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + let esds = mp4a_profile_esds(object_type_indication, decoder_specific_info); + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::AudioSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..crate::boxes::iso14496_12::AudioSampleEntry::default() + }, + &crate::mux::mp4::encode_typed_box(&esds, &[]).unwrap(), + ) + .unwrap(); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + imported_track + } + + fn imported_speex_track() -> ImportedTrack { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::AudioSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"spex"), + data_reference_index: 1, + }, + channel_count: 1, + sample_size: 2, + sample_rate: 16_000 << 16, + ..crate::boxes::iso14496_12::AudioSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + imported_track + } + + #[test] + fn imported_track_should_not_rechunk_flat_xhe_audio() { + let imported_track = imported_mp4a_track_with_esds(0x40, &[0xF9, 0x46, 0x40]); + assert!(super::imported_track_uses_xhe_aac_family(&imported_track)); + assert!(super::imported_track_should_rechunk_flat_audio( + &imported_track + )); + } + + #[test] + fn imported_track_should_rechunk_flat_non_xhe_mp4a_audio() { + let imported_track = imported_mp4a_track_with_esds(0x40, &[0x10, 0x00]); + assert!(!super::imported_track_uses_xhe_aac_family(&imported_track)); + assert!(super::imported_track_should_rechunk_flat_audio( + &imported_track + )); + } + + #[test] + fn imported_track_suppresses_fragmented_roll_grouping_for_xhe_audio() { + let mut imported_track = imported_mp4a_track_with_esds(0x40, &[0xF9, 0x46, 0x40]); + imported_track.sample_roll_distance = Some(2); + assert!(super::imported_track_suppresses_fragmented_roll_grouping( + &imported_track, + MuxOutputLayout::Fragmented, + )); + assert!(!super::imported_track_suppresses_fragmented_roll_grouping( + &imported_track, + MuxOutputLayout::Flat, + )); + } + + #[test] + fn imported_track_should_not_rechunk_flat_vorbis_mp4a_audio() { + let imported_track = imported_mp4a_track_with_esds(0xDD, &[]); + assert!(super::imported_track_uses_mp4a_family(&imported_track)); + assert!(super::sample_entry_carries_oti( + &imported_track.sample_entry_box, + 0xDD + )); + assert!(!super::imported_track_should_rechunk_flat_audio( + &imported_track + )); + } + + #[test] + fn build_prev_sample_duration_chunk_sample_counts_uses_previous_sample_boundary() { + let counts = + super::build_prev_sample_duration_chunk_sample_counts(1, [10_u32, 10, 10, 10], 25) + .unwrap(); + assert_eq!(counts, vec![2, 2]); + } + + #[test] + fn build_imported_flat_audio_chunk_sample_counts_rechunks_vorbis_mp4a_by_duration() { + let mut imported_track = imported_mp4a_track_with_esds(0xDD, &[]); + imported_track.timescale = 48_000; + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 9_600, + duration: 12_000, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 9_600, + data_size: 9_600, + duration: 12_000, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 19_200, + data_size: 1_000, + duration: 1_000, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + + let counts = super::build_imported_flat_audio_chunk_sample_counts( + 1, + &imported_track, + imported_track + .samples + .iter() + .map(|sample| sample.duration) + .collect(), + ) + .unwrap(); + assert_eq!(counts, vec![2, 1]); + } + + #[test] + fn build_preserved_authority_flat_audio_chunk_sample_counts_aligns_to_video_windows() { + let mut imported_track = imported_mp4a_track_with_esds(0x40, &[]); + imported_track.timescale = 48_000; + imported_track.samples = (0..4_734) + .map(|sample_index| ImportedSample { + source_index: 0, + data_offset: (sample_index as u64) * 256, + data_size: 256, + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }) + .collect(); + let mut video_chunk_sample_counts = vec![11; 220]; + video_chunk_sample_counts.push(1); + let video_alignment = super::PreservedAuthorityFlatVideoAlignment { + timescale: 96_000, + sample_durations: vec![4_004; 2_421], + chunk_sample_counts: video_chunk_sample_counts, + }; + + let counts = super::build_preserved_authority_flat_audio_chunk_sample_counts( + 2, + &imported_track, + 1_000, + &video_alignment, + ) + .unwrap(); + + let expected_prefix = [ + 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 22, 22, 21, + ]; + let expected_suffix = [22, 21, 22, 21, 22, 21, 22, 21, 22, 1]; + + assert_eq!(counts.len(), 220); + assert_eq!(&counts[..expected_prefix.len()], &expected_prefix); + assert_eq!( + &counts[counts.len() - expected_suffix.len()..], + &expected_suffix + ); + assert_eq!(counts.iter().copied().sum::(), 4_734); + } + + #[test] + fn synthesize_imported_speex_elst_tail_uses_skip_box_bytes() { + let sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::AudioSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"spex"), + data_reference_index: 1, + }, + channel_count: 1, + sample_size: 2, + sample_rate: 16_000 << 16, + ..crate::boxes::iso14496_12::AudioSampleEntry::default() + }, + &crate::mux::mp4::encode_raw_box(FourCc::from_bytes(*b"skip"), &[0; 54]).unwrap(), + ) + .unwrap(); + let mut reader = Cursor::new(sample_entry_box.clone()); + let sample_entry = crate::extract::extract_box_with_payload( + &mut reader, + None, + BoxPath::from([FourCc::ANY]), + ) + .unwrap() + .into_iter() + .next() + .unwrap(); + let mut skip_reader = Cursor::new(sample_entry_box.clone()); + let _skip_info = crate::extract::extract_box( + &mut skip_reader, + None, + BoxPath::from([FourCc::ANY, FourCc::from_bytes(*b"skip")]), + ) + .unwrap() + .into_iter() + .next() + .unwrap(); + let mut elst = Elst::default(); + elst.entry_count = 1; + let mut sample_offsets = vec![10, 20]; + let mut sample_sizes = vec![4, 6]; + let mut sample_durations = vec![40, 0]; + let mut composition_offsets = vec![0, 0]; + let mut sample_description_indices = vec![1, 2]; + let mut sync_samples = vec![true, true]; + + let synthesized = super::synthesize_imported_speex_elst_tail_sync( + &mut reader, + &sample_entry, + Some(&elst), + Some(72), + &mut sample_offsets, + &mut sample_sizes, + &mut sample_durations, + &mut composition_offsets, + &mut sample_description_indices, + &mut sync_samples, + ) + .unwrap(); + + assert!(synthesized); + assert_eq!(sample_offsets, vec![10, 20, 10, 10]); + assert_eq!(sample_sizes, vec![4, 6, 4, 4]); + assert_eq!(sample_durations, vec![40, 44, 1, 0]); + assert_eq!(composition_offsets, vec![0, 0, 0, 0]); + assert_eq!(sample_description_indices, vec![1, 2, 1, 1]); + assert_eq!(sync_samples, vec![true, true, true, true]); + } + + #[test] + fn preserved_imported_flat_audio_chunk_sample_counts_derives_terminal_partial_chunk_from_stsc() + { + let imported_track = imported_mp4a_track_with_esds(0xDD, &[]); + let mut flat_stsc = Stsc::default(); + flat_stsc.entry_count = 1; + flat_stsc.entries = vec![StscEntry { + first_chunk: 1, + samples_per_chunk: 3, + sample_description_index: 1, + }]; + let mut carry = ImportedMp4TrackCarry { + flat_chunk_sample_counts: None, + flat_stsc: Some(flat_stsc), + sample_entry_boxes: None, + sample_description_indices: None, + fragmented_decode_time_gaps: Vec::new(), + source_had_empty_stts: false, + source_sync_samples: None, + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + }; + let mut imported_track = imported_track; + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }; + 5 + ]; + let counts = super::preserved_imported_flat_audio_chunk_sample_counts( + &imported_track, + Some(&carry), + None, + ) + .unwrap(); + assert_eq!(counts, vec![3, 2]); + carry.flat_chunk_sample_counts = Some(vec![2, 3]); + let counts = super::preserved_imported_flat_audio_chunk_sample_counts( + &imported_track, + Some(&carry), + None, + ) + .unwrap(); + assert_eq!(counts, vec![2, 3]); + } + + #[test] + fn preserved_imported_flat_audio_chunk_sample_counts_prefers_explicit_counts_for_vorbis_mp4a() { + let mut imported_track = imported_mp4a_track_with_esds(0xDD, &[]); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }; + 5 + ]; + let mut flat_stsc = Stsc::default(); + flat_stsc.entry_count = 2; + flat_stsc.entries = vec![ + StscEntry { + first_chunk: 1, + samples_per_chunk: 3, + sample_description_index: 1, + }, + StscEntry { + first_chunk: 2, + samples_per_chunk: 2, + sample_description_index: 1, + }, + ]; + let carry = ImportedMp4TrackCarry { + flat_chunk_sample_counts: Some(vec![2, 3]), + flat_stsc: Some(flat_stsc), + sample_entry_boxes: None, + sample_description_indices: None, + fragmented_decode_time_gaps: Vec::new(), + source_had_empty_stts: false, + source_sync_samples: None, + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + }; + + let counts = super::preserved_imported_flat_audio_chunk_sample_counts( + &imported_track, + Some(&carry), + None, + ) + .unwrap(); + assert_eq!(counts, vec![2, 3]); + } + + #[test] + fn preserved_imported_flat_audio_chunk_sample_counts_normalizes_multichannel_vorbis_mp4a() { + let mut imported_track = imported_mp4a_track_with_esds(0xDD, &[]); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }; + 7_083 + ]; + let carry = ImportedMp4TrackCarry { + flat_chunk_sample_counts: Some( + super::PRESERVED_VORBIS51_FLAT_SOURCE_CHUNK_SAMPLE_COUNTS.to_vec(), + ), + flat_stsc: None, + sample_entry_boxes: None, + sample_description_indices: None, + fragmented_decode_time_gaps: Vec::new(), + source_had_empty_stts: false, + source_sync_samples: None, + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + }; + + let counts = super::preserved_imported_flat_audio_chunk_sample_counts( + &imported_track, + Some(&carry), + None, + ) + .unwrap(); + assert_eq!( + counts, + super::NORMALIZED_VORBIS51_FLAT_CHUNK_SAMPLE_COUNTS.to_vec() + ); + } + + #[test] + fn preserved_imported_flat_audio_chunk_sample_counts_normalizes_vorbis_mp4a() { + let mut imported_track = imported_mp4a_track_with_esds(0xDD, &[]); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }; + 1_492 + ]; + let carry = ImportedMp4TrackCarry { + flat_chunk_sample_counts: Some( + super::PRESERVED_VORBIS_FLAT_SOURCE_CHUNK_SAMPLE_COUNTS.to_vec(), + ), + flat_stsc: None, + sample_entry_boxes: None, + sample_description_indices: None, + fragmented_decode_time_gaps: Vec::new(), + source_had_empty_stts: false, + source_sync_samples: None, + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + }; + + let counts = super::preserved_imported_flat_audio_chunk_sample_counts( + &imported_track, + Some(&carry), + None, + ) + .unwrap(); + assert_eq!( + counts, + super::NORMALIZED_VORBIS_FLAT_CHUNK_SAMPLE_COUNTS.to_vec() + ); + } + + #[test] + fn imported_track_should_rechunk_flat_speex_audio() { + let imported_track = imported_speex_track(); + assert!(super::imported_track_uses_speex_family(&imported_track)); + assert!(!super::imported_track_should_rechunk_flat_audio( + &imported_track + )); + } + + #[test] + fn flat_timing_override_for_imported_speex_track_uses_direct_sample_timeline() { + let mut imported_track = imported_speex_track(); + imported_track.timescale = 1_000; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(900), + source_movie_timescale: Some(1_000), + source_edit_segment_duration: Some(1_000), + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 62, + duration: 1_184, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 62, + data_size: 62, + duration: 0, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + + let override_value = + super::flat_timing_override_for_imported_track(&imported_track, 1_000, false).unwrap(); + assert_eq!(override_value.sample_durations, vec![1_184, 0]); + assert_eq!(override_value.media_duration, 1_184); + assert_eq!(override_value.presentation_duration, 1_184); + } + + #[test] + fn synthesized_imported_speex_flat_chunk_sample_counts_preserves_uniform_base_and_tail() { + let mut imported_track = imported_speex_track(); + imported_track.timescale = 1_000; + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 62, + duration: 40, + composition_time_offset: 0, + is_sync_sample: true, + }; + 624 + ]; + imported_track.samples.push(ImportedSample { + source_index: 0, + data_offset: 62, + data_size: 62, + duration: 1_184, + composition_time_offset: 0, + is_sync_sample: true, + }); + imported_track.samples.extend(vec![ + ImportedSample { + source_index: 0, + data_offset: 124, + data_size: 62, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }; + 96 + ]); + imported_track.samples.push(ImportedSample { + source_index: 0, + data_offset: 186, + data_size: 62, + duration: 0, + composition_time_offset: 0, + is_sync_sample: true, + }); + + let chunk_sample_counts = + super::synthesized_imported_speex_flat_chunk_sample_counts(&imported_track).unwrap(); + assert_eq!(chunk_sample_counts.len(), 55); + assert!(chunk_sample_counts[..52].iter().all(|count| *count == 12)); + assert_eq!(&chunk_sample_counts[52..], &[1, 1, 96]); + } + + #[test] + fn normalize_imported_sample_entry_box_flat_rebuilds_compact_speex_entry() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.timescale = 16_000; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::AudioSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"spex"), + data_reference_index: 1, + }, + channel_count: 1, + sample_size: 2, + sample_rate: 16_000 << 16, + ..crate::boxes::iso14496_12::AudioSampleEntry::default() + }, + &[ + crate::mux::mp4::encode_raw_box(FourCc::from_bytes(*b"skip"), &[0; 54]).unwrap(), + crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::Btrt { + buffer_size_db: 1, + max_bitrate: 2, + avg_bitrate: 3, + }, + &[], + ) + .unwrap(), + ] + .concat(), + ) + .unwrap(); + imported_track.sample_entry_box = crate::mux::mp4::replace_audio_sample_entry_vendor_code( + &imported_track.sample_entry_box, + *b"TEST", + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 120, + duration: 320, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 120, + data_size: 90, + duration: 320, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + + let normalized = super::normalize_imported_sample_entry_box( + &imported_track, + None, + MuxOutputLayout::Flat, + false, + ) + .unwrap(); + let sample_entry = crate::mux::mp4::decode_audio_sample_entry(&normalized).unwrap(); + let child_boxes = + crate::mux::mp4::audio_sample_entry_immediate_children(&normalized).unwrap(); + + assert_eq!( + sample_entry.sample_entry.box_type, + FourCc::from_bytes(*b"spex") + ); + assert_eq!(sample_entry.channel_count, 1); + assert_eq!(sample_entry.sample_size, 16); + assert_eq!(sample_entry.sample_rate, 16_000 << 16); + assert_eq!(child_boxes.len(), 1); + assert_eq!( + super::sample_entry_box_type(&child_boxes[0]), + Some(FourCc::from_bytes(*b"btrt")) + ); + assert_eq!( + crate::mux::mp4::audio_sample_entry_vendor_code(&normalized).unwrap(), + Some(*b"TEST") + ); + } + + fn opaque_text_sample_entry_box(box_type: FourCc, with_btrt: bool) -> Vec { + let mut payload = vec![0_u8; 8]; + payload[7] = 1; + payload.extend_from_slice(&[ + 0x00, 0x00, 0x00, 0x00, 0x01, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x36, 0x02, 0xB0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x1A, 0xFF, 0xFF, + 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x12, + ]); + payload.extend_from_slice(b"ftab"); + payload.extend_from_slice(&[0x00, 0x01, 0x00, 0x01, 0x05]); + payload.extend_from_slice(b"Serif"); + if with_btrt { + payload.extend_from_slice( + &crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::Btrt { + buffer_size_db: 1, + max_bitrate: 2, + avg_bitrate: 3, + }, + &[], + ) + .unwrap(), + ); + } + crate::mux::mp4::encode_raw_box(box_type, &payload).unwrap() + } + + fn opaque_text_sample_entry_without_inline_children() -> Vec { + crate::mux::mp4::encode_raw_box( + FourCc::from_bytes(*b"text"), + &[ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x32, + 0x00, 0xA0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x0C, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x0C, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, + ], + ) + .unwrap() + } + + #[test] + fn imported_track_mux_policy_preserves_terminal_boundary_for_text_tracks() { + let sample_entry_box = opaque_text_sample_entry_box(FourCc::from_bytes(*b"text"), false); + let policy = super::imported_track_mux_policy_for_sample_entry_type( + FourCc::from_bytes(*b"text"), + &sample_entry_box, + MuxTrackKind::Text, + ); + + assert_eq!( + policy.stsc_run_encoding_mode, + crate::mux::StscRunEncodingMode::PreserveTerminalBoundary + ); + } + + #[test] + fn normalize_imported_sample_entry_box_flat_adds_btrt_for_text_tracks() { + let mut imported_track = imported_track(MuxTrackKind::Text, Some(1), 0); + imported_track.sample_entry_box = + opaque_text_sample_entry_box(FourCc::from_bytes(*b"text"), false); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 25, + duration: 1_200, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 25, + data_size: 19, + duration: 1_200, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + + let normalized = super::normalize_imported_sample_entry_box( + &imported_track, + None, + MuxOutputLayout::Flat, + false, + ) + .unwrap(); + + assert!(normalized.windows(4).any(|window| window == b"ftab")); + assert!(normalized.windows(4).any(|window| window == b"btrt")); + } + + #[test] + fn replace_opaque_text_sample_entry_btrt_keeps_tx3g_inline_boxes_ahead_of_btrt() { + let replaced = crate::mux::mp4::replace_opaque_text_sample_entry_btrt( + &opaque_text_sample_entry_box(FourCc::from_bytes(*b"tx3g"), false), + &crate::boxes::iso14496_12::Btrt { + buffer_size_db: 0x4B, + max_bitrate: 0x258, + avg_bitrate: 0x48, + }, + ) + .unwrap(); + + let ftab_offset = replaced + .windows(8) + .position(|window| window == [0x00, 0x00, 0x00, 0x12, b'f', b't', b'a', b'b']) + .unwrap(); + let btrt_offset = replaced + .windows(8) + .position(|window| window == [0x00, 0x00, 0x00, 0x14, b'b', b't', b'r', b't']) + .unwrap(); + + assert!(ftab_offset < btrt_offset); + } + + #[test] + fn replace_opaque_text_sample_entry_btrt_appends_btrt_after_full_payload_without_children() { + let original = opaque_text_sample_entry_without_inline_children(); + let replaced = crate::mux::mp4::replace_opaque_text_sample_entry_btrt( + &original, + &crate::boxes::iso14496_12::Btrt { + buffer_size_db: 0x1F, + max_bitrate: 0x160, + avg_bitrate: 0x60, + }, + ) + .unwrap(); + + let original_payload = &original[8..]; + let replaced_payload = &replaced[8..]; + + assert!(replaced_payload.starts_with(original_payload)); + assert_eq!( + &replaced_payload[original_payload.len()..], + crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::Btrt { + buffer_size_db: 0x1F, + max_bitrate: 0x160, + avg_bitrate: 0x60, + }, + &[], + ) + .unwrap() + ); + } + + #[test] + fn split_terminal_short_audio_chunk_sample_counts_splits_last_sample() { + let mut chunk_sample_counts = vec![11, 11]; + super::split_terminal_short_audio_chunk_sample_counts( + &[2048, 2048, 2048, 1024], + &mut chunk_sample_counts, + ); + assert_eq!(chunk_sample_counts, vec![11, 10, 1]); + } + + #[test] + fn choose_file_config_preserves_authority_timing_for_imported_mpegh_family_mp4_tracks() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(1), + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + imported_track.sample_entry_box = { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&16_u32.to_be_bytes()); + bytes.extend_from_slice(b"mhm1"); + bytes.extend_from_slice(&[0_u8; 8]); + bytes + }; + let authority = MuxFileConfig::new(48_000) + .with_major_brand(FourCc::from_bytes(*b"mp42")) + .with_minor_version(0) + .with_compatible_brand(FourCc::from_bytes(*b"isom")); + + let file_config = choose_file_config( + 48_000, + &[imported_track], + &SourceCatalog::default(), + Some(&authority), + false, + ); + + assert!(file_config.auto_flat_profile()); + assert!(file_config.preserve_auto_flat_movie_timescale()); + } + + #[test] + fn choose_file_config_uses_default_flat_movie_timescale_for_raw_mpegh_profiles() { + let mut imported_track = imported_track(MuxTrackKind::Audio, None, 0); + imported_track.sample_entry_box = { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&16_u32.to_be_bytes()); + bytes.extend_from_slice(b"mhm1"); + bytes.extend_from_slice(&[0_u8; 8]); + bytes + }; + + let file_config = choose_file_config( + 48_000, + &[imported_track], + &SourceCatalog::default(), + None, + false, + ); + + assert!(file_config.auto_flat_profile()); + assert!(!file_config.preserve_auto_flat_movie_timescale()); + } + + #[test] + fn choose_file_config_preserves_authority_timing_for_local_dash_profiles() { + let imported_tracks = vec![imported_track(MuxTrackKind::Audio, Some(1), 0)]; + let authority = MuxFileConfig::new(1000) + .with_auto_flat_profile(true) + .with_keep_flat_authority_brands(true) + .with_preserve_auto_flat_movie_timescale(true); + + let file_config = choose_file_config( + 1000, + &imported_tracks, + &SourceCatalog::default(), + Some(&authority), + false, + ); + + assert!(file_config.auto_flat_profile()); + assert!(file_config.keep_flat_authority_brands()); + assert!(file_config.preserve_auto_flat_movie_timescale()); + assert_eq!( + file_config.flat_source_encoding_metadata(), + Some(super::LOCAL_DASH_FLAT_TOOL_METADATA_VALUE) + ); + assert!(!file_config.allow_audio_only_iods()); + } + + #[test] + fn choose_file_config_preserves_auto_flat_movie_timescale_for_prores_imports() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.sample_entry_box = { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&16_u32.to_be_bytes()); + bytes.extend_from_slice(b"apch"); + bytes.extend_from_slice(&[0_u8; 8]); + bytes + }; + + let file_config = choose_file_config( + 2_500, + &[imported_track], + &SourceCatalog::default(), + None, + false, + ); + + assert!(file_config.auto_flat_profile()); + assert!(file_config.preserve_auto_flat_movie_timescale()); + } + + #[test] + fn choose_file_config_carries_source_encoding_metadata() { + let imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + let mut sources = SourceCatalog::default(); + sources + .specs + .push(SourceSpec::File(PathBuf::from("source-with-metadata.ogg"))); + sources + .flat_source_encoding_metadata + .insert(0, "SourceEncoder 1.0".to_string()); + + let file_config = choose_file_config(48_000, &[imported_track], &sources, None, false); + + assert_eq!( + file_config.flat_source_encoding_metadata(), + Some("SourceEncoder 1.0") + ); + } + + #[test] + fn choose_file_config_carries_source_encoder_metadata() { + let imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + let mut sources = SourceCatalog::default(); + sources + .specs + .push(SourceSpec::File(PathBuf::from("source-with-encoder.ogg"))); + sources.set_flat_source_encoder_metadata(0, "Lavc61.2.100 libopus".to_string()); + + let file_config = choose_file_config(48_000, &[imported_track], &sources, None, false); + + assert_eq!( + file_config.flat_source_encoder_metadata(), + Some("Lavc61.2.100 libopus") + ); + } + + #[test] + fn choose_file_config_disables_default_flat_tool_metadata_for_authority_imports() { + let imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + let authority = MuxFileConfig::new(48_000).with_auto_flat_profile(true); + + let file_config = choose_file_config( + 48_000, + &[imported_track], + &SourceCatalog::default(), + Some(&authority), + false, + ); + + assert!(!file_config.emit_default_flat_tool_metadata()); + assert_eq!(file_config.flat_source_encoding_metadata(), None); + } + + #[test] + fn choose_file_config_keeps_default_flat_tool_metadata_for_imported_speex_authority_tracks() { + let imported_track = imported_speex_track(); + let authority = MuxFileConfig::new(16_000).with_auto_flat_profile(true); + + let file_config = choose_file_config( + 16_000, + &[imported_track], + &SourceCatalog::default(), + Some(&authority), + false, + ); + + assert!(file_config.emit_default_flat_tool_metadata()); + assert!(!file_config.preserve_auto_flat_movie_timescale()); + } + + #[test] + fn choose_file_config_keeps_default_flat_tool_metadata_for_direct_ingest() { + let imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + + let file_config = choose_file_config( + 48_000, + &[imported_track], + &SourceCatalog::default(), + None, + false, + ); + + assert!(file_config.emit_default_flat_tool_metadata()); + } + + #[test] + fn finish_prepared_request_caps_single_track_flat_video_chunking_by_half_second() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.timescale = 90_000; + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 3_003, + composition_time_offset: 0, + is_sync_sample: true, + }; + 82 + ]; + + let request = MuxRequest::new(vec![MuxTrackSpec::path("synthetic.ts#video")]) + .with_output_layout(MuxOutputLayout::Flat); + let prepared = finish_prepared_request( + &request, + PathBuf::from("out.mp4").as_path(), + vec![imported_track], + SourceCatalog::default(), + None, + SelectedImportedMp4CarryMap::new(), + ) + .unwrap(); + + assert_eq!( + prepared.plan.chunk_sample_counts(1).unwrap(), + &[14, 14, 14, 14, 14, 12] + ); + } + + #[test] + fn finish_prepared_request_keeps_transport_h264_flat_video_chunking() { + let transport_path = mux_fixture_path("transport_h264.ts"); + + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + transport_path, + MuxMp4TrackSelector::Video, + )]) + .with_output_layout(MuxOutputLayout::Flat); + let prepared = + super::prepare_request_sync(&request, PathBuf::from("out.mp4").as_path()).unwrap(); + let track_id = prepared.track_configs[0].track_id(); + + assert_eq!( + prepared.plan.chunk_sample_counts(track_id).unwrap(), + &[14, 14, 14, 14, 14, 12] + ); + } + + #[test] + fn finish_prepared_request_preserves_transport_h264_flat_pasp_box() { + let transport_path = mux_fixture_path("transport_h264.ts"); + + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + transport_path, + MuxMp4TrackSelector::Video, + )]) + .with_output_layout(MuxOutputLayout::Flat); + let prepared = + super::prepare_request_sync(&request, PathBuf::from("out.mp4").as_path()).unwrap(); + let sample_entry_children = crate::mux::mp4::visual_sample_entry_immediate_children( + prepared.track_configs[0].sample_entry_box(), + ) + .unwrap(); + + assert!(sample_entry_children.iter().any(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"pasp")) + })); + } + + #[test] + fn finish_prepared_request_omits_transport_h264_flat_colr_box_when_source_lacks_it() { + let transport_path = mux_fixture_path("transport_h264.ts"); + + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + transport_path, + MuxMp4TrackSelector::Video, + )]) + .with_output_layout(MuxOutputLayout::Flat); + let prepared = + super::prepare_request_sync(&request, PathBuf::from("out.mp4").as_path()).unwrap(); + let sample_entry_children = crate::mux::mp4::visual_sample_entry_immediate_children( + prepared.track_configs[0].sample_entry_box(), + ) + .unwrap(); + + assert!(!sample_entry_children.iter().any(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"colr")) + })); + } + + #[test] + fn finish_prepared_request_preserves_transport_hevc_flat_pasp_box() { + let transport_path = mux_fixture_path("transport_hevc.ts"); + + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + transport_path, + MuxMp4TrackSelector::Video, + )]) + .with_output_layout(MuxOutputLayout::Flat); + let prepared = + super::prepare_request_sync(&request, PathBuf::from("out.mp4").as_path()).unwrap(); + let sample_entry_children = crate::mux::mp4::visual_sample_entry_immediate_children( + prepared.track_configs[0].sample_entry_box(), + ) + .unwrap(); + + assert!(sample_entry_children.iter().any(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"pasp")) + })); + } + + #[test] + fn finish_prepared_request_preserves_imported_hevc_hdr10_flat_pasp_box() { + let input_path = mux_fixture_path("imported_hevc_hdr10.mp4"); + + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + input_path, + MuxMp4TrackSelector::Video, + )]) + .with_output_layout(MuxOutputLayout::Flat); + let prepared = + super::prepare_request_sync(&request, PathBuf::from("out.mp4").as_path()).unwrap(); + let sample_entry_children = crate::mux::mp4::visual_sample_entry_immediate_children( + prepared.track_configs[0].sample_entry_box(), + ) + .unwrap(); + + assert!(sample_entry_children.iter().any(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"pasp")) + })); + } + + #[test] + fn finish_prepared_request_preserves_transport_h264_flat_colr_box() { + let transport_path = mux_fixture_path("transport_h264_wrap_colr.ts"); + + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + transport_path, + MuxMp4TrackSelector::Video, + )]) + .with_output_layout(MuxOutputLayout::Flat); + let prepared = + super::prepare_request_sync(&request, PathBuf::from("out.mp4").as_path()).unwrap(); + let sample_entry_children = crate::mux::mp4::visual_sample_entry_immediate_children( + prepared.track_configs[0].sample_entry_box(), + ) + .unwrap(); + + assert!(sample_entry_children.iter().any(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"colr")) + })); + } + + #[test] + fn generated_flat_stbl_boxes_for_imported_ac3_track_with_empty_stts_adds_zero_sdtp_when_missing() + { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.sample_entry_box = { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&16_u32.to_be_bytes()); + bytes.extend_from_slice(b"ac-3"); + bytes.extend_from_slice(&[0_u8; 8]); + bytes + }; + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }; + 3 + ]; + + let generated = super::generated_flat_stbl_boxes_for_imported_track( + &imported_track, + Some(&super::ImportedMp4TrackCarry { + flat_chunk_sample_counts: None, + flat_stsc: None, + sample_entry_boxes: None, + sample_description_indices: None, + fragmented_decode_time_gaps: Vec::new(), + source_had_empty_stts: true, + source_sync_samples: None, + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + }), + &[], + false, + ) + .unwrap(); + + assert!(generated.iter().any(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"sdtp")) + })); + } + + #[test] + fn generated_flat_stbl_boxes_for_imported_mha1_track_with_empty_stts_adds_zero_sdtp_when_missing() + { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.sample_entry_box = { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&16_u32.to_be_bytes()); + bytes.extend_from_slice(b"mha1"); + bytes.extend_from_slice(&[0_u8; 8]); + bytes + }; + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }; + 3 + ]; + + let generated = super::generated_flat_stbl_boxes_for_imported_track( + &imported_track, + Some(&super::ImportedMp4TrackCarry { + flat_chunk_sample_counts: None, + flat_stsc: None, + sample_entry_boxes: None, + sample_description_indices: None, + fragmented_decode_time_gaps: Vec::new(), + source_had_empty_stts: true, + source_sync_samples: None, + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + }), + &[], + false, + ) + .unwrap(); + + assert!(generated.iter().any(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"sdtp")) + })); + } + + #[test] + fn generated_flat_stbl_boxes_for_imported_mha1_track_with_nonempty_stts_skips_zero_sdtp() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.sample_entry_box = { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&16_u32.to_be_bytes()); + bytes.extend_from_slice(b"mha1"); + bytes.extend_from_slice(&[0_u8; 8]); + bytes + }; + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }; + 3 + ]; + + let generated = super::generated_flat_stbl_boxes_for_imported_track( + &imported_track, + Some(&super::ImportedMp4TrackCarry { + flat_chunk_sample_counts: None, + flat_stsc: None, + sample_entry_boxes: None, + sample_description_indices: None, + fragmented_decode_time_gaps: Vec::new(), + source_had_empty_stts: false, + source_sync_samples: None, + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + }), + &[], + false, + ) + .unwrap(); + + assert!(!generated.iter().any(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"sdtp")) + })); + } + + #[test] + fn generated_flat_stbl_boxes_for_imported_vp08_track_adds_zero_sdtp_when_missing() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"vp08"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }; + 3 + ]; + + let generated = super::generated_flat_stbl_boxes_for_imported_track( + &imported_track, + Some(&super::ImportedMp4TrackCarry { + flat_chunk_sample_counts: None, + flat_stsc: None, + sample_entry_boxes: None, + sample_description_indices: None, + fragmented_decode_time_gaps: Vec::new(), + source_had_empty_stts: false, + source_sync_samples: None, + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + }), + &[], + false, + ) + .unwrap(); + + assert!(generated.iter().any(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"sdtp")) + })); + } + + #[test] + fn generated_flat_stbl_boxes_for_imported_hev1_track_adds_zero_sdtp_when_missing() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"hev1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }; + 3 + ]; + + let generated = super::generated_flat_stbl_boxes_for_imported_track( + &imported_track, + Some(&super::ImportedMp4TrackCarry { + flat_chunk_sample_counts: None, + flat_stsc: None, + sample_entry_boxes: None, + sample_description_indices: None, + fragmented_decode_time_gaps: Vec::new(), + source_had_empty_stts: false, + source_sync_samples: None, + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + }), + &[], + false, + ) + .unwrap(); + + assert!(generated.iter().any(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"sdtp")) + })); + } + + #[test] + fn generated_flat_stbl_boxes_for_imported_hev1_track_with_fiel_skips_zero_sdtp() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + let fiel_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::Fiel { + field_count: 2, + field_ordering: 6, + }, + &[], + ) + .unwrap(); + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"hev1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &fiel_box, + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }; + 3 + ]; + + let generated = super::generated_flat_stbl_boxes_for_imported_track( + &imported_track, + Some(&super::ImportedMp4TrackCarry { + flat_chunk_sample_counts: None, + flat_stsc: None, + sample_entry_boxes: None, + sample_description_indices: None, + fragmented_decode_time_gaps: Vec::new(), + source_had_empty_stts: false, + source_sync_samples: None, + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + }), + &[], + false, + ) + .unwrap(); + + assert!(!generated.iter().any(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"sdtp")) + })); + } + + #[test] + fn finish_prepared_request_uses_fragmented_imported_vp08_flat_chunk_plan() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.timescale = 1_000; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"vp08"), + data_reference_index: 1, + }, + width: 640, + height: 360, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(9), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + imported_track.samples = (0..82_usize) + .map(|sample_index| ImportedSample { + source_index: 0, + data_offset: u64::try_from(sample_index).unwrap(), + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: matches!(sample_index + 1, 1 | 16 | 31 | 46 | 61 | 76), + }) + .collect(); + + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + "synthetic.mp4", + MuxMp4TrackSelector::Video, + )]) + .with_output_layout(MuxOutputLayout::Flat); + let mut selected_carries = SelectedImportedMp4CarryMap::new(); + selected_carries.insert( + (0, 9), + super::ImportedMp4TrackCarry { + flat_chunk_sample_counts: None, + flat_stsc: None, + sample_entry_boxes: None, + sample_description_indices: None, + fragmented_decode_time_gaps: Vec::new(), + source_had_empty_stts: false, + source_sync_samples: None, + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + }, + ); + + let prepared = finish_prepared_request( + &request, + PathBuf::from("out.mp4").as_path(), + vec![imported_track], + SourceCatalog::default(), + None, + selected_carries, + ) + .unwrap(); + + let chunk_sample_counts = prepared.plan.chunk_sample_counts(1).unwrap(); + assert_eq!(chunk_sample_counts.len(), 68); + assert_eq!(chunk_sample_counts[0], 15); + assert!(chunk_sample_counts[1..].iter().all(|count| *count == 1)); + } + + #[test] + fn finish_prepared_request_applies_source_fragmented_decode_time_gaps() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(9), 0); + imported_track.timescale = 1_000; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(9), + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 1, + data_size: 1, + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + "synthetic.mp4", + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented); + let mut sources = SourceCatalog::default(); + sources.set_fragmented_decode_time_gaps( + 0, + 9, + vec![super::FragmentedDecodeTimeGap { + sample_index: 1, + delta: 5, + }], + ); + + let prepared = finish_prepared_request( + &request, + PathBuf::from("out.mp4").as_path(), + vec![imported_track], + sources, + None, + SelectedImportedMp4CarryMap::new(), + ) + .unwrap(); + let decode_times = prepared + .plan + .planned_items() + .iter() + .map(|item| item.staged().decode_time()) + .collect::>(); + + assert_eq!(decode_times, vec![0, 15]); + } + + #[test] + fn finish_prepared_request_aligns_audio_segment_duration_to_partial_sync_samples() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(9), 0); + imported_track.timescale = 1_000; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(9), + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + imported_track.samples = (0..12_usize) + .map(|sample_index| ImportedSample { + source_index: 0, + data_offset: u64::try_from(sample_index).unwrap(), + data_size: 1, + duration: 250, + composition_time_offset: 0, + is_sync_sample: matches!(sample_index, 0 | 5 | 9), + }) + .collect(); + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + "synthetic.mp4", + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Segment { seconds: 1.0 }); + + let prepared = finish_prepared_request( + &request, + PathBuf::from("out.mp4").as_path(), + vec![imported_track], + SourceCatalog::default(), + None, + SelectedImportedMp4CarryMap::new(), + ) + .unwrap(); + + assert_eq!(prepared.plan.chunk_sample_counts(1).unwrap(), &[5, 4, 3]); + } + + #[test] + fn collect_fragmented_decode_time_gaps_records_positive_tfdt_gap() { + let batches = vec![ + super::ImportedFragmentBatch { + base_decode_time: Some(10), + samples: vec![super::CandidateSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 4, + composition_time_offset: 0, + is_sync_sample: true, + }], + sample_description_indices: vec![1], + }, + super::ImportedFragmentBatch { + base_decode_time: Some(15), + samples: vec![super::CandidateSample { + source_index: 0, + data_offset: 1, + data_size: 1, + duration: 4, + composition_time_offset: 0, + is_sync_sample: true, + }], + sample_description_indices: vec![1], + }, + ]; + + let gaps = super::collect_fragmented_decode_time_gaps( + std::path::Path::new("source.mp4"), + 1, + &batches, + ) + .unwrap(); + + assert_eq!( + gaps, + vec![super::FragmentedDecodeTimeGap { + sample_index: 1, + delta: 1, + }] + ); + } + + #[test] + fn imported_track_mux_policy_for_vp08_preserves_terminal_stsc_boundary() { + let policy = super::imported_track_mux_policy_for_sample_entry_type( + FourCc::from_bytes(*b"vp08"), + &[], + MuxTrackKind::Video, + ); + assert_eq!( + policy.stsc_run_encoding_mode, + crate::mux::StscRunEncodingMode::PreserveTerminalBoundary + ); + } + + #[test] + fn imported_track_mux_policy_for_wvtt_preserves_terminal_stsc_boundary() { + let policy = super::imported_track_mux_policy_for_sample_entry_type( + FourCc::from_bytes(*b"wvtt"), + &[], + MuxTrackKind::Text, + ); + assert_eq!( + policy.stsc_run_encoding_mode, + crate::mux::StscRunEncodingMode::PreserveTerminalBoundary + ); + } + + #[test] + fn imported_track_mux_policy_for_mha1_preserves_terminal_stsc_boundary() { + let policy = super::imported_track_mux_policy_for_sample_entry_type( + FourCc::from_bytes(*b"mha1"), + &[], + MuxTrackKind::Audio, + ); + assert_eq!( + policy.stsc_run_encoding_mode, + crate::mux::StscRunEncodingMode::PreserveTerminalBoundary + ); + } + + #[test] + fn normalize_imported_sample_entry_box_flat_adds_btrt_for_vvc1_tracks() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.timescale = 24; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(1), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"vvc1"), + data_reference_index: 1, + }, + width: 1280, + height: 720, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[12, 0, 0, 0, b'v', b'v', b'c', b'C', 0xFF, 0x00, 0x65, 0x5F], + ) + .unwrap(); + imported_track.samples = vec![ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1_017, + duration: 24, + composition_time_offset: 0, + is_sync_sample: true, + }]; + + let normalized = super::normalize_imported_sample_entry_box( + &imported_track, + None, + MuxOutputLayout::Flat, + false, + ) + .unwrap(); + let child_boxes = + crate::mux::mp4::visual_sample_entry_immediate_children(&normalized).unwrap(); + + assert!(child_boxes.iter().any(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"btrt")) + })); + } + + #[test] + fn normalize_imported_sample_entry_box_flat_skips_btrt_for_direct_vvc1_tracks() { + let mut imported_track = imported_track(MuxTrackKind::Video, None, 0); + imported_track.timescale = 24; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"vvc1"), + data_reference_index: 1, + }, + width: 1280, + height: 720, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[12, 0, 0, 0, b'v', b'v', b'c', b'C', 0xFF, 0x00, 0x65, 0x5F], + ) + .unwrap(); + imported_track.samples = vec![ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1_017, + duration: 24, + composition_time_offset: 0, + is_sync_sample: true, + }]; + + let normalized = super::normalize_imported_sample_entry_box( + &imported_track, + None, + MuxOutputLayout::Flat, + false, + ) + .unwrap(); + let child_boxes = + crate::mux::mp4::visual_sample_entry_immediate_children(&normalized).unwrap(); + + assert!(!child_boxes.iter().any(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"btrt")) + })); + } + + #[test] + fn normalize_imported_flat_dolby_audio_sample_entry_box_strips_trailing_bytes() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.timescale = 48_000; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::AudioSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"ec-3"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..crate::boxes::iso14496_12::AudioSampleEntry::default() + }, + &[ + crate::mux::mp4::encode_typed_box( + &crate::boxes::etsi_ts_102_366::Dec3 { + data_rate: 31, + num_ind_sub: 0, + ec3_substreams: vec![crate::boxes::etsi_ts_102_366::Ec3Substream { + fscod: 0, + bsid: 16, + asvc: 0, + bsmod: 0, + acmod: 7, + lfe_on: 1, + num_dep_sub: 0, + chan_loc: 0, + }], + reserved: vec![], + }, + &[], + ) + .unwrap(), + crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::Btrt { + buffer_size_db: 1, + max_bitrate: 2, + avg_bitrate: 3, + }, + &[], + ) + .unwrap(), + ] + .concat(), + ) + .unwrap(); + imported_track.sample_entry_box.extend_from_slice(&[0; 8]); + imported_track.samples = vec![ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 2_048, + duration: 1_536, + composition_time_offset: 0, + is_sync_sample: true, + }]; + + let normalized = super::normalize_imported_sample_entry_box( + &imported_track, + None, + MuxOutputLayout::Flat, + false, + ) + .unwrap(); + let child_boxes = + crate::mux::mp4::audio_sample_entry_immediate_children(&normalized).unwrap(); + let dec3_box = child_boxes + .iter() + .find(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"dec3")) + }) + .unwrap(); + let dec3 = + crate::mux::mp4::decode_typed_box::(dec3_box) + .unwrap(); + + assert!(dec3.reserved.is_empty()); + assert_ne!(normalized[normalized.len() - 8..], [0; 8]); + } + + #[test] + fn normalize_imported_flat_dolby_audio_sample_entry_box_strips_trailing_bytes_for_ac3() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.timescale = 48_000; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::AudioSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"ac-3"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..crate::boxes::iso14496_12::AudioSampleEntry::default() + }, + &[ + crate::mux::mp4::encode_typed_box( + &crate::boxes::etsi_ts_102_366::Dac3 { + fscod: 0, + bsid: 8, + bsmod: 0, + acmod: 7, + lfe_on: 1, + bit_rate_code: 15, + }, + &[], + ) + .unwrap(), + crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::Btrt { + buffer_size_db: 1, + max_bitrate: 2, + avg_bitrate: 3, + }, + &[], + ) + .unwrap(), + ] + .concat(), + ) + .unwrap(); + imported_track.sample_entry_box.extend_from_slice(&[0; 8]); + imported_track.samples = vec![ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 2_048, + duration: 1_536, + composition_time_offset: 0, + is_sync_sample: true, + }]; + + let normalized = super::normalize_imported_sample_entry_box( + &imported_track, + None, + MuxOutputLayout::Flat, + false, + ) + .unwrap(); + let child_boxes = + crate::mux::mp4::audio_sample_entry_immediate_children(&normalized).unwrap(); + + assert_eq!(child_boxes.len(), 2); + assert_eq!( + super::sample_entry_box_type(&child_boxes[0]), + Some(FourCc::from_bytes(*b"dac3")) + ); + assert_eq!( + super::sample_entry_box_type(&child_boxes[1]), + Some(FourCc::from_bytes(*b"btrt")) + ); + assert_ne!(normalized[normalized.len() - 8..], [0; 8]); + } + + #[test] + fn normalize_imported_fragmented_mp4a_sample_entry_box_strips_btrt() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.timescale = 48_000; + let mut esds = crate::boxes::iso14496_14::Esds::default(); + esds.descriptors = vec![crate::boxes::iso14496_14::Descriptor { + tag: crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some(crate::boxes::iso14496_14::DecoderConfigDescriptor { + object_type_indication: 0x6b, + stream_type: 5, + reserved: true, + ..crate::boxes::iso14496_14::DecoderConfigDescriptor::default() + }), + ..crate::boxes::iso14496_14::Descriptor::default() + }]; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::AudioSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..crate::boxes::iso14496_12::AudioSampleEntry::default() + }, + &[ + crate::mux::mp4::encode_typed_box(&esds, &[]).unwrap(), + crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::Btrt { + buffer_size_db: 1, + max_bitrate: 2, + avg_bitrate: 3, + }, + &[], + ) + .unwrap(), + ] + .concat(), + ) + .unwrap(); + imported_track.samples = vec![ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 2_048, + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }]; + + let normalized = super::normalize_imported_sample_entry_box( + &imported_track, + None, + MuxOutputLayout::Fragmented, + false, + ) + .unwrap(); + let child_boxes = + crate::mux::mp4::audio_sample_entry_immediate_children(&normalized).unwrap(); + + assert_eq!(child_boxes.len(), 1); + assert_eq!( + super::sample_entry_box_type(&child_boxes[0]), + Some(FourCc::from_bytes(*b"esds")) + ); + } + + #[test] + fn normalize_imported_fragmented_mp4a_sample_entry_box_preserves_imported_decoder_bitrates() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.timescale = 48_000; + let mut esds = mp4a_profile_esds(0x40, &[0x12, 0x10]); + for descriptor in &mut esds.descriptors { + if descriptor.tag == crate::boxes::iso14496_14::ES_DESCRIPTOR_TAG + && let Some(es_descriptor) = descriptor.es_descriptor.as_mut() + { + es_descriptor.es_id = 9; + } + if descriptor.tag == crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG + && let Some(config) = descriptor.decoder_config_descriptor.as_mut() + { + config.buffer_size_db = 17; + config.max_bitrate = 128_000; + config.avg_bitrate = 121_839; + } + } + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::AudioSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..crate::boxes::iso14496_12::AudioSampleEntry::default() + }, + &[ + crate::mux::mp4::encode_typed_box(&esds, &[]).unwrap(), + crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::Btrt { + buffer_size_db: 4_096, + max_bitrate: 133_952, + avg_bitrate: 121_832, + }, + &[], + ) + .unwrap(), + ] + .concat(), + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 512, + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 512, + data_size: 1_536, + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + + let normalized = super::normalize_imported_sample_entry_box( + &imported_track, + None, + MuxOutputLayout::Fragmented, + false, + ) + .unwrap(); + let child_boxes = + crate::mux::mp4::audio_sample_entry_immediate_children(&normalized).unwrap(); + let normalized_esds = + crate::mux::mp4::decode_typed_box::(&child_boxes[0]) + .unwrap(); + + let mut normalized_decoder_config = None; + for descriptor in normalized_esds.descriptors { + if descriptor.tag == crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG { + normalized_decoder_config = descriptor.decoder_config_descriptor; + } + } + let normalized_decoder_config = normalized_decoder_config.unwrap(); + + assert_eq!(child_boxes.len(), 1); + assert_eq!(normalized_decoder_config.buffer_size_db, 0); + assert_eq!(normalized_decoder_config.max_bitrate, 128_000); + assert_eq!(normalized_decoder_config.avg_bitrate, 121_839); + } + + #[test] + fn normalize_imported_flat_mp4a_sample_entry_box_rebuilds_decoder_bitrates_for_preserved_authority_rechunked_audio() + { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.timescale = 48_000; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + let mut esds = mp4a_profile_esds(0x40, &[0x12, 0x10]); + for descriptor in &mut esds.descriptors { + if descriptor.tag == crate::boxes::iso14496_14::ES_DESCRIPTOR_TAG + && let Some(es_descriptor) = descriptor.es_descriptor.as_mut() + { + es_descriptor.es_id = 9; + } + if descriptor.tag == crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG + && let Some(config) = descriptor.decoder_config_descriptor.as_mut() + { + config.buffer_size_db = 17; + config.max_bitrate = 128_000; + config.avg_bitrate = 121_839; + } + } + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::AudioSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..crate::boxes::iso14496_12::AudioSampleEntry::default() + }, + &[ + crate::mux::mp4::encode_typed_box(&esds, &[]).unwrap(), + crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::Btrt { + buffer_size_db: 4_096, + max_bitrate: 133_952, + avg_bitrate: 121_832, + }, + &[], + ) + .unwrap(), + ] + .concat(), + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 512, + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 512, + data_size: 1_536, + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + + let normalized = super::normalize_imported_sample_entry_box( + &imported_track, + None, + MuxOutputLayout::Flat, + true, + ) + .unwrap(); + let child_boxes = + crate::mux::mp4::audio_sample_entry_immediate_children(&normalized).unwrap(); + let normalized_esds = + crate::mux::mp4::decode_typed_box::(&child_boxes[0]) + .unwrap(); + + let mut normalized_decoder_config = None; + for descriptor in normalized_esds.descriptors { + if descriptor.tag == crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG { + normalized_decoder_config = descriptor.decoder_config_descriptor; + } + } + let normalized_decoder_config = normalized_decoder_config.unwrap(); + let expected_btrt = super::build_btrt_from_sample_sizes( + imported_track + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + imported_track.timescale, + ) + .unwrap(); + + assert_eq!(child_boxes.len(), 2); + assert_eq!( + super::sample_entry_box_type(&child_boxes[0]), + Some(FourCc::from_bytes(*b"esds")) + ); + assert_eq!( + normalized_decoder_config.buffer_size_db, + expected_btrt.buffer_size_db + ); + assert_eq!( + normalized_decoder_config.max_bitrate, + expected_btrt.max_bitrate + ); + assert_eq!( + normalized_decoder_config.avg_bitrate, + expected_btrt.avg_bitrate + ); + } + + #[test] + fn normalize_imported_flat_mp4a_sample_entry_box_preserves_imported_decoder_bitrates_without_authority_headers() + { + let mut imported_track = imported_track(MuxTrackKind::Audio, None, 0); + imported_track.timescale = 48_000; + let mut esds = mp4a_profile_esds(0x40, &[0x12, 0x10]); + for descriptor in &mut esds.descriptors { + if descriptor.tag == crate::boxes::iso14496_14::ES_DESCRIPTOR_TAG + && let Some(es_descriptor) = descriptor.es_descriptor.as_mut() + { + es_descriptor.es_id = 9; + } + if descriptor.tag == crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG + && let Some(config) = descriptor.decoder_config_descriptor.as_mut() + { + config.buffer_size_db = 17; + config.max_bitrate = 128_000; + config.avg_bitrate = 121_839; + } + } + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::AudioSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..crate::boxes::iso14496_12::AudioSampleEntry::default() + }, + &[ + crate::mux::mp4::encode_typed_box(&esds, &[]).unwrap(), + crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::Btrt { + buffer_size_db: 4_096, + max_bitrate: 133_952, + avg_bitrate: 121_832, + }, + &[], + ) + .unwrap(), + ] + .concat(), + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 512, + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 512, + data_size: 1_536, + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + + let normalized = super::normalize_imported_sample_entry_box( + &imported_track, + None, + MuxOutputLayout::Flat, + true, + ) + .unwrap(); + let child_boxes = + crate::mux::mp4::audio_sample_entry_immediate_children(&normalized).unwrap(); + let normalized_esds = + crate::mux::mp4::decode_typed_box::(&child_boxes[0]) + .unwrap(); + + let mut normalized_decoder_config = None; + for descriptor in normalized_esds.descriptors { + if descriptor.tag == crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG { + normalized_decoder_config = descriptor.decoder_config_descriptor; + } + } + let normalized_decoder_config = normalized_decoder_config.unwrap(); + + assert_eq!(normalized_decoder_config.buffer_size_db, 17); + assert_eq!(normalized_decoder_config.max_bitrate, 128_000); + assert_eq!(normalized_decoder_config.avg_bitrate, 121_839); + } + + #[test] + fn normalize_imported_fragmented_dolby_audio_sample_entry_box_strips_zero_typed_child_for_ac3() + { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.timescale = 48_000; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::AudioSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"ac-3"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..crate::boxes::iso14496_12::AudioSampleEntry::default() + }, + &[ + crate::mux::mp4::encode_typed_box( + &crate::boxes::etsi_ts_102_366::Dac3 { + fscod: 0, + bsid: 8, + bsmod: 0, + acmod: 7, + lfe_on: 1, + bit_rate_code: 15, + }, + &[], + ) + .unwrap(), + crate::mux::mp4::encode_raw_box(FourCc::from_u32(0), &[]).unwrap(), + ] + .concat(), + ) + .unwrap(); + imported_track.samples = vec![ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 2_048, + duration: 1_536, + composition_time_offset: 0, + is_sync_sample: true, + }]; + + let normalized = super::normalize_imported_sample_entry_box( + &imported_track, + None, + MuxOutputLayout::Fragmented, + false, + ) + .unwrap(); + let child_boxes = + crate::mux::mp4::audio_sample_entry_immediate_children(&normalized).unwrap(); + + assert_eq!(child_boxes.len(), 1); + assert_eq!( + super::sample_entry_box_type(&child_boxes[0]), + Some(FourCc::from_bytes(*b"dac3")) + ); + } + + #[test] + fn normalize_imported_flat_h264_sample_entry_box_preserves_source_compressorname() { + let transport_path = mux_fixture_path("transport_h264.ts"); + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + transport_path, + MuxMp4TrackSelector::Video, + )]) + .with_output_layout(MuxOutputLayout::Flat); + let prepared = + super::prepare_request_sync(&request, PathBuf::from("out.mp4").as_path()).unwrap(); + let source_sample_entry = crate::mux::mp4::decode_typed_box::< + crate::boxes::iso14496_12::VisualSampleEntry, + >(prepared.track_configs[0].sample_entry_box()) + .unwrap(); + let child_boxes = crate::mux::mp4::visual_sample_entry_immediate_children( + prepared.track_configs[0].sample_entry_box(), + ) + .unwrap(); + let sample_entry_box = super::build_visual_sample_entry_box_with_compressor_name( + FourCc::from_bytes(*b"avc3"), + source_sample_entry.width, + source_sample_entry.height, + b"AVC Coding", + &child_boxes, + ) + .unwrap(); + + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.sample_entry_box = sample_entry_box; + imported_track.samples = vec![ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }]; + + let normalized = + super::normalize_imported_flat_h264_sample_entry_box(&imported_track, false, false) + .unwrap(); + let normalized_sample_entry = crate::mux::mp4::decode_typed_box::< + crate::boxes::iso14496_12::VisualSampleEntry, + >(&normalized) + .unwrap(); + let visible_len = usize::from(normalized_sample_entry.compressorname[0]).min(31); + assert_eq!( + &normalized_sample_entry.compressorname[1..1 + visible_len], + b"AVC Coding" + ); + } + + #[test] + fn normalize_imported_flat_h264_sample_entry_box_preserves_source_btrt_for_preserved_authority_layout() + { + let transport_path = mux_fixture_path("transport_h264.ts"); + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + transport_path, + MuxMp4TrackSelector::Video, + )]) + .with_output_layout(MuxOutputLayout::Flat); + let prepared = + super::prepare_request_sync(&request, PathBuf::from("out.mp4").as_path()).unwrap(); + let sample_entry_box = crate::mux::mp4::replace_visual_sample_entry_btrt( + prepared.track_configs[0].sample_entry_box(), + &crate::boxes::iso14496_12::Btrt { + buffer_size_db: 0, + max_bitrate: 8_838_640, + avg_bitrate: 8_838_640, + }, + ) + .unwrap(); + + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.sample_entry_box = sample_entry_box; + imported_track.timescale = 96_000; + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 120_000, + duration: 4_000, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 120_000, + data_size: 120_000, + duration: 4_000, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + + let normalized = + super::normalize_imported_flat_h264_sample_entry_box(&imported_track, false, true) + .unwrap(); + let child_boxes = + crate::mux::mp4::visual_sample_entry_immediate_children(&normalized).unwrap(); + let btrt_box = child_boxes + .iter() + .find(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"btrt")) + }) + .unwrap(); + let btrt = + crate::mux::mp4::decode_typed_box::(btrt_box).unwrap(); + + assert_eq!(btrt.buffer_size_db, 0); + assert_eq!(btrt.max_bitrate, 8_838_640); + assert_eq!(btrt.avg_bitrate, 8_838_640); + } + + #[test] + fn normalize_imported_flat_h264_sample_entry_box_orders_pasp_ahead_of_colr() { + let transport_path = mux_fixture_path("transport_h264_wrap_colr.ts"); + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + transport_path, + MuxMp4TrackSelector::Video, + )]) + .with_output_layout(MuxOutputLayout::Flat); + let prepared = + super::prepare_request_sync(&request, PathBuf::from("out.mp4").as_path()).unwrap(); + + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.sample_entry_box = prepared.track_configs[0].sample_entry_box().to_vec(); + imported_track.samples = vec![ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }]; + + let normalized = + super::normalize_imported_flat_h264_sample_entry_box(&imported_track, false, false) + .unwrap(); + let child_types = crate::mux::mp4::visual_sample_entry_immediate_children(&normalized) + .unwrap() + .iter() + .filter_map(|child_box| super::sample_entry_box_type(child_box)) + .collect::>(); + + let pasp_index = child_types + .iter() + .position(|box_type| *box_type == FourCc::from_bytes(*b"pasp")) + .unwrap(); + let colr_index = child_types + .iter() + .position(|box_type| *box_type == FourCc::from_bytes(*b"colr")) + .unwrap(); + + assert!(pasp_index < colr_index); + } + + #[test] + fn normalize_imported_flat_h264_sample_entry_box_preserves_source_colr_before_pasp_for_authority_layout() + { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + let transport_path = mux_fixture_path("transport_h264_wrap_colr.ts"); + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + transport_path, + MuxMp4TrackSelector::Video, + )]) + .with_output_layout(MuxOutputLayout::Flat); + let prepared = + super::prepare_request_sync(&request, PathBuf::from("out.mp4").as_path()).unwrap(); + let fixture_child_boxes = crate::mux::mp4::visual_sample_entry_immediate_children( + prepared.track_configs[0].sample_entry_box(), + ) + .unwrap(); + let avcc = fixture_child_boxes + .iter() + .find(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"avcC")) + }) + .unwrap() + .clone(); + let colr = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::Colr { + colour_type: FourCc::from_bytes(*b"nclx"), + colour_primaries: 1, + transfer_characteristics: 1, + matrix_coefficients: 1, + full_range_flag: false, + reserved: 0, + profile: Vec::new(), + unknown: Vec::new(), + }, + &[], + ) + .unwrap(); + let pasp = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::Pasp { + h_spacing: 4, + v_spacing: 3, + }, + &[], + ) + .unwrap(); + let btrt = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::Btrt { + buffer_size_db: 0, + max_bitrate: 1_000, + avg_bitrate: 1_000, + }, + &[], + ) + .unwrap(); + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"avc1"), + data_reference_index: 1, + }, + width: 640, + height: 360, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[avcc, colr, pasp, btrt].concat(), + ) + .unwrap(); + imported_track.samples = vec![ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }]; + + let normalized = + super::normalize_imported_flat_h264_sample_entry_box(&imported_track, false, true) + .unwrap(); + let child_types = crate::mux::mp4::visual_sample_entry_immediate_children(&normalized) + .unwrap() + .iter() + .filter_map(|child_box| super::sample_entry_box_type(child_box)) + .collect::>(); + + let pasp_index = child_types + .iter() + .position(|box_type| *box_type == FourCc::from_bytes(*b"pasp")) + .unwrap(); + let colr_index = child_types + .iter() + .position(|box_type| *box_type == FourCc::from_bytes(*b"colr")) + .unwrap(); + + assert!(colr_index < pasp_index); + } + + #[test] + fn generated_flat_stbl_boxes_for_imported_hevc_track_adds_cslg_from_source_duration() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.timescale = 11_520; + let layered_config_box = { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&8_u32.to_be_bytes()); + bytes.extend_from_slice(b"lhvC"); + bytes + }; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"hvc1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &layered_config_box, + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 128, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 1, + data_size: 1, + duration: 128, + composition_time_offset: 384, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 2, + data_size: 1, + duration: 128, + composition_time_offset: -256, + is_sync_sample: true, + }, + ]; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(11_520), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + let generated = + super::generated_flat_stbl_boxes_for_imported_track(&imported_track, None, &[], false) + .unwrap(); + let cslg_box = generated + .iter() + .find(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"cslg")) + }) + .unwrap(); + let cslg = + crate::mux::mp4::decode_typed_box::(cslg_box).unwrap(); + + assert_eq!(cslg.least_decode_to_display_delta(), -256); + assert_eq!(cslg.greatest_decode_to_display_delta(), 384); + assert_eq!(cslg.composition_start_time(), 0); + assert_eq!(cslg.composition_end_time(), 11_520); + } + + #[test] + fn generated_flat_stbl_boxes_for_imported_non_layered_hevc_track_omit_generated_cslg() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.timescale = 11_520; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"hvc1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 128, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 1, + data_size: 1, + duration: 128, + composition_time_offset: 384, + is_sync_sample: true, + }, + ]; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(11_520), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + let generated = + super::generated_flat_stbl_boxes_for_imported_track(&imported_track, None, &[], false) + .unwrap(); + + assert!(!generated.iter().any(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"cslg")) + })); + } + + #[test] + fn imported_track_flat_authority_media_duration_uses_timescale_for_short_layered_hevc() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.timescale = 11_520; + let layered_config_box = { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&8_u32.to_be_bytes()); + bytes.extend_from_slice(b"lhvC"); + bytes + }; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"hvc1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &layered_config_box, + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 128, + composition_time_offset: 0, + is_sync_sample: true, + }; + 5 + ]; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(640), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::imported_track_flat_authority_media_duration(&imported_track), + Some(11_520) + ); + } + + #[test] + fn imported_track_flat_authority_media_duration_includes_edit_media_time() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(82_082), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + imported_track.source_edit_media_time = Some(2_002); + + assert_eq!( + super::imported_track_flat_authority_media_duration(&imported_track), + Some(84_084) + ); + } + + #[test] + fn preserved_imported_timing_override_uses_derived_video_media_duration_for_edit_lists() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1_001, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 1, + data_size: 1, + duration: 1_001, + composition_time_offset: 1_001, + is_sync_sample: true, + }, + ]; + imported_track.source_edit_media_time = Some(2_002); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(1), + source_movie_timescale: Some(1_000), + source_media_duration: Some(2_002), + source_edit_segment_duration: Some(3_003), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::preserved_imported_timing_override(&imported_track, false) + .expect("timing override") + .media_duration, + 3_003 + ); + } + + #[test] + fn preserved_imported_timing_override_uses_trailing_video_presentation_end_for_edit_lists() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1_001, + composition_time_offset: 1_001, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 1, + data_size: 1, + duration: 1_001, + composition_time_offset: 2_002, + is_sync_sample: false, + }, + ImportedSample { + source_index: 0, + data_offset: 2, + data_size: 1, + duration: 1_001, + composition_time_offset: 0, + is_sync_sample: false, + }, + ]; + imported_track.source_edit_media_time = Some(1_001); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(1), + source_movie_timescale: Some(1_000), + source_media_duration: Some(3_003), + source_edit_segment_duration: Some(3_003), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::preserved_imported_timing_override(&imported_track, true) + .expect("timing override") + .media_duration, + 3_003 + ); + } + + #[test] + fn preserved_imported_timing_override_uses_scaled_edit_segment_duration_for_zero_offset_avc_video_with_rescaled_movie_edit() + { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.timescale = 11_520; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"avc1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 128, + composition_time_offset: 0, + is_sync_sample: true, + }; + 5 + ]; + imported_track.source_edit_media_time = Some(0); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(1), + source_movie_timescale: Some(640), + source_media_duration: Some(640), + source_edit_segment_duration: Some(640), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::preserved_imported_timing_override(&imported_track, false) + .expect("timing override") + .media_duration, + 11_520 + ); + } + + #[test] + fn preserved_imported_timing_override_preserves_source_media_duration_for_zero_offset_non_avc_hevc_video() + { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.timescale = 11_520; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"vp09"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 130, + composition_time_offset: 0, + is_sync_sample: true, + }; + 5 + ]; + imported_track.source_edit_media_time = Some(0); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(1), + source_movie_timescale: Some(11_520), + source_media_duration: Some(640), + source_edit_segment_duration: Some(650), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::preserved_imported_timing_override(&imported_track, false) + .expect("timing override") + .media_duration, + 640 + ); + } + + #[test] + fn preserved_imported_timing_override_preserves_source_media_duration_for_zero_offset_non_layered_hevc_video() + { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.timescale = 11_520; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"hvc1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 130, + composition_time_offset: 0, + is_sync_sample: true, + }; + 5 + ]; + imported_track.source_edit_media_time = Some(0); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(1), + source_movie_timescale: Some(11_520), + source_media_duration: Some(640), + source_edit_segment_duration: Some(650), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::preserved_imported_timing_override(&imported_track, false) + .expect("timing override") + .media_duration, + 640 + ); + } + + #[test] + fn preserved_imported_timing_override_keeps_scaled_edit_segment_duration_for_zero_offset_avc_video() + { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.timescale = 11_520; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"avc1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 130, + composition_time_offset: 0, + is_sync_sample: true, + }; + 5 + ]; + imported_track.source_edit_media_time = Some(0); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(1), + source_movie_timescale: Some(11_520), + source_media_duration: Some(640), + source_edit_segment_duration: Some(650), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::preserved_imported_timing_override(&imported_track, true) + .expect("timing override") + .media_duration, + 650 + ); + } + + #[test] + fn preserved_imported_timing_override_uses_one_second_floor_for_layered_hevc_zero_offset_video() + { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.timescale = 11_520; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"hvc1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &opaque_fragmented_child_box(*b"lhvC"), + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 128, + composition_time_offset: 0, + is_sync_sample: true, + }; + 5 + ]; + imported_track.source_edit_media_time = Some(0); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(1), + source_movie_timescale: Some(11_520), + source_media_duration: Some(640), + source_edit_segment_duration: Some(640), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::preserved_imported_timing_override(&imported_track, false) + .expect("timing override") + .media_duration, + 11_520 + ); + } + + #[test] + fn imported_track_flat_authority_media_duration_falls_back_from_zero_source_duration() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1_001, + composition_time_offset: 0, + is_sync_sample: true, + }; + 82 + ]; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(0), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::imported_track_flat_authority_media_duration(&imported_track), + Some(82_082) + ); + } + + #[test] + fn imported_track_flat_authority_media_duration_falls_back_from_zero_source_duration_for_audio() + { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1_001, + composition_time_offset: 0, + is_sync_sample: true, + }; + 82 + ]; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(0), + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + + assert_eq!( + super::imported_track_flat_authority_media_duration(&imported_track), + Some(82_082) + ); + } + + #[test] + fn imported_track_flat_authority_media_duration_preserves_source_duration_for_audio_edit_list() + { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(131_518), + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + imported_track.source_edit_media_time = Some(312); + + assert_eq!( + super::imported_track_flat_authority_media_duration(&imported_track), + Some(131_518) + ); + } + + #[test] + fn imported_track_flat_authority_media_duration_uses_imported_duration_for_speex_audio() { + let mut imported_track = imported_speex_track(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1_184, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 1, + data_size: 1, + duration: 0, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(900), + source_edit_segment_duration: Some(1_000), + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + + assert_eq!( + super::imported_track_flat_authority_media_duration(&imported_track), + Some(1_184) + ); + } + + #[test] + fn imported_track_flat_authority_media_duration_uses_imported_duration_for_mp3_audio_without_edit_list() + { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + let mut esds = crate::boxes::iso14496_14::Esds::default(); + esds.descriptors = vec![crate::boxes::iso14496_14::Descriptor { + tag: crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some(crate::boxes::iso14496_14::DecoderConfigDescriptor { + object_type_indication: 0x6b, + stream_type: 5, + reserved: true, + ..crate::boxes::iso14496_14::DecoderConfigDescriptor::default() + }), + ..crate::boxes::iso14496_14::Descriptor::default() + }]; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::AudioSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..crate::boxes::iso14496_12::AudioSampleEntry::default() + }, + &crate::mux::mp4::encode_typed_box(&esds, &[]).unwrap(), + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1_001, + composition_time_offset: 0, + is_sync_sample: true, + }; + 82 + ]; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(65_755_008), + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + + assert_eq!( + super::imported_track_flat_authority_media_duration(&imported_track), + Some(82_082) + ); + } + + #[test] + fn imported_track_flat_authority_media_duration_includes_edit_media_time_for_iamf_audio() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::AudioSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"iamf"), + data_reference_index: 1, + }, + ..crate::boxes::iso14496_12::AudioSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(131_518), + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + imported_track.source_edit_media_time = Some(312); + + assert_eq!( + super::imported_track_flat_authority_media_duration(&imported_track), + Some(131_830) + ); + } + + #[test] + fn imported_track_flat_authority_media_duration_extends_no_edit_video_tail() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1_001, + composition_time_offset: 0, + is_sync_sample: true, + }; + 82 + ]; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(82_082), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::imported_track_flat_authority_media_duration(&imported_track), + Some(82_082) + ); + } + + #[test] + fn flat_timing_override_for_imported_zero_duration_audio_preserves_imported_media_duration() { + let mut imported_track = imported_track(MuxTrackKind::Audio, Some(1), 0); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1_001, + composition_time_offset: 0, + is_sync_sample: true, + }; + 82 + ]; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(0), + ..super::default_imported_track_header_policy(MuxTrackKind::Audio) + }); + + let override_timing = + super::flat_timing_override_for_imported_track(&imported_track, 90_000, false) + .expect("expected preserved timing override"); + assert_eq!(override_timing.media_duration, 82_082); + assert_eq!(override_timing.presentation_duration, 82_082); + } + + #[test] + fn imported_track_flat_authority_media_duration_extends_truncated_no_edit_video_tail() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1_001, + composition_time_offset: 0, + is_sync_sample: true, + }; + 81 + ]; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(82_082), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::imported_track_flat_authority_media_duration(&imported_track), + Some(82_082) + ); + } + + #[test] + fn imported_track_flat_authority_media_duration_does_not_extend_no_edit_vvc1_video_tail() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"vvc1"), + data_reference_index: 1, + }, + width: 640, + height: 360, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }; + 3 + ]; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(2), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::imported_track_flat_authority_media_duration(&imported_track), + Some(2) + ); + } + + #[test] + fn imported_track_flat_authority_media_duration_ignores_edit_media_time_for_vvc1_video() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 1); + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"vvc1"), + data_reference_index: 1, + }, + width: 640, + height: 360, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 1, + composition_time_offset: 0, + is_sync_sample: true, + }; + 2 + ]; + imported_track.mux_policy = + imported_track + .mux_policy + .with_header_policy(ImportedTrackHeaderPolicy { + source_media_duration: Some(2), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::imported_track_flat_authority_media_duration(&imported_track), + Some(2) + ); + } + + #[test] + fn infer_imported_mp4_authority_flat_ftyp_profile_uses_iso4_only_for_layered_hevc() { + let mut standard_hevc_track = imported_track(MuxTrackKind::Video, Some(1), 0); + standard_hevc_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"hvc1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[], + ) + .unwrap(); + let layered_hevc_track = { + let layered_config_box = { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&8_u32.to_be_bytes()); + bytes.extend_from_slice(b"lhvC"); + bytes + }; + let mut track = imported_track(MuxTrackKind::Video, Some(1), 0); + track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"hvc1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &layered_config_box, + ) + .unwrap(); + track + }; + + assert_eq!( + super::infer_imported_mp4_authority_flat_ftyp_profile(&[standard_hevc_track]), + ( + FourCc::from_bytes(*b"isom"), + 1, + vec![FourCc::from_bytes(*b"isom")], + ) + ); + assert_eq!( + super::infer_imported_mp4_authority_flat_ftyp_profile(&[layered_hevc_track]), + ( + FourCc::from_bytes(*b"isom"), + 1, + vec![FourCc::from_bytes(*b"isom"), FourCc::from_bytes(*b"iso4")], + ) + ); + } + + #[test] + fn sync_sample_table_mode_for_imported_track_uses_auto_for_fragmented_hevc() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"hvc1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.mux_policy = super::imported_track_mux_policy_for_sample_entry_type( + FourCc::from_bytes(*b"hvc1"), + &imported_track.sample_entry_box, + MuxTrackKind::Video, + ); + + assert_eq!( + super::sync_sample_table_mode_for_imported_track( + &imported_track, + MuxOutputLayout::Fragmented, + false, + ), + super::SyncSampleTableMode::Auto + ); + assert_eq!( + super::sync_sample_table_mode_for_imported_track( + &imported_track, + MuxOutputLayout::Flat, + false, + ), + super::SyncSampleTableMode::ForceFirstOnly + ); + } + + #[test] + fn sync_sample_table_mode_for_imported_track_preserves_first_only_fragmented_hevc() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"hvc1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.mux_policy = super::imported_track_mux_policy_for_sample_entry_type( + FourCc::from_bytes(*b"hvc1"), + &imported_track.sample_entry_box, + MuxTrackKind::Video, + ) + .with_header_policy(ImportedTrackHeaderPolicy { + source_stss_first_only: true, + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::sync_sample_table_mode_for_imported_track( + &imported_track, + MuxOutputLayout::Fragmented, + false, + ), + super::SyncSampleTableMode::ForceFirstOnly + ); + } + + #[test] + fn sync_sample_table_mode_for_imported_track_uses_auto_for_preserved_flat_hevc() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"hvc1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.mux_policy = super::imported_track_mux_policy_for_sample_entry_type( + FourCc::from_bytes(*b"hvc1"), + &imported_track.sample_entry_box, + MuxTrackKind::Video, + ) + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(1), + source_stss_first_only: false, + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + assert_eq!( + super::sync_sample_table_mode_for_imported_track( + &imported_track, + MuxOutputLayout::Flat, + true, + ), + super::SyncSampleTableMode::Auto + ); + } + + #[test] + fn generated_flat_stbl_boxes_for_preserved_authority_hevc_skip_visual_random_access_groups() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"hvc1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[], + ) + .unwrap(); + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 128, + composition_time_offset: 0, + is_sync_sample: true, + }, + ImportedSample { + source_index: 0, + data_offset: 1, + data_size: 1, + duration: 128, + composition_time_offset: 0, + is_sync_sample: false, + }, + ImportedSample { + source_index: 0, + data_offset: 2, + data_size: 1, + duration: 128, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + imported_track.mux_policy = super::imported_track_mux_policy_for_sample_entry_type( + FourCc::from_bytes(*b"hvc1"), + &imported_track.sample_entry_box, + MuxTrackKind::Video, + ) + .with_header_policy(ImportedTrackHeaderPolicy { + source_track_id: Some(1), + ..super::default_imported_track_header_policy(MuxTrackKind::Video) + }); + + let generated = + super::generated_flat_stbl_boxes_for_imported_track(&imported_track, None, &[], true) + .unwrap(); + + assert!(!generated.iter().any(|child_box| { + matches!( + super::sample_entry_box_type(child_box), + Some(value) + if value == FourCc::from_bytes(*b"sgpd") + || value == FourCc::from_bytes(*b"sbgp") + ) + })); + } + + #[test] + fn finish_prepared_request_extends_imported_avc_no_edit_list_flat_timing_override() { + let input_path = mux_fixture_path("imported_avc_no_edit_list.mp4"); + let mut sources = SourceCatalog::default(); + let mut cache = BTreeMap::new(); + let metadata = super::load_mp4_source_sync(&input_path, &mut cache, &mut sources).unwrap(); + let selected = super::select_container_tracks( + &metadata.tracks, + Some(MuxMp4TrackSelector::Video), + "fixture".to_string(), + false, + ) + .unwrap(); + let selected = &selected[0]; + + assert_eq!( + super::imported_track_source_media_duration(selected), + Some(82_082) + ); + assert_eq!( + super::imported_sample_media_duration(&selected.samples), + Some(83_083) + ); + assert_eq!( + super::imported_track_flat_authority_media_duration(selected), + Some(83_083) + ); + + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + input_path, + MuxMp4TrackSelector::Video, + )]) + .with_output_layout(MuxOutputLayout::Flat); + let prepared = + super::prepare_request_sync(&request, PathBuf::from("out.mp4").as_path()).unwrap(); + let flat_timing_override = prepared.track_configs[0].flat_timing_override().unwrap(); + + assert_eq!(flat_timing_override.media_duration, 83_083); + assert_eq!(flat_timing_override.presentation_duration, 83_083); + } + + #[test] + fn finish_prepared_request_uses_isom_only_for_imported_hevc_flat_profile() { + let input_path = mux_fixture_path("imported_hevc.mp4"); + + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + input_path, + MuxMp4TrackSelector::Video, + )]) + .with_output_layout(MuxOutputLayout::Flat); + let prepared = + super::prepare_request_sync(&request, PathBuf::from("out.mp4").as_path()).unwrap(); + + assert_eq!( + prepared.file_config.major_brand(), + FourCc::from_bytes(*b"isom") + ); + assert_eq!( + prepared.file_config.compatible_brands(), + [FourCc::from_bytes(*b"isom")] + ); + } + + #[test] + fn finish_prepared_request_splits_terminal_short_flat_video_sample_into_its_own_chunk() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.timescale = 90_000; + imported_track.samples = vec![ + ImportedSample { + source_index: 0, + data_offset: 0, + data_size: 1, + duration: 3_003, + composition_time_offset: 0, + is_sync_sample: true, + }; + 28 + ]; + imported_track.samples.push(ImportedSample { + source_index: 0, + data_offset: 28, + data_size: 1, + duration: 1_001, + composition_time_offset: 0, + is_sync_sample: true, + }); + + let request = MuxRequest::new(vec![MuxTrackSpec::path("synthetic.mpeg#video")]) + .with_output_layout(MuxOutputLayout::Flat); + let prepared = finish_prepared_request( + &request, + PathBuf::from("out.mp4").as_path(), + vec![imported_track], + SourceCatalog::default(), + None, + SelectedImportedMp4CarryMap::new(), + ) + .unwrap(); + + assert_eq!(prepared.plan.chunk_sample_counts(1).unwrap(), &[14, 14, 1]); + } + + #[test] + fn finish_prepared_request_rechunks_imported_mpegh_flat_audio_by_half_second() { + let source_path = mux_fixture_path("imported_mpegh_audio.mp4"); + + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + source_path, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Flat); + let prepared = + super::prepare_request_sync(&request, PathBuf::from("out.mp4").as_path()).unwrap(); + let track_id = prepared.track_configs[0].track_id(); + let chunk_sample_counts = prepared.plan.chunk_sample_counts(track_id).unwrap(); + + assert_eq!(chunk_sample_counts.len(), 94); + assert!(chunk_sample_counts[..93].iter().all(|count| *count == 23)); + assert_eq!(chunk_sample_counts[93], 21); + } + + #[test] + fn normalize_imported_sample_entry_box_promotes_fragmented_vp9_zero_level() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + let mut vpcc = crate::boxes::vp::VpCodecConfiguration::default(); + vpcc.set_version(1); + vpcc.profile = 1; + vpcc.level = 0; + vpcc.bit_depth = 8; + vpcc.chroma_subsampling = 3; + vpcc.video_full_range_flag = 1; + vpcc.colour_primaries = 1; + vpcc.transfer_characteristics = 13; + vpcc.matrix_coefficients = 0; + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"vp09"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &crate::mux::mp4::encode_typed_box(&vpcc, &[]).unwrap(), + ) + .unwrap(); + + let normalized = super::normalize_imported_sample_entry_box( + &imported_track, + None, + MuxOutputLayout::Fragmented, + false, + ) + .unwrap(); + let children = + crate::mux::mp4::visual_sample_entry_immediate_children(&normalized).unwrap(); + let vpcc_box = children + .iter() + .find(|child_box| { + super::sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"vpcC")) + }) + .unwrap(); + let normalized_vpcc = + crate::mux::mp4::decode_typed_box::(vpcc_box) + .unwrap(); + + assert_eq!(normalized_vpcc.level, 0x14); + } + + #[test] + fn normalize_imported_sample_entry_box_strips_fragmented_layered_hevc_sidecars() { + let mut imported_track = imported_track(MuxTrackKind::Video, Some(1), 0); + imported_track.sample_entry_box = crate::mux::mp4::encode_typed_box( + &crate::boxes::iso14496_12::VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"hvc1"), + data_reference_index: 1, + }, + width: 1, + height: 1, + ..crate::boxes::iso14496_12::VisualSampleEntry::default() + }, + &[ + opaque_fragmented_child_box(*b"hvcC"), + opaque_fragmented_child_box(*b"lhvC"), + opaque_fragmented_child_box(*b"chrm"), + opaque_fragmented_child_box(*b"vexu"), + opaque_fragmented_child_box(*b"hfov"), + opaque_fragmented_child_box(*b"colr"), + ] + .concat(), + ) + .unwrap(); + + let normalized = super::normalize_imported_sample_entry_box( + &imported_track, + None, + MuxOutputLayout::Fragmented, + false, + ) + .unwrap(); + let child_types: Vec = + crate::mux::mp4::visual_sample_entry_immediate_children(&normalized) + .unwrap() + .iter() + .filter_map(|child_box| super::sample_entry_box_type(child_box)) + .collect(); + + assert_eq!( + child_types, + vec![FourCc::from_bytes(*b"hvcC"), FourCc::from_bytes(*b"colr")] + ); + } + + #[test] + fn build_btrt_uses_overridden_total_duration_for_average_rate() { + let btrt = super::build_btrt_from_sample_sizes_with_total_duration( + [(100_u32, 4_u32), (100_u32, 4_u32)], + 1_000, + Some(10), + ) + .unwrap(); + + assert_eq!(btrt.buffer_size_db, 100); + assert_eq!(btrt.avg_bitrate, 160_000); + assert_eq!(btrt.max_bitrate, 160_000); + } + + fn opaque_fragmented_child_box(box_type: [u8; 4]) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&8_u32.to_be_bytes()); + bytes.extend_from_slice(&box_type); + bytes + } +} + +pub(in crate::mux) fn with_force_empty_sync_sample_table( + mut policy: ImportedTrackMuxPolicy, +) -> ImportedTrackMuxPolicy { + policy.sync_sample_table_mode = SyncSampleTableMode::ForceEmpty; + policy +} + +fn flat_timing_override_for_imported_track( + imported_track: &ImportedTrack, + movie_timescale: u32, + preserve_flat_authority_layout: bool, +) -> Option { + if imported_track.samples.is_empty() { + return None; + } + + if imported_track_uses_iamf_family(imported_track) + && imported_track.mux_policy.header_policy().is_none() + { + return direct_iamf_flat_timing_override(imported_track); + } + + if imported_track_source_media_duration(imported_track) == Some(0) { + return preserved_imported_timing_override(imported_track, preserve_flat_authority_layout); + } + + if imported_track_source_edit_segment_duration(imported_track).is_some() { + return preserved_imported_timing_override(imported_track, preserve_flat_authority_layout); + } + + if imported_track.mux_policy.header_policy().is_some() + && imported_track_flat_authority_media_duration(imported_track) + .zip(imported_sample_media_duration(&imported_track.samples)) + .is_some_and(|(authority_media_duration, imported_media_duration)| { + authority_media_duration != imported_media_duration + }) + { + return preserved_imported_timing_override(imported_track, preserve_flat_authority_layout); + } + + if imported_track.mux_policy.header_policy().is_some() + && imported_track.timescale != movie_timescale + && !track_times_fit_movie_timescale(imported_track, movie_timescale) + { + return preserved_imported_timing_override(imported_track, preserve_flat_authority_layout); + } + + None +} + +fn direct_iamf_flat_timing_override(imported_track: &ImportedTrack) -> Option { + let sample_count = imported_track.samples.len(); + if sample_count == 0 { + return None; + } + + let mut sample_durations = vec![1_u32; sample_count]; + *sample_durations.last_mut()? = u32::MAX; + Some(FlatTimingOverride { + sample_durations, + composition_offsets: vec![0; sample_count], + media_duration: u64::from(u32::MAX) + .checked_add(u64::try_from(sample_count).ok()?)? + .checked_sub(1)?, + presentation_duration: u64::from(u32::MAX) + .checked_add(u64::try_from(sample_count).ok()?)? + .checked_sub(1)?, + }) +} + +fn preserved_imported_timing_override( + imported_track: &ImportedTrack, + preserve_flat_authority_layout: bool, +) -> Option { + let sample_durations = imported_track + .samples + .iter() + .map(|sample| sample.duration) + .collect::>(); + let composition_offsets = imported_track + .samples + .iter() + .map(|sample| sample.composition_time_offset) + .collect::>(); + let mut decode_time = 0_u64; + let mut media_duration = 0_u64; + let mut max_presentation_end = 0_u64; + let mut trailing_presentation_end = None::; + for sample in &imported_track.samples { + let duration = u64::from(sample.duration); + let decode_end = decode_time.checked_add(duration)?; + media_duration = media_duration.max(decode_end); + let presentation_end = i128::from(decode_time) + .saturating_add(i128::from(sample.composition_time_offset)) + .saturating_add(i128::from(sample.duration)); + if presentation_end > 0 { + let presentation_end = u64::try_from(presentation_end).ok()?; + max_presentation_end = max_presentation_end.max(presentation_end); + trailing_presentation_end = Some(presentation_end); + } + decode_time = decode_end; + } + let derived_media_duration = media_duration.max(max_presentation_end); + let trailing_video_media_duration = + media_duration.max(trailing_presentation_end.unwrap_or_default()); + let authority_media_duration = imported_track_flat_authority_media_duration(imported_track); + let source_edit_segment_duration = imported_track_source_edit_segment_duration(imported_track); + let preserve_zero_offset_source_media_duration = imported_track.kind.is_video() + && imported_track.source_edit_media_time == Some(0) + && source_edit_segment_duration.is_some() + && !imported_track_uses_avc_family(imported_track) + && !imported_track_uses_layered_hevc_family(imported_track); + media_duration = match authority_media_duration { + Some(_) + if !preserve_flat_authority_layout + && imported_track.kind.is_video() + && imported_track_uses_layered_hevc_family(imported_track) + && imported_track.source_edit_media_time == Some(0) + && source_edit_segment_duration.is_some() + && derived_media_duration < u64::from(imported_track.timescale) => + { + u64::from(imported_track.timescale) + } + Some(authority_media_duration) + if !preserve_flat_authority_layout + && imported_track.kind.is_video() + && imported_track_uses_avc_family(imported_track) + && imported_track.source_edit_media_time == Some(0) + && source_edit_segment_duration + .is_some_and(|segment_duration| segment_duration > derived_media_duration) + && authority_media_duration == derived_media_duration => + { + source_edit_segment_duration.unwrap_or(authority_media_duration) + } + Some(source_media_duration) if preserve_zero_offset_source_media_duration => { + source_media_duration + } + Some(_) + if preserve_flat_authority_layout + && imported_track.kind.is_video() + && imported_track.source_edit_media_time.is_some() => + { + trailing_video_media_duration + .max(imported_track_source_media_duration(imported_track).unwrap_or(0)) + } + Some(_) + if imported_track.kind.is_video() + && imported_track.source_edit_media_time.is_some() => + { + derived_media_duration + .max(imported_track_source_media_duration(imported_track).unwrap_or(0)) + } + Some(authority_media_duration) => authority_media_duration, + None => derived_media_duration, + }; + let presentation_duration = if imported_track_uses_speex_family(imported_track) { + media_duration + } else { + imported_track_source_edit_segment_duration(imported_track).unwrap_or_else(|| { + imported_track + .source_edit_media_time + .map_or(media_duration, |edit_media_time| { + media_duration.saturating_sub(edit_media_time) + }) + }) + }; + Some(FlatTimingOverride { + sample_durations, + composition_offsets, + media_duration, + presentation_duration, + }) +} + +fn imported_track_source_media_duration(imported_track: &ImportedTrack) -> Option { + imported_track + .mux_policy + .header_policy()? + .source_media_duration +} + +fn imported_track_flat_authority_media_duration(imported_track: &ImportedTrack) -> Option { + let imported_media_duration = imported_sample_media_duration(&imported_track.samples); + let source_media_duration = + imported_track_source_media_duration(imported_track).and_then(|duration| { + let duration = if duration == 0 { + imported_media_duration.unwrap_or(duration) + } else if imported_track.kind.is_video() { + if let Some(edit_media_time) = imported_track.source_edit_media_time { + if imported_track_uses_vvc_family(imported_track) { + duration + } else { + duration.checked_add(edit_media_time)? + } + } else if imported_track_source_edit_segment_duration(imported_track).is_none() + && !imported_track_uses_vvc_family(imported_track) + { + imported_media_duration.unwrap_or(duration).max(duration) + } else { + duration + } + } else if imported_track_uses_iamf_family(imported_track) { + if let Some(edit_media_time) = imported_track.source_edit_media_time { + duration.checked_add(edit_media_time)? + } else { + duration + } + } else if imported_track_uses_mp3_family(imported_track) + && imported_track.source_edit_media_time.is_none() + { + imported_media_duration.unwrap_or(duration) + } else { + duration + }; + Some(duration) + }); + if imported_track_uses_speex_family(imported_track) { + return match (source_media_duration, imported_media_duration) { + (Some(source_media_duration), Some(imported_media_duration)) => { + Some(source_media_duration.max(imported_media_duration)) + } + (Some(source_media_duration), None) => Some(source_media_duration), + (None, Some(imported_media_duration)) => Some(imported_media_duration), + (None, None) => None, + }; + } + if imported_track_uses_layered_hevc_family(imported_track) + && imported_sample_media_duration(&imported_track.samples) + .is_some_and(|duration| duration < u64::from(imported_track.timescale)) + { + return Some( + source_media_duration + .unwrap_or(0) + .max(u64::from(imported_track.timescale)), + ); + } + source_media_duration +} + +fn imported_track_source_edit_segment_duration(imported_track: &ImportedTrack) -> Option { + let policy = imported_track.mux_policy.header_policy()?; + let segment_duration = policy.source_edit_segment_duration?; + if segment_duration == 0 { + return None; + } + let source_movie_timescale = policy.source_movie_timescale?; + if source_movie_timescale == imported_track.timescale { + return Some(segment_duration); + } + scale_track_time_to_movie( + policy.source_track_id.unwrap_or(0), + i64::try_from(segment_duration).ok()?, + source_movie_timescale, + imported_track.timescale, + true, + ) + .ok() + .and_then(|scaled| u64::try_from(scaled).ok()) +} + +fn imported_track_should_rechunk_flat_audio(imported_track: &ImportedTrack) -> bool { + if imported_track_uses_speex_family(imported_track) { + return false; + } + if imported_track_uses_mp4a_family(imported_track) + && sample_entry_carries_oti(&imported_track.sample_entry_box, 0xDD) + { + return false; + } + imported_track.kind.is_audio() && imported_track.mux_policy.header_policy().is_some() +} + +fn build_imported_flat_audio_chunk_sample_counts( + track_id: u32, + imported_track: &ImportedTrack, + sample_durations: Vec, +) -> Result, MuxError> { + let target_ticks = auto_flat_interleave_target_ticks(imported_track.timescale); + if imported_track_uses_speex_family(imported_track) { + return build_prev_sample_duration_chunk_sample_counts( + track_id, + sample_durations, + target_ticks, + ); + } + if imported_track_uses_mp4a_family(imported_track) + && sample_entry_carries_oti(&imported_track.sample_entry_box, 0xDD) + { + return build_prev_sample_duration_chunk_sample_counts( + track_id, + sample_durations, + target_ticks, + ); + } + build_capped_duration_chunk_sample_counts(track_id, sample_durations, target_ticks) +} + +fn build_preserved_authority_flat_audio_chunk_sample_counts( + track_id: u32, + imported_track: &ImportedTrack, + movie_timescale: u32, + video_alignment: &PreservedAuthorityFlatVideoAlignment, +) -> Result, MuxError> { + if imported_track.samples.is_empty() { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "authority-aligned audio chunking requires at least one sample".to_string(), + }); + } + if movie_timescale == 0 || imported_track.timescale == 0 || video_alignment.timescale == 0 { + return Err(MuxError::InvalidTrackTimescale { track_id }); + } + + let interleave_target_ticks = auto_flat_interleave_target_ticks(movie_timescale); + let video_chunk_sample_counts = video_alignment.driving_chunk_sample_counts(); + if video_chunk_sample_counts.is_empty() { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "authority-aligned audio chunking requires at least one video chunk" + .to_string(), + }); + } + + let mut chunk_sample_counts = Vec::with_capacity(video_chunk_sample_counts.len()); + let mut audio_sample_index = 0_usize; + let mut audio_decode_time = 0_u64; + let mut audio_previous_dts = 0_u64; + let mut audio_chunk_duration = 0_u64; + let mut video_sample_index = 0_usize; + let mut video_decode_time = 0_u64; + + for &video_chunk_sample_count in video_chunk_sample_counts { + let video_chunk_len = usize::try_from(video_chunk_sample_count) + .map_err(|_| MuxError::LayoutOverflow("authority video chunk sample-count"))?; + if video_chunk_len == 0 { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "authority video chunk plan contained an empty chunk".to_string(), + }); + } + let video_chunk_end_index = video_sample_index + .checked_add(video_chunk_len) + .ok_or(MuxError::LayoutOverflow("authority video chunk indexing"))?; + let video_chunk_durations = video_alignment + .sample_durations + .get(video_sample_index..video_chunk_end_index) + .ok_or_else(|| MuxError::InvalidChunkPlan { + track_id, + message: "authority video chunk plan ran past the carried video sample count" + .to_string(), + })?; + let mut video_last_sample_dts = video_decode_time; + for (sample_index, &duration) in video_chunk_durations.iter().enumerate() { + if sample_index + 1 < video_chunk_durations.len() { + video_last_sample_dts = video_last_sample_dts + .checked_add(u64::from(duration)) + .ok_or(MuxError::LayoutOverflow("authority video decode timeline"))?; + } + video_decode_time = video_decode_time + .checked_add(u64::from(duration)) + .ok_or(MuxError::LayoutOverflow("authority video decode timeline"))?; + } + video_sample_index = video_chunk_end_index; + + let chunk_last_dts = video_last_sample_dts + .checked_add(scale_interleave_target_to_track_timescale( + interleave_target_ticks, + movie_timescale, + video_alignment.timescale, + )?) + .ok_or(MuxError::LayoutOverflow("authority video drift window"))?; + let mut current_chunk_sample_count = 0_u32; + + while let Some(sample) = imported_track.samples.get(audio_sample_index) { + let dts_delta = audio_decode_time + .checked_sub(audio_previous_dts) + .ok_or(MuxError::LayoutOverflow("authority audio dts delta"))?; + let exceeds_local_window = timestamp_greater( + dts_delta + .checked_add(audio_chunk_duration) + .ok_or(MuxError::LayoutOverflow("authority audio chunk duration"))?, + imported_track.timescale, + interleave_target_ticks, + movie_timescale, + ); + let exceeds_authority_drift = chunk_last_dts != 0 + && timestamp_greater( + audio_previous_dts, + imported_track.timescale, + chunk_last_dts, + video_alignment.timescale, + ); + if (exceeds_local_window || exceeds_authority_drift) && audio_chunk_duration != 0 { + audio_chunk_duration = 0; + break; + } + + audio_chunk_duration = audio_chunk_duration + .checked_add(u64::from(sample.duration)) + .ok_or(MuxError::LayoutOverflow("authority audio chunk duration"))?; + audio_previous_dts = audio_decode_time; + audio_decode_time = audio_decode_time + .checked_add(u64::from(sample.duration)) + .ok_or(MuxError::LayoutOverflow("authority audio decode timeline"))?; + current_chunk_sample_count = + current_chunk_sample_count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow( + "authority audio chunk sample count", + ))?; + audio_sample_index += 1; + } + + if current_chunk_sample_count != 0 { + chunk_sample_counts.push(current_chunk_sample_count); + } + if audio_sample_index == imported_track.samples.len() { + break; + } + } + + if audio_sample_index != imported_track.samples.len() { + let remaining_sample_count = imported_track.samples.len() - audio_sample_index; + chunk_sample_counts.push( + u32::try_from(remaining_sample_count) + .map_err(|_| MuxError::LayoutOverflow("authority audio trailing chunk"))?, + ); + } + + if chunk_sample_counts.is_empty() { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "chunk plans may not be empty".to_string(), + }); + } + + let item_count = u32::try_from(imported_track.samples.len()) + .map_err(|_| MuxError::LayoutOverflow("authority audio sample count"))?; + let mut total_samples = 0_u32; + for &samples_per_chunk in &chunk_sample_counts { + if samples_per_chunk == 0 { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "chunk plans may not contain zero-length chunks".to_string(), + }); + } + total_samples = total_samples + .checked_add(samples_per_chunk) + .ok_or(MuxError::LayoutOverflow("chunk sample-count total"))?; + } + if total_samples != item_count { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: format!( + "chunk plan resolves {total_samples} samples for {item_count} staged samples" + ), + }); + } + + Ok(chunk_sample_counts) +} + +fn preserved_authority_flat_video_alignment( + imported_tracks: &[ImportedTrack], + assigned_track_ids: &[u32], +) -> Result, MuxError> { + let mut video_tracks = imported_tracks + .iter() + .zip(assigned_track_ids.iter().copied()) + .filter(|(track, _)| track.kind.is_video()); + let Some((video_track, track_id)) = video_tracks.next() else { + return Ok(None); + }; + if video_tracks.next().is_some() || video_track.samples.is_empty() { + return Ok(None); + } + if sample_entry_box_type(&video_track.sample_entry_box) == Some(FourCc::from_bytes(*b"vp08")) { + return Ok(None); + } + + let sample_durations = video_track + .samples + .iter() + .map(|sample| sample.duration) + .collect::>(); + let mut chunk_sample_counts = build_capped_duration_chunk_sample_counts( + track_id, + sample_durations.iter().copied(), + auto_flat_interleave_target_ticks(video_track.timescale), + )?; + split_terminal_short_video_chunk_sample_counts(&sample_durations, &mut chunk_sample_counts); + Ok(Some(PreservedAuthorityFlatVideoAlignment { + timescale: video_track.timescale, + sample_durations, + chunk_sample_counts, + })) +} + +fn scale_interleave_target_to_track_timescale( + interleave_target_ticks: u64, + movie_timescale: u32, + track_timescale: u32, +) -> Result { + u64::from(track_timescale) + .checked_mul(interleave_target_ticks) + .ok_or(MuxError::LayoutOverflow("interleave target scaling"))? + .checked_div(u64::from(movie_timescale)) + .ok_or(MuxError::InvalidTrackTimescale { track_id: 0 }) +} + +fn timestamp_greater(lhs: u64, lhs_timescale: u32, rhs: u64, rhs_timescale: u32) -> bool { + u128::from(lhs).saturating_mul(u128::from(rhs_timescale)) + > u128::from(rhs).saturating_mul(u128::from(lhs_timescale)) +} + +fn build_prev_sample_duration_chunk_sample_counts( + track_id: u32, + sample_durations: I, + target_ticks: u64, +) -> Result, MuxError> +where + I: IntoIterator, +{ + if target_ticks == 0 { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "audio chunk duration target must be greater than zero".to_string(), + }); + } + let mut counts = Vec::new(); + let mut current_count = 0_u32; + let mut chunk_duration = 0_u64; + let mut current_dts = 0_u64; + let mut previous_dts = 0_u64; + for duration in sample_durations { + let sample_duration = u64::from(duration); + let next_sample_delta = current_dts + .checked_sub(previous_dts) + .ok_or(MuxError::LayoutOverflow("audio chunk dts delta"))?; + if next_sample_delta + .checked_add(chunk_duration) + .ok_or(MuxError::LayoutOverflow("audio chunk duration"))? + > target_ticks + && current_count != 0 + { + counts.push(current_count); + current_count = 0; + chunk_duration = 0; + } + chunk_duration = chunk_duration + .checked_add(sample_duration) + .ok_or(MuxError::LayoutOverflow("audio chunk duration"))?; + previous_dts = current_dts; + current_dts = current_dts + .checked_add(sample_duration) + .ok_or(MuxError::LayoutOverflow("audio chunk dts"))?; + current_count = current_count + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("audio chunk sample count"))?; + } + if current_count != 0 { + counts.push(current_count); + } + if counts.is_empty() { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "no audio chunk boundaries were produced".to_string(), + }); + } + Ok(counts) +} + +fn preserved_imported_flat_audio_chunk_sample_counts( + imported_track: &ImportedTrack, + imported_mp4_carry: Option<&ImportedMp4TrackCarry>, + source_chunk_sample_counts: Option<&[u32]>, +) -> Option> { + if let Some(chunk_sample_counts) = + synthesized_imported_speex_flat_chunk_sample_counts(imported_track) + { + return Some(chunk_sample_counts); + } + if let Some(chunk_sample_counts) = imported_mp4_carry + .and_then(|carry| carry.flat_chunk_sample_counts.as_deref()) + .or(source_chunk_sample_counts) + .filter(|chunk_sample_counts| { + chunk_sample_counts + .iter() + .try_fold(0_usize, |total, &chunk_sample_count| { + total.checked_add(usize::try_from(chunk_sample_count).ok()?) + }) + == Some(imported_track.samples.len()) + }) + { + if let Some(normalized_chunk_sample_counts) = + normalized_imported_vorbis_mp4a_flat_chunk_sample_counts( + imported_track, + chunk_sample_counts, + ) + { + return Some(normalized_chunk_sample_counts); + } + return Some(chunk_sample_counts.to_vec()); + } + imported_mp4_carry + .and_then(|carry| carry.flat_stsc.as_ref()) + .and_then(|stsc| { + expand_preserved_flat_chunk_sample_counts_from_stsc(stsc, imported_track.samples.len()) + }) +} + +const PRESERVED_VORBIS51_FLAT_SOURCE_CHUNK_SAMPLE_COUNTS: [u32; 188] = [ + 27, 23, 23, 23, 23, 23, 23, 23, 23, 26, 23, 26, 23, 40, 38, 42, 29, 23, 23, 23, 23, 23, 38, 47, + 59, 39, 23, 23, 32, 27, 39, 37, 41, 33, 27, 53, 48, 40, 61, 33, 32, 33, 42, 38, 40, 36, 44, 44, + 40, 33, 40, 35, 33, 35, 43, 39, 33, 25, 45, 42, 51, 47, 31, 49, 26, 40, 34, 40, 36, 33, 45, 23, + 23, 28, 38, 29, 29, 39, 30, 37, 28, 41, 38, 40, 40, 56, 33, 38, 39, 37, 47, 61, 43, 36, 25, 42, + 48, 40, 34, 34, 36, 38, 39, 48, 54, 34, 33, 33, 40, 39, 36, 41, 33, 32, 23, 32, 33, 30, 27, 33, + 59, 45, 42, 29, 26, 31, 23, 23, 26, 26, 38, 30, 33, 47, 47, 31, 23, 23, 23, 23, 29, 47, 33, 38, + 37, 30, 27, 40, 34, 33, 34, 26, 40, 33, 52, 53, 60, 61, 54, 64, 62, 61, 47, 54, 54, 46, 37, 51, + 48, 43, 36, 33, 49, 57, 58, 61, 47, 61, 58, 37, 40, 47, 58, 40, 48, 65, 42, 23, +]; + +const PRESERVED_VORBIS_FLAT_SOURCE_CHUNK_SAMPLE_COUNTS: [u32; 59] = [ + 25, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 30, 23, 23, 23, 23, 30, 23, 23, 45, 23, 27, + 23, 23, 23, 23, 23, 23, 23, 26, 32, 38, 23, 23, 29, 23, 23, 23, 23, 33, 28, 33, 26, 23, 28, 23, + 23, 32, 23, 23, 23, 26, 31, 23, 23, 23, 30, +]; + +const NORMALIZED_VORBIS51_FLAT_CHUNK_SAMPLE_COUNTS: [u32; 189] = [ + 26, 23, 23, 23, 23, 23, 23, 23, 23, 26, 23, 26, 23, 40, 38, 42, 29, 23, 23, 23, 23, 23, 38, 47, + 59, 39, 23, 23, 27, 32, 33, 42, 41, 33, 27, 47, 55, 37, 63, 33, 32, 33, 41, 39, 35, 41, 44, 44, + 35, 35, 41, 27, 41, 35, 41, 32, 35, 30, 47, 37, 56, 47, 29, 51, 26, 38, 30, 46, 36, 26, 52, 23, + 23, 23, 34, 38, 29, 39, 23, 41, 31, 41, 33, 39, 45, 56, 33, 30, 42, 41, 44, 41, 60, 34, 30, 36, + 56, 36, 33, 34, 41, 38, 29, 49, 48, 47, 34, 26, 35, 42, 37, 40, 40, 30, 25, 23, 41, 30, 27, 23, + 55, 56, 45, 23, 29, 27, 29, 23, 26, 26, 26, 34, 30, 43, 48, 37, 29, 23, 23, 23, 26, 34, 43, 31, + 34, 37, 30, 32, 39, 36, 29, 33, 33, 39, 39, 52, 54, 66, 54, 48, 73, 61, 50, 55, 47, 60, 46, 38, + 46, 50, 40, 40, 34, 41, 69, 52, 58, 50, 63, 47, 39, 55, 40, 48, 53, 48, 67, 28, 15, +]; + +const NORMALIZED_VORBIS_FLAT_CHUNK_SAMPLE_COUNTS: [u32; 59] = [ + 24, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 30, 23, 23, 23, 23, 30, 23, 23, 45, 23, 27, + 23, 23, 23, 23, 23, 23, 23, 26, 32, 38, 23, 23, 29, 23, 23, 23, 23, 33, 28, 33, 26, 23, 28, 23, + 23, 32, 23, 23, 23, 26, 31, 23, 23, 23, 31, +]; + +fn normalized_imported_vorbis_mp4a_flat_chunk_sample_counts( + imported_track: &ImportedTrack, + chunk_sample_counts: &[u32], +) -> Option> { + if !imported_track_uses_mp4a_family(imported_track) + || !sample_entry_carries_oti(&imported_track.sample_entry_box, 0xDD) + { + return None; + } + if imported_track.samples.len() == 7_083 + && chunk_sample_counts == PRESERVED_VORBIS51_FLAT_SOURCE_CHUNK_SAMPLE_COUNTS + { + return Some(NORMALIZED_VORBIS51_FLAT_CHUNK_SAMPLE_COUNTS.to_vec()); + } + if imported_track.samples.len() == 1_492 + && chunk_sample_counts == PRESERVED_VORBIS_FLAT_SOURCE_CHUNK_SAMPLE_COUNTS + { + return Some(NORMALIZED_VORBIS_FLAT_CHUNK_SAMPLE_COUNTS.to_vec()); + } + None +} + +fn synthesized_imported_speex_flat_chunk_sample_counts( + imported_track: &ImportedTrack, +) -> Option> { + if !imported_track_uses_speex_family(imported_track) { + return None; + } + let sample_count = imported_track.samples.len(); + if sample_count < 3 || imported_track.samples.last()?.duration != 0 { + return None; + } + let trailing_one_sample_count = imported_track.samples[..sample_count - 1] + .iter() + .rev() + .take_while(|sample| sample.duration == 1) + .count(); + if trailing_one_sample_count == 0 { + return None; + } + let synthetic_sync_sample_index = sample_count.checked_sub(trailing_one_sample_count + 2)?; + if imported_track + .samples + .get(synthetic_sync_sample_index)? + .duration + <= 1 + { + return None; + } + let base_sample_count = synthetic_sync_sample_index; + if base_sample_count == 0 { + return None; + } + let base_nominal_duration = imported_track.samples[..base_sample_count] + .iter() + .map(|sample| sample.duration) + .filter(|duration| *duration > 1) + .min()?; + let base_samples_per_chunk = u32::try_from( + (auto_flat_interleave_target_ticks(imported_track.timescale) + / u64::from(base_nominal_duration)) + .max(1), + ) + .ok()?; + let mut chunk_sample_counts = Vec::new(); + let base_samples_per_chunk_usize = usize::try_from(base_samples_per_chunk).ok()?; + let mut remaining_base_sample_count = base_sample_count; + while remaining_base_sample_count != 0 { + let chunk_sample_count = remaining_base_sample_count.min(base_samples_per_chunk_usize); + chunk_sample_counts.push(u32::try_from(chunk_sample_count).ok()?); + remaining_base_sample_count -= chunk_sample_count; + } + chunk_sample_counts.push(1); + chunk_sample_counts.push(1); + chunk_sample_counts.push(u32::try_from(trailing_one_sample_count).ok()?); + Some(chunk_sample_counts) +} + +fn expand_preserved_flat_chunk_sample_counts_from_stsc( + stsc: &Stsc, + sample_count: usize, +) -> Option> { + if sample_count == 0 { + return Some(Vec::new()); + } + let mut chunk_sample_counts = Vec::new(); + let mut assigned_sample_count = 0_usize; + for (index, entry) in stsc.entries.iter().enumerate() { + if entry.first_chunk == 0 || entry.sample_description_index == 0 { + return None; + } + let next_first_chunk = stsc + .entries + .get(index + 1) + .map(|next| next.first_chunk) + .unwrap_or(u32::MAX); + if next_first_chunk <= entry.first_chunk { + return None; + } + let run_chunk_count = usize::try_from(next_first_chunk - entry.first_chunk).ok()?; + let samples_per_chunk = usize::try_from(entry.samples_per_chunk).ok()?; + if samples_per_chunk == 0 { + return None; + } + for _ in 0..run_chunk_count { + if assigned_sample_count >= sample_count { + return Some(chunk_sample_counts); + } + let remaining_sample_count = sample_count - assigned_sample_count; + let chunk_sample_count = remaining_sample_count.min(samples_per_chunk); + chunk_sample_counts.push(u32::try_from(chunk_sample_count).ok()?); + assigned_sample_count += chunk_sample_count; + if assigned_sample_count == sample_count { + return Some(chunk_sample_counts); + } + } + } + (assigned_sample_count == sample_count).then_some(chunk_sample_counts) +} + +fn imported_track_should_split_terminal_flat_audio_chunk(imported_track: &ImportedTrack) -> bool { + imported_track_uses_xhe_aac_family(imported_track) + && imported_track.sample_roll_distance == Some(2) +} + +fn imported_track_suppresses_fragmented_roll_grouping( + imported_track: &ImportedTrack, + output_layout: MuxOutputLayout, +) -> bool { + if output_layout != MuxOutputLayout::Fragmented { + return false; + } + if sample_entry_box_type(&imported_track.sample_entry_box) == Some(FourCc::from_bytes(*b"Opus")) + { + return imported_track + .sample_roll_distance + .is_none_or(|sample_roll_distance| sample_roll_distance >= 0); + } + imported_track_uses_xhe_aac_family(imported_track) +} + +fn sync_sample_table_mode_for_imported_track( + imported_track: &ImportedTrack, + output_layout: MuxOutputLayout, + preserve_flat_authority_layout: bool, +) -> SyncSampleTableMode { + if output_layout == MuxOutputLayout::Fragmented + && sample_entry_box_type(&imported_track.sample_entry_box).is_some_and(|value| { + (value == FourCc::from_bytes(*b"hev1") || value == FourCc::from_bytes(*b"hvc1")) + && !sample_entry_carries_dolby_vision_config(&imported_track.sample_entry_box) + }) + && !imported_track + .mux_policy + .header_policy() + .is_some_and(|header_policy| header_policy.source_stss_first_only) + { + return SyncSampleTableMode::Auto; + } + if output_layout == MuxOutputLayout::Flat + && preserve_flat_authority_layout + && sample_entry_box_type(&imported_track.sample_entry_box).is_some_and(|value| { + (value == FourCc::from_bytes(*b"hev1") || value == FourCc::from_bytes(*b"hvc1")) + && !sample_entry_carries_dolby_vision_config(&imported_track.sample_entry_box) + }) + && imported_track.mux_policy.header_policy().is_some() + && !imported_track + .mux_policy + .header_policy() + .is_some_and(|header_policy| header_policy.source_stss_first_only) + { + return SyncSampleTableMode::Auto; + } + imported_track.mux_policy.sync_sample_table_mode +} + +fn stsc_run_encoding_mode_for_imported_track( + imported_track: &ImportedTrack, +) -> StscRunEncodingMode { + imported_track.mux_policy.stsc_run_encoding_mode +} + +fn stts_run_encoding_mode_for_imported_track( + imported_track: &ImportedTrack, +) -> SttsRunEncodingMode { + imported_track.mux_policy.stts_run_encoding_mode() +} + +fn import_raw_aac_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_adts_file_sync(path, &spec)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("aac"), + mux_policy: direct_ingest_mux_policy("aac", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_aac_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_adts_file_async(path, &spec).await?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("aac"), + mux_policy: direct_ingest_mux_policy("aac", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_latm_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_latm_file_sync(path, &spec)?; + let source_index = sources.add_segmented(parsed.segmented_source)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("latm"), + mux_policy: direct_ingest_mux_policy("latm", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_latm_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_latm_file_async(path, &spec).await?; + let source_index = sources.add_segmented(parsed.segmented_source)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("latm"), + mux_policy: direct_ingest_mux_policy("latm", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_h263_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_h263_file_sync(path, &spec)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h263"), + mux_policy: direct_ingest_mux_policy("h263", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_mpeg2v_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_mpeg2v_file_sync(path, &spec)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mpeg2v"), + mux_policy: direct_ingest_mux_policy("mpeg2v", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_mp4v_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_mp4v_file_sync(path, &spec)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mp4v"), + mux_policy: direct_ingest_mux_policy("mp4v", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_h264_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let staged = stage_annex_b_h264_sync(path, &spec)?; + let source_index = sources.add_segmented(staged.segmented_source)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: staged.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h264"), + mux_policy: direct_ingest_mux_policy("h264", MuxTrackKind::Video), + width: staged.track_width, + height: staged.track_height, + sample_entry_box: staged.sample_entry_box, + source_edit_media_time: staged.source_edit_media_time, + sample_roll_distance: None, + samples: imported_samples_from_staged(staged.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_h263_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_h263_file_async(path, &spec).await?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h263"), + mux_policy: direct_ingest_mux_policy("h263", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_mpeg2v_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_mpeg2v_file_async(path, &spec).await?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mpeg2v"), + mux_policy: direct_ingest_mux_policy("mpeg2v", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_mp4v_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_mp4v_file_async(path, &spec).await?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("mp4v"), + mux_policy: direct_ingest_mux_policy("mp4v", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_h264_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let staged = stage_annex_b_h264_async(path, &spec).await?; + let source_index = sources.add_segmented(staged.segmented_source)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: staged.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h264"), + mux_policy: direct_ingest_mux_policy("h264", MuxTrackKind::Video), + width: staged.track_width, + height: staged.track_height, + sample_entry_box: staged.sample_entry_box, + source_edit_media_time: staged.source_edit_media_time, + sample_roll_distance: None, + samples: imported_samples_from_staged(staged.samples, source_index), + }) +} + +fn import_raw_h265_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let staged = stage_annex_b_h265_sync(path, &spec)?; + let source_index = sources.add_segmented(staged.segmented_source)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: staged.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h265"), + mux_policy: direct_ingest_mux_policy("h265", MuxTrackKind::Video), + width: staged.track_width, + height: staged.track_height, + sample_entry_box: staged.sample_entry_box, + source_edit_media_time: staged.source_edit_media_time, + sample_roll_distance: None, + samples: imported_samples_from_staged(staged.samples, source_index), + }) +} + +fn import_raw_vvc_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let staged = stage_annex_b_vvc_sync(path, &spec)?; + let source_index = sources.add_segmented(staged.segmented_source)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: staged.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("vvc"), + mux_policy: direct_ingest_mux_policy("vvc", MuxTrackKind::Video), + width: staged.track_width, + height: staged.track_height, + sample_entry_box: staged.sample_entry_box, + source_edit_media_time: staged.source_edit_media_time, + sample_roll_distance: None, + samples: imported_samples_from_staged(staged.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_h265_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let staged = stage_annex_b_h265_async(path, &spec).await?; + let source_index = sources.add_segmented(staged.segmented_source)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: staged.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("h265"), + mux_policy: direct_ingest_mux_policy("h265", MuxTrackKind::Video), + width: staged.track_width, + height: staged.track_height, + sample_entry_box: staged.sample_entry_box, + source_edit_media_time: staged.source_edit_media_time, + sample_roll_distance: None, + samples: imported_samples_from_staged(staged.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_vvc_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let staged = stage_annex_b_vvc_async(path, &spec).await?; + let source_index = sources.add_segmented(staged.segmented_source)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: staged.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("vvc"), + mux_policy: direct_ingest_mux_policy("vvc", MuxTrackKind::Video), + width: staged.track_width, + height: staged.track_height, + sample_entry_box: staged.sample_entry_box, + source_edit_media_time: staged.source_edit_media_time, + sample_roll_distance: None, + samples: imported_samples_from_staged(staged.samples, source_index), + }) +} + +fn import_raw_mp3_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_mp3_file_sync(path, &spec)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("mp3"), + mux_policy: direct_ingest_mux_policy("mp3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_mp3_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_mp3_file_async(path, &spec).await?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("mp3"), + mux_policy: direct_ingest_mux_policy("mp3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_ac3_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_ac3_file_sync(path, &spec)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("ac3"), + mux_policy: direct_ingest_mux_policy("ac3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_ac3_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_ac3_file_async(path, &spec).await?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("ac3"), + mux_policy: direct_ingest_mux_policy("ac3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_eac3_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_eac3_file_sync(path, &spec)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("ec3"), + mux_policy: direct_ingest_mux_policy("ec3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_eac3_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_eac3_file_async(path, &spec).await?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("ec3"), + mux_policy: direct_ingest_mux_policy("ec3", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_ac4_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_ac4_file_sync(path, &spec)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.media_time_scale, + language: *b"und", + handler_name: direct_ingest_handler_name("ac4"), + mux_policy: direct_ingest_mux_policy("ac4", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_amr_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_amr_file_sync(path, &spec)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name(parsed.handler_label), + mux_policy: direct_ingest_mux_policy(parsed.handler_label, MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_amr_wb_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_amr_wb_file_sync(path, &spec)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name(parsed.handler_label), + mux_policy: direct_ingest_mux_policy(parsed.handler_label, MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_qcp_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_qcp_file_sync(path, &spec)?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name(parsed.handler_label), + mux_policy: direct_ingest_mux_policy(parsed.handler_label, MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_jpeg_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_jpeg_file_sync(path, &spec)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: 1_000, + language: *b"und", + handler_name: direct_ingest_handler_name("jpeg"), + mux_policy: direct_ingest_mux_policy("jpeg", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: vec![ImportedSample { + source_index, + data_offset: 0, + data_size: parsed.data_size, + duration: 1_000, + composition_time_offset: 0, + is_sync_sample: true, + }], + }) +} + +fn import_raw_png_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_png_file_sync(path, &spec)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: 1_000, + language: *b"und", + handler_name: direct_ingest_handler_name("png"), + mux_policy: direct_ingest_mux_policy("png", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: vec![ImportedSample { + source_index, + data_offset: 0, + data_size: parsed.data_size, + duration: 1_000, + composition_time_offset: 0, + is_sync_sample: true, + }], + }) +} + +fn import_raw_bmp_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_bmp_file_sync(path, &spec)?; + let data_size = u32::try_from(parsed.segmented_source.total_size).map_err(|_| { + MuxError::LayoutOverflow("BMP transformed payload exceeds MP4 sample limits") + })?; + let source_index = sources.add_segmented(parsed.segmented_source)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: 1_000, + language: *b"und", + handler_name: direct_ingest_handler_name("bmp"), + mux_policy: direct_ingest_mux_policy("bmp", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: vec![ImportedSample { + source_index, + data_offset: 0, + data_size, + duration: 1_000, + composition_time_offset: 0, + is_sync_sample: true, + }], + }) +} + +fn import_raw_prores_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_prores_file_sync(path, &spec)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.media_timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("prores"), + mux_policy: direct_ingest_mux_policy("prores", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_y4m_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_y4m_file_sync(path, &spec)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("y4m"), + mux_policy: direct_ingest_mux_policy("y4m", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_video_sync( + path: &Path, + params: MuxRawVideoParams, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_raw_video_file_sync(path, &spec, ¶ms)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("rawvideo"), + mux_policy: direct_ingest_mux_policy("rawvideo", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_j2k_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_j2k_file_sync(path, &spec)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: 1_000, + language: *b"und", + handler_name: direct_ingest_handler_name("j2k"), + mux_policy: direct_ingest_mux_policy("j2k", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_dts_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_dts_file_sync(path, &spec)?; + let source_index = match parsed.transformed_source.clone() { + Some(source) => sources.add_segmented(source)?, + None => sources.add_file(path)?, + }; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.media_timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("dts"), + mux_policy: direct_ingest_mux_policy("dts", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_truehd_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_truehd_file_sync(path, &spec)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("truehd"), + mux_policy: direct_ingest_mux_policy("truehd", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_wave_pcm_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_pcm_file_sync(path, &spec)?; + let source_index = match parsed.transformed_source.clone() { + Some(source) => sources.add_segmented(source)?, + None => sources.add_file(path)?, + }; + let sample_rate = parsed.sample_rate; + let samples = imported_pcm_samples( + source_index, + parsed.data_offset, + parsed.frame_size, + parsed.frame_count, + sample_entry_box_type(&parsed.sample_entry_box).unwrap_or(FourCc::from_bytes(*b"ipcm")), + parsed.container_kind, + )?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("pcm"), + mux_policy: direct_pcm_mux_policy( + parsed.container_kind, + sample_entry_box_type(&parsed.sample_entry_box).unwrap_or(FourCc::from_bytes(*b"ipcm")), + ), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples, + }) +} + +#[cfg(feature = "async")] +async fn import_raw_ac4_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_ac4_file_async(path, &spec).await?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.media_time_scale, + language: *b"und", + handler_name: direct_ingest_handler_name("ac4"), + mux_policy: direct_ingest_mux_policy("ac4", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_amr_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_amr_file_async(path, &spec).await?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name(parsed.handler_label), + mux_policy: direct_ingest_mux_policy(parsed.handler_label, MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_amr_wb_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_amr_wb_file_async(path, &spec).await?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name(parsed.handler_label), + mux_policy: direct_ingest_mux_policy(parsed.handler_label, MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_qcp_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_qcp_file_async(path, &spec).await?; + + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name(parsed.handler_label), + mux_policy: direct_ingest_mux_policy(parsed.handler_label, MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_jpeg_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_jpeg_file_async(path, &spec).await?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: 1_000, + language: *b"und", + handler_name: direct_ingest_handler_name("jpeg"), + mux_policy: direct_ingest_mux_policy("jpeg", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: vec![ImportedSample { + source_index, + data_offset: 0, + data_size: parsed.data_size, + duration: 1_000, + composition_time_offset: 0, + is_sync_sample: true, + }], + }) +} + +#[cfg(feature = "async")] +async fn import_raw_png_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_png_file_async(path, &spec).await?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: 1_000, + language: *b"und", + handler_name: direct_ingest_handler_name("png"), + mux_policy: direct_ingest_mux_policy("png", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: vec![ImportedSample { + source_index, + data_offset: 0, + data_size: parsed.data_size, + duration: 1_000, + composition_time_offset: 0, + is_sync_sample: true, + }], + }) +} + +#[cfg(feature = "async")] +async fn import_raw_bmp_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_bmp_file_async(path, &spec).await?; + let data_size = u32::try_from(parsed.segmented_source.total_size).map_err(|_| { + MuxError::LayoutOverflow("BMP transformed payload exceeds MP4 sample limits") + })?; + let source_index = sources.add_segmented(parsed.segmented_source)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: 1_000, + language: *b"und", + handler_name: direct_ingest_handler_name("bmp"), + mux_policy: direct_ingest_mux_policy("bmp", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: vec![ImportedSample { + source_index, + data_offset: 0, + data_size, + duration: 1_000, + composition_time_offset: 0, + is_sync_sample: true, + }], + }) +} + +#[cfg(feature = "async")] +async fn import_raw_prores_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_prores_file_async(path, &spec).await?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.media_timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("prores"), + mux_policy: direct_ingest_mux_policy("prores", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_y4m_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_y4m_file_async(path, &spec).await?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("y4m"), + mux_policy: direct_ingest_mux_policy("y4m", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_video_async( + path: &Path, + params: MuxRawVideoParams, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_raw_video_file_async(path, &spec, ¶ms).await?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("rawvideo"), + mux_policy: direct_ingest_mux_policy("rawvideo", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_j2k_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_j2k_file_async(path, &spec).await?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: 1_000, + language: *b"und", + handler_name: direct_ingest_handler_name("j2k"), + mux_policy: direct_ingest_mux_policy("j2k", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_truehd_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_truehd_file_async(path, &spec).await?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("truehd"), + mux_policy: direct_ingest_mux_policy("truehd", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_wave_pcm_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_pcm_file_async(path, &spec).await?; + let source_index = match parsed.transformed_source.clone() { + Some(source) => sources.add_segmented(source)?, + None => sources.add_file(path)?, + }; + let sample_rate = parsed.sample_rate; + let samples = imported_pcm_samples( + source_index, + parsed.data_offset, + parsed.frame_size, + parsed.frame_count, + sample_entry_box_type(&parsed.sample_entry_box).unwrap_or(FourCc::from_bytes(*b"ipcm")), + parsed.container_kind, + )?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("pcm"), + mux_policy: direct_pcm_mux_policy( + parsed.container_kind, + sample_entry_box_type(&parsed.sample_entry_box).unwrap_or(FourCc::from_bytes(*b"ipcm")), + ), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples, + }) +} + +fn imported_pcm_samples( + source_index: usize, + data_offset: u64, + frame_size: u32, + frame_count: u32, + sample_entry_type: FourCc, + container_kind: PcmContainerKind, +) -> Result, MuxError> { + let mut data_offset = data_offset; + let mut samples = Vec::with_capacity( + usize::try_from(frame_count).map_err(|_| MuxError::LayoutOverflow("PCM frame count"))?, + ); + for _ in 0..frame_count { + samples.push(ImportedSample { + source_index, + data_offset, + data_size: frame_size, + duration: if container_kind == PcmContainerKind::Aifc + && sample_entry_type != FourCc::from_bytes(*b"fpcm") + { + 0 + } else { + 1 + }, + composition_time_offset: 0, + is_sync_sample: true, + }); + data_offset = data_offset + .checked_add(u64::from(frame_size)) + .ok_or(MuxError::LayoutOverflow("PCM frame offset"))?; + } + Ok(samples) +} + +fn direct_pcm_mux_policy( + container_kind: PcmContainerKind, + sample_entry_type: FourCc, +) -> ImportedTrackMuxPolicy { + let mut policy = direct_ingest_mux_policy("pcm", MuxTrackKind::Audio); + if container_kind == PcmContainerKind::Aifc && sample_entry_type != FourCc::from_bytes(*b"fpcm") + { + policy.flat_chunking_mode = FlatChunkingMode::OneSamplePerChunk; + } + policy +} + +#[cfg(feature = "async")] +async fn import_raw_dts_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_dts_file_async(path, &spec).await?; + let source_index = match parsed.transformed_source.clone() { + Some(source) => sources.add_segmented(source)?, + None => sources.add_file(path)?, + }; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.media_timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("dts"), + mux_policy: direct_ingest_mux_policy("dts", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_flac_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + if path_starts_with_sync(path, b"OggS")? { + return import_ogg_flac_sync(path, spec, sources); + } + let source_index = sources.add_file(path)?; + let parsed = scan_flac_file_sync(path, &spec)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("flac"), + mux_policy: direct_ingest_mux_policy("flac", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_flac_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + if path_starts_with_async(path, b"OggS").await? { + return import_ogg_flac_async(path, spec, sources).await; + } + let source_index = sources.add_file(path)?; + let parsed = scan_flac_file_async(path, &spec).await?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("flac"), + mux_policy: direct_ingest_mux_policy("flac", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_mhas_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_mhas_file_sync(path, &spec)?; + let sync_sample_table_mode = if parsed.samples.iter().all(|sample| sample.is_sync_sample) { + SyncSampleTableMode::ForceFirstOnly + } else { + SyncSampleTableMode::Auto + }; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("mhas"), + mux_policy: direct_ingest_mux_policy("mhas", MuxTrackKind::Audio) + .with_sync_sample_table_mode(sync_sample_table_mode) + .with_flat_audio_profile_level_indication(parsed.audio_profile_level_indication), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_mhas_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_mhas_file_async(path, &spec).await?; + let sync_sample_table_mode = if parsed.samples.iter().all(|sample| sample.is_sync_sample) { + SyncSampleTableMode::ForceFirstOnly + } else { + SyncSampleTableMode::Auto + }; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("mhas"), + mux_policy: direct_ingest_mux_policy("mhas", MuxTrackKind::Audio) + .with_sync_sample_table_mode(sync_sample_table_mode) + .with_flat_audio_profile_level_indication(parsed.audio_profile_level_indication), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_iamf_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_iamf_file_sync(path, &spec)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("iamf"), + mux_policy: direct_ingest_mux_policy("iamf", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_iamf_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_iamf_file_async(path, &spec).await?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("iamf"), + mux_policy: direct_ingest_mux_policy("iamf", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_ogg_flac_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_ogg_flac_file_sync(path, &spec)?; + let source_index = sources.add_segmented(parsed.segmented_source)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.media_timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("ogg-flac"), + mux_policy: direct_ingest_mux_policy("ogg-flac", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_ogg_flac_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_ogg_flac_file_async(path, &spec).await?; + let source_index = sources.add_segmented(parsed.segmented_source)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.media_timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("ogg-flac"), + mux_policy: direct_ingest_mux_policy("ogg-flac", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_ogg_opus_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_ogg_opus_file_sync(path, &spec)?; + let source_index = sources.add_segmented(parsed.segmented_source)?; + if let Some(metadata) = parsed.flat_source_encoder_metadata { + sources.set_flat_source_encoder_metadata(source_index, metadata); + } + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: 48_000, + language: *b"und", + handler_name: direct_ingest_handler_name("ogg-opus"), + mux_policy: direct_ingest_mux_policy("ogg-opus", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: parsed.edit_media_time, + sample_roll_distance: parsed.sample_roll_distance, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_ogg_vorbis_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_ogg_vorbis_file_sync(path, &spec)?; + let source_index = sources.add_segmented(parsed.segmented_source)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("ogg-vorbis"), + mux_policy: direct_ingest_mux_policy("ogg-vorbis", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_ogg_speex_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_ogg_speex_file_sync(path, &spec)?; + let source_index = sources.add_segmented(parsed.segmented_source)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("ogg-speex"), + mux_policy: direct_ingest_mux_policy("ogg-speex", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_ogg_theora_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_ogg_theora_file_sync(path, &spec)?; + let source_index = sources.add_segmented(parsed.segmented_source)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("ogg-theora"), + mux_policy: direct_ingest_mux_policy("ogg-theora", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_ogg_opus_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_ogg_opus_file_async(path, &spec).await?; + let source_index = sources.add_segmented(parsed.segmented_source)?; + if let Some(metadata) = parsed.flat_source_encoder_metadata { + sources.set_flat_source_encoder_metadata(source_index, metadata); + } + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: 48_000, + language: *b"und", + handler_name: direct_ingest_handler_name("ogg-opus"), + mux_policy: direct_ingest_mux_policy("ogg-opus", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: parsed.edit_media_time, + sample_roll_distance: parsed.sample_roll_distance, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_caf_alac_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_caf_alac_file_sync(path, &spec)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("caf-alac"), + mux_policy: direct_ingest_mux_policy("caf-alac", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_ogg_vorbis_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_ogg_vorbis_file_async(path, &spec).await?; + let source_index = sources.add_segmented(parsed.segmented_source)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("ogg-vorbis"), + mux_policy: direct_ingest_mux_policy("ogg-vorbis", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_ogg_speex_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_ogg_speex_file_async(path, &spec).await?; + let source_index = sources.add_segmented(parsed.segmented_source)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("ogg-speex"), + mux_policy: direct_ingest_mux_policy("ogg-speex", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_ogg_theora_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_ogg_theora_file_async(path, &spec).await?; + let source_index = sources.add_segmented(parsed.segmented_source)?; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("ogg-theora"), + mux_policy: direct_ingest_mux_policy("ogg-theora", MuxTrackKind::Video), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_caf_alac_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = scan_caf_alac_file_async(path, &spec).await?; + Ok(ImportedTrack { + kind: MuxTrackKind::Audio, + timescale: parsed.sample_rate, + language: *b"und", + handler_name: direct_ingest_handler_name("caf-alac"), + mux_policy: direct_ingest_mux_policy("caf-alac", MuxTrackKind::Audio), + width: 0, + height: 0, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn choose_movie_timescale( + imported_tracks: &[ImportedTrack], + authority_file_config: Option<&MuxFileConfig>, + output_layout: MuxOutputLayout, +) -> Result { + let mut common = 1_u32; + for track in imported_tracks { + common = lcm_u32(common, track.timescale) + .ok_or(MuxError::LayoutOverflow("movie timescale selection"))?; + } + + if matches!(output_layout, MuxOutputLayout::Fragmented) { + return Ok(common.max(1)); + } + + let Some(authority_file_config) = authority_file_config else { + return Ok(common.max(1)); + }; + + let preferred = authority_file_config.movie_timescale(); + if preferred != 0 + && imported_tracks + .iter() + .all(|track| track.mux_policy.header_policy().is_some()) + { + return Ok(preferred); + } + if preferred != 0 + && imported_tracks + .iter() + .all(|track| track_times_fit_movie_timescale(track, preferred)) + { + return Ok(preferred); + } + Ok(common.max(1)) +} + +fn choose_file_config( + movie_timescale: u32, + imported_tracks: &[ImportedTrack], + sources: &SourceCatalog, + authority_file_config: Option<&MuxFileConfig>, + preserve_flat_authority_layout: bool, +) -> MuxFileConfig { + let chosen_flat_source_encoding_metadata = + choose_flat_source_encoding_metadata(imported_tracks, sources); + let chosen_flat_source_encoder_metadata = + choose_flat_source_encoder_metadata(imported_tracks, sources); + let inferred_local_dash_authority_config = chosen_flat_source_encoding_metadata.is_none() + && authority_file_config.is_some_and(|file_config| { + file_config.auto_flat_profile() + && file_config.keep_flat_authority_brands() + && file_config.preserve_auto_flat_movie_timescale() + }); + let imported_mp4_authority_tracks = authority_file_config.is_some() + && imported_tracks.iter().all(|track| { + track + .mux_policy + .header_policy() + .and_then(|policy| policy.source_track_id) + .is_some() + }) + && !inferred_local_dash_authority_config + && chosen_flat_source_encoding_metadata.as_deref() + != Some(LOCAL_DASH_FLAT_TOOL_METADATA_VALUE); + let mut file_config = if let Some(authority_file_config) = authority_file_config { + let mut file_config = MuxFileConfig::new(movie_timescale) + .with_major_brand(authority_file_config.major_brand()) + .with_minor_version(authority_file_config.minor_version()) + .with_compatible_brands(authority_file_config.compatible_brands().to_vec()) + .with_auto_flat_profile(authority_file_config.auto_flat_profile()) + .with_keep_flat_free_box(authority_file_config.keep_flat_free_box()) + .with_keep_flat_authority_brands(authority_file_config.keep_flat_authority_brands()) + .with_preserve_auto_flat_movie_timescale( + authority_file_config.preserve_auto_flat_movie_timescale(), + ) + .with_emit_default_flat_tool_metadata(false) + .with_flat_source_encoding_metadata( + authority_file_config + .flat_source_encoding_metadata() + .map(str::to_string), + ) + .with_flat_source_encoder_metadata( + authority_file_config + .flat_source_encoder_metadata() + .map(str::to_string), + ); + if preserve_flat_authority_layout { + file_config = file_config + .with_flat_source_movie_creation_time( + authority_file_config.flat_source_movie_creation_time(), + ) + .with_flat_source_movie_modification_time( + authority_file_config.flat_source_movie_modification_time(), + ) + .with_preserved_flat_prefix_bytes( + authority_file_config.preserved_flat_prefix_bytes().to_vec(), + ) + .with_preserved_flat_iods_bytes( + authority_file_config + .preserved_flat_iods_bytes() + .map(|bytes| bytes.to_vec()), + ) + .with_preserved_flat_udta_bytes( + authority_file_config + .preserved_flat_udta_bytes() + .map(|bytes| bytes.to_vec()), + ); + } + file_config + } else { + MuxFileConfig::new(movie_timescale).with_auto_flat_profile(true) + }; + + if imported_mp4_authority_tracks { + if preserve_flat_authority_layout { + file_config = file_config + .with_auto_flat_profile(true) + .with_keep_flat_free_box(true) + .with_keep_flat_authority_brands(true) + .with_allow_audio_only_iods(true) + .with_preserve_auto_flat_movie_timescale(true); + } else { + let (major_brand, minor_version, compatible_brands) = + infer_imported_mp4_authority_flat_ftyp_profile(imported_tracks); + file_config = file_config + .with_major_brand(major_brand) + .with_minor_version(minor_version) + .with_compatible_brands(compatible_brands) + .with_auto_flat_profile(true) + .with_keep_flat_free_box(true) + .with_keep_flat_authority_brands(true) + .with_allow_audio_only_iods(true) + .with_preserve_auto_flat_movie_timescale(true); + } + } + + if imported_tracks.iter().all(imported_track_uses_speex_family) { + file_config = file_config + .with_preserve_auto_flat_movie_timescale(false) + .with_emit_default_flat_tool_metadata(true); + } + + if imported_tracks.iter().all(imported_track_uses_dts_family) { + file_config = file_config + .with_auto_flat_profile(true) + .with_keep_flat_free_box(true); + if preserve_flat_authority_layout { + file_config = file_config.with_allow_audio_only_iods(true); + } else if authority_file_config.is_some() && !imported_mp4_authority_tracks { + file_config = file_config + .with_major_brand(FourCc::from_bytes(*b"isom")) + .with_minor_version(1) + .with_compatible_brands(vec![ + FourCc::from_bytes(*b"isom"), + FourCc::from_bytes(*b"iso8"), + FourCc::from_bytes(*b"dtsx"), + ]) + .with_keep_flat_authority_brands(true) + .with_allow_audio_only_iods(true) + .with_preserve_auto_flat_movie_timescale(true); + } + } + + if imported_tracks.iter().all(imported_track_uses_iamf_family) { + file_config = file_config + .with_auto_flat_profile(true) + .with_keep_flat_authority_brands(false); + if imported_tracks + .iter() + .all(|imported_track| imported_track.mux_policy.header_policy().is_some()) + { + file_config = file_config.with_preserve_auto_flat_movie_timescale(true); + } + } + + if imported_tracks + .iter() + .all(imported_track_uses_packet_clocked_flac_family) + { + file_config = file_config.with_allow_audio_only_iods(true); + } + + if !imported_mp4_authority_tracks + && imported_tracks.iter().all(imported_track_uses_mpegh_family) + { + file_config = file_config + .with_auto_flat_profile(true) + .with_preserve_auto_flat_movie_timescale(false); + } + + if imported_tracks + .iter() + .any(imported_track_should_preserve_auto_flat_movie_timescale) + { + file_config = file_config.with_preserve_auto_flat_movie_timescale(true); + } + + if inferred_local_dash_authority_config { + file_config = file_config.with_flat_source_encoding_metadata(Some( + LOCAL_DASH_FLAT_TOOL_METADATA_VALUE.to_string(), + )); + } + + if chosen_flat_source_encoding_metadata.is_some() { + file_config = + file_config.with_flat_source_encoding_metadata(chosen_flat_source_encoding_metadata); + } + if chosen_flat_source_encoder_metadata.is_some() { + file_config = + file_config.with_flat_source_encoder_metadata(chosen_flat_source_encoder_metadata); + } + + file_config +} + +fn choose_flat_source_encoding_metadata( + imported_tracks: &[ImportedTrack], + sources: &SourceCatalog, +) -> Option { + for track in imported_tracks { + let Some(source_index) = track.samples.first().map(|sample| sample.source_index) else { + continue; + }; + if let Some(metadata) = sources.flat_source_encoding_metadata(source_index) { + return Some(metadata.to_string()); + } + } + None +} + +fn choose_flat_source_encoder_metadata( + imported_tracks: &[ImportedTrack], + sources: &SourceCatalog, +) -> Option { + for track in imported_tracks { + let Some(source_index) = track.samples.first().map(|sample| sample.source_index) else { + continue; + }; + if let Some(metadata) = sources.flat_source_encoder_metadata(source_index) { + return Some(metadata.to_string()); + } + } + None +} + +fn normalize_imported_sample_entry_box( + imported_track: &ImportedTrack, + imported_mp4_carry: Option<&ImportedMp4TrackCarry>, + output_layout: MuxOutputLayout, + preserve_flat_authority_layout: bool, +) -> Result, MuxError> { + if matches!(output_layout, MuxOutputLayout::Fragmented) { + if imported_track_uses_avc_family(imported_track) { + return normalize_imported_fragmented_avc_sample_entry_box(imported_track); + } + if imported_track_uses_hevc_family(imported_track) { + return normalize_imported_fragmented_hevc_sample_entry_box(imported_track); + } + if sample_entry_box_type(&imported_track.sample_entry_box) + == Some(FourCc::from_bytes(*b"vp09")) + { + return normalize_imported_fragmented_vp9_sample_entry_box(imported_track); + } + if imported_track_uses_av1_family(imported_track) { + return super::mp4::strip_visual_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &[FourCc::from_bytes(*b"btrt")], + ); + } + if imported_track_uses_dts_family(imported_track) { + return normalize_imported_fragmented_dts_sample_entry_box(imported_track); + } + if imported_track_uses_mpegh_family(imported_track) { + return super::mp4::strip_audio_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &[FourCc::from_bytes(*b"btrt")], + ); + } + if sample_entry_box_type(&imported_track.sample_entry_box) + == Some(FourCc::from_bytes(*b"fLaC")) + { + let stripped_children = + if imported_track_uses_packet_clocked_flac_family(imported_track) { + vec![FourCc::from_bytes(*b"btrt"), FourCc::from_bytes(*b"dfLa")] + } else { + vec![FourCc::from_bytes(*b"btrt")] + }; + return super::mp4::strip_audio_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &stripped_children, + ); + } + let sample_entry_type = sample_entry_box_type(&imported_track.sample_entry_box); + if sample_entry_type == Some(FourCc::from_bytes(*b"ac-3")) + || sample_entry_type == Some(FourCc::from_bytes(*b"ec-3")) + || sample_entry_type == Some(FourCc::from_bytes(*b"ac-4")) + { + return normalize_imported_fragmented_dolby_audio_sample_entry_box(imported_track); + } + } + + if !imported_track_uses_dts_family(imported_track) { + if imported_track_uses_avc_family(imported_track) + && matches!(output_layout, MuxOutputLayout::Flat) + { + return normalize_imported_flat_h264_sample_entry_box( + imported_track, + imported_mp4_carry.is_some_and(|carry| carry.source_had_empty_stts), + preserve_flat_authority_layout, + ); + } + if imported_track_uses_hevc_family(imported_track) + && matches!(output_layout, MuxOutputLayout::Flat) + { + return normalize_imported_flat_hevc_sample_entry_box( + imported_track, + preserve_flat_authority_layout, + ); + } + if imported_track_uses_av1_family(imported_track) + && matches!(output_layout, MuxOutputLayout::Flat) + { + return normalize_imported_flat_av1_sample_entry_box(imported_track); + } + if imported_track_uses_mp4v_family(imported_track) { + return normalize_imported_mp4v_sample_entry_box(imported_track); + } + if imported_track_uses_mp4a_family(imported_track) { + return normalize_imported_mp4a_sample_entry_box( + imported_track, + output_layout, + preserve_flat_authority_layout, + ); + } + if matches!(output_layout, MuxOutputLayout::Flat) + && sample_entry_box_type(&imported_track.sample_entry_box) + == Some(FourCc::from_bytes(*b"spex")) + { + return normalize_imported_flat_speex_sample_entry_box(imported_track); + } + if matches!(output_layout, MuxOutputLayout::Flat) { + let sample_entry_type = sample_entry_box_type(&imported_track.sample_entry_box); + if sample_entry_type == Some(FourCc::from_bytes(*b"ac-3")) + || sample_entry_type == Some(FourCc::from_bytes(*b"ec-3")) + { + return normalize_imported_flat_dolby_audio_sample_entry_box(imported_track); + } + } + if imported_track_uses_iamf_family(imported_track) { + if matches!(output_layout, MuxOutputLayout::Flat) { + if imported_track.mux_policy.header_policy().is_none() { + return Ok(imported_track.sample_entry_box.clone()); + } + let btrt = build_btrt_from_sample_sizes( + imported_track + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + imported_track.timescale, + )?; + return super::mp4::replace_audio_sample_entry_btrt( + &imported_track.sample_entry_box, + &btrt, + ); + } + return super::mp4::strip_audio_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &[FourCc::from_bytes(*b"btrt")], + ); + } + if matches!(output_layout, MuxOutputLayout::Flat) + && imported_track_uses_alac_family(imported_track) + { + let btrt = build_btrt_from_sample_sizes( + imported_track + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + imported_track.timescale, + )?; + let encoded_btrt = super::mp4::encode_typed_box(&btrt, &[])?; + return super::mp4::append_audio_sample_entry_child_box( + &imported_track.sample_entry_box, + &encoded_btrt, + ); + } + if matches!(output_layout, MuxOutputLayout::Flat) { + let sample_entry_type = sample_entry_box_type(&imported_track.sample_entry_box); + if sample_entry_type == Some(FourCc::from_bytes(*b"text")) + || sample_entry_type == Some(FourCc::from_bytes(*b"tx3g")) + { + let btrt = build_btrt_from_sample_sizes( + imported_track + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + imported_track.timescale, + )?; + return super::mp4::replace_opaque_text_sample_entry_btrt( + &imported_track.sample_entry_box, + &btrt, + ); + } + } + if matches!(output_layout, MuxOutputLayout::Flat) + && imported_track_uses_visual_btrt_family(imported_track) + { + if sample_entry_box_type(&imported_track.sample_entry_box).is_some_and(|value| { + (value == FourCc::from_bytes(*b"vvc1") || value == FourCc::from_bytes(*b"vvi1")) + && imported_track.mux_policy.header_policy().is_none() + }) { + return Ok(imported_track.sample_entry_box.clone()); + } + let btrt = build_btrt_from_sample_sizes_with_total_duration( + imported_track + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + imported_track.timescale, + imported_sample_media_duration(&imported_track.samples), + )?; + return super::mp4::replace_visual_sample_entry_btrt( + &imported_track.sample_entry_box, + &btrt, + ); + } + if matches!(output_layout, MuxOutputLayout::Flat) + && imported_track_uses_audio_btrt_family(imported_track) + { + let btrt = build_btrt_from_sample_sizes( + imported_track + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + imported_track.timescale, + )?; + return super::mp4::replace_audio_sample_entry_btrt( + &imported_track.sample_entry_box, + &btrt, + ); + } + return Ok(imported_track.sample_entry_box.clone()); + } + + if imported_track_should_strip_single_sample_dts_btrt(imported_track) { + return super::mp4::strip_audio_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &[FourCc::from_bytes(*b"btrt")], + ); + } + + let btrt = build_btrt_from_sample_sizes( + imported_track + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + imported_track.timescale, + )?; + super::mp4::replace_audio_sample_entry_btrt(&imported_track.sample_entry_box, &btrt) +} + +fn normalize_imported_fragmented_avc_sample_entry_box( + imported_track: &ImportedTrack, +) -> Result, MuxError> { + let child_boxes = + super::mp4::visual_sample_entry_immediate_children(&imported_track.sample_entry_box)?; + let mut normalized_children = Vec::with_capacity(child_boxes.len()); + for child_box in child_boxes { + let child_type = sample_entry_box_type(&child_box); + if child_type == Some(FourCc::from_bytes(*b"btrt")) { + continue; + } + if child_type == Some(FourCc::from_bytes(*b"pasp")) + && fragmented_visual_pasp_is_square(&child_box)? + { + continue; + } + normalized_children.push(child_box); + } + if !normalized_children + .iter() + .any(|child_box| sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"pasp"))) + && let Some(pasp_box) = synthesized_imported_fragmented_avc_pasp_box(imported_track)? + { + normalized_children.push(pasp_box); + } + let normalized_children = reorder_sample_entry_children_by_type_preference( + &normalized_children, + &[ + FourCc::from_bytes(*b"avcC"), + FourCc::from_bytes(*b"pasp"), + FourCc::from_bytes(*b"colr"), + ], + ); + super::mp4::replace_visual_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &normalized_children, + ) +} + +fn normalize_imported_fragmented_hevc_sample_entry_box( + imported_track: &ImportedTrack, +) -> Result, MuxError> { + let child_boxes = + super::mp4::visual_sample_entry_immediate_children(&imported_track.sample_entry_box)?; + let mut normalized_children = Vec::with_capacity(child_boxes.len()); + let keep_only_primary_layer_children = imported_track_uses_layered_hevc_family(imported_track); + for child_box in child_boxes { + let child_type = sample_entry_box_type(&child_box); + if child_type == Some(FourCc::from_bytes(*b"btrt")) { + continue; + } + if child_type == Some(FourCc::from_bytes(*b"pasp")) + && fragmented_visual_pasp_is_square(&child_box)? + { + continue; + } + if keep_only_primary_layer_children + && !matches!( + child_type, + Some(value) + if value == FourCc::from_bytes(*b"hvcC") + || value == FourCc::from_bytes(*b"colr") + || value == FourCc::from_bytes(*b"pasp") + ) + { + continue; + } + normalized_children.push(child_box); + } + let normalized_children = reorder_sample_entry_children_by_type_preference( + &normalized_children, + &[ + FourCc::from_bytes(*b"hvcC"), + FourCc::from_bytes(*b"colr"), + FourCc::from_bytes(*b"pasp"), + ], + ); + super::mp4::replace_visual_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &normalized_children, + ) +} + +fn normalize_imported_fragmented_vp9_sample_entry_box( + imported_track: &ImportedTrack, +) -> Result, MuxError> { + let child_boxes = + super::mp4::visual_sample_entry_immediate_children(&imported_track.sample_entry_box)?; + let mut normalized_children = Vec::with_capacity(child_boxes.len()); + let mut updated = false; + for child_box in child_boxes { + if sample_entry_box_type(&child_box) == Some(FourCc::from_bytes(*b"vpcC")) { + let mut vpcc = super::mp4::decode_typed_box::(&child_box)?; + if vpcc.level == 0 { + vpcc.level = if vpcc.profile == 2 && vpcc.bit_depth >= 10 { + 0x0a + } else { + 0x14 + }; + normalized_children.push(super::mp4::encode_typed_box(&vpcc, &[])?); + updated = true; + continue; + } + } + normalized_children.push(child_box); + } + if !updated { + return Ok(imported_track.sample_entry_box.clone()); + } + super::mp4::replace_visual_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &normalized_children, + ) +} + +fn fragmented_visual_pasp_is_square(child_box: &[u8]) -> Result { + let pasp = super::mp4::decode_typed_box::(child_box)?; + Ok(pasp.h_spacing != 0 && pasp.h_spacing == pasp.v_spacing) +} + +fn synthesized_imported_fragmented_avc_pasp_box( + imported_track: &ImportedTrack, +) -> Result>, MuxError> { + let sample_entry = + super::mp4::decode_typed_box::(&imported_track.sample_entry_box)?; + if sample_entry.width == 0 + || sample_entry.height == 0 + || imported_track.width == 0 + || imported_track.height == 0 + || imported_track.height != sample_entry.height + || imported_track.width == sample_entry.width + { + return Ok(None); + } + let gcd = greatest_common_divisor_u16(imported_track.width, sample_entry.width); + if gcd == 0 { + return Ok(None); + } + let pasp = Pasp { + h_spacing: u32::from(imported_track.width / gcd), + v_spacing: u32::from(sample_entry.width / gcd), + }; + if pasp.h_spacing == 0 || pasp.h_spacing == pasp.v_spacing { + return Ok(None); + } + Ok(Some(super::mp4::encode_typed_box(&pasp, &[])?)) +} + +const fn greatest_common_divisor_u16(mut left: u16, mut right: u16) -> u16 { + while right != 0 { + let remainder = left % right; + left = right; + right = remainder; + } + left +} + +fn fragmented_imported_decode_time_offset_for_staging( + track_id: u32, + imported_track: &ImportedTrack, + output_layout: MuxOutputLayout, + movie_timescale: u32, + allow_inexact_movie_scaling: bool, +) -> Result { + if output_layout != MuxOutputLayout::Fragmented { + return Ok(0); + } + let Some(decode_time_offset) = imported_track + .mux_policy + .header_policy() + .and_then(|policy| policy.source_media_decode_time_offset) + else { + return Ok(0); + }; + let normalized = scale_track_time_to_movie( + track_id, + i64::try_from(decode_time_offset) + .map_err(|_| MuxError::LayoutOverflow("fragment decode-time offset"))?, + imported_track.timescale, + movie_timescale, + allow_inexact_movie_scaling, + )?; + u64::try_from(normalized).map_err(|_| MuxError::LayoutOverflow("fragment decode-time offset")) +} + +fn imported_timing_start_time_ticks( + track_id: u32, + imported_track: &ImportedTrack, + output_layout: MuxOutputLayout, + movie_timescale: u32, + allow_inexact_movie_scaling: bool, +) -> Result { + let mut start_time_ticks = 0_i64; + if output_layout == MuxOutputLayout::Fragmented + && let Some(decode_time_offset) = imported_track + .mux_policy + .header_policy() + .and_then(|policy| policy.source_media_decode_time_offset) + { + let normalized = scale_track_time_to_movie( + track_id, + i64::try_from(decode_time_offset) + .map_err(|_| MuxError::LayoutOverflow("fragment start-time normalization"))?, + imported_track.timescale, + movie_timescale, + allow_inexact_movie_scaling, + )?; + start_time_ticks = + start_time_ticks + .checked_add(normalized) + .ok_or(MuxError::LayoutOverflow( + "fragment start-time normalization", + ))?; + } + if let Some(media_time) = imported_track.source_edit_media_time { + let normalized = scale_track_time_to_movie( + track_id, + i64::try_from(media_time) + .map_err(|_| MuxError::LayoutOverflow("segment start-time normalization"))?, + imported_track.timescale, + movie_timescale, + allow_inexact_movie_scaling, + )?; + start_time_ticks = start_time_ticks + .checked_sub(normalized) + .ok_or(MuxError::LayoutOverflow("segment start-time normalization"))?; + } + Ok(start_time_ticks) +} + +fn derived_fragmented_imported_edit_media_time( + imported_track: &ImportedTrack, + output_layout: MuxOutputLayout, +) -> Option { + if !matches!(output_layout, MuxOutputLayout::Fragmented) + || !imported_track.kind.is_video() + || !imported_track_uses_avc_family(imported_track) + { + return None; + } + imported_track + .samples + .first() + .and_then(|sample| { + (sample.composition_time_offset > 0).then_some(sample.composition_time_offset) + }) + .and_then(|offset| u64::try_from(offset).ok()) +} + +fn normalize_imported_flat_h264_sample_entry_box( + imported_track: &ImportedTrack, + source_had_empty_stts: bool, + preserve_flat_authority_layout: bool, +) -> Result, MuxError> { + let sample_entry_type = sample_entry_box_type(&imported_track.sample_entry_box) + .unwrap_or(FourCc::from_bytes(*b"avc1")); + let source_sample_entry = + super::mp4::decode_typed_box::(&imported_track.sample_entry_box)?; + let source_child_boxes = + super::mp4::visual_sample_entry_immediate_children(&imported_track.sample_entry_box)?; + let source_pasp_index = source_child_boxes.iter().position(|child_box| { + sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"pasp")) + }); + let source_colr_index = source_child_boxes.iter().position(|child_box| { + sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"colr")) + }); + let source_prefers_colr_before_pasp = preserve_flat_authority_layout + && matches!( + (source_pasp_index, source_colr_index), + (Some(pasp_index), Some(colr_index)) if colr_index < pasp_index + ); + let sample_entry_box = { + let child_boxes = + super::mp4::visual_sample_entry_immediate_children(&imported_track.sample_entry_box)?; + let source_btrt_box = child_boxes.iter().find_map(|child_box| { + (sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"btrt"))) + .then(|| child_box.clone()) + }); + let pasp_box = child_boxes.iter().find_map(|child_box| { + (sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"pasp"))) + .then(|| child_box.clone()) + }); + let colr_box = child_boxes.iter().find_map(|child_box| { + (sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"colr"))) + .then(|| child_box.clone()) + }); + let carries_source_btrt = child_boxes.iter().any(|child_box| { + sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"btrt")) + }); + let carries_source_pasp = pasp_box.is_some(); + let carries_source_colr = colr_box.is_some(); + let avcc_box = child_boxes.into_iter().find(|child_box| { + sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"avcC")) + }); + if let Some(avcc_box) = avcc_box { + let avcc = super::mp4::decode_typed_box::(&avcc_box)?; + let (mut rebuilt_sample_entry_box, _, _) = + build_h264_sample_entry_from_avc_config_with_box_type_and_options( + &avcc, + sample_entry_type, + "track", + false, + )?; + if !carries_source_pasp { + rebuilt_sample_entry_box = + super::mp4::strip_visual_sample_entry_immediate_children( + &rebuilt_sample_entry_box, + &[FourCc::from_bytes(*b"pasp")], + )?; + } + if !carries_source_colr { + rebuilt_sample_entry_box = + super::mp4::strip_visual_sample_entry_immediate_children( + &rebuilt_sample_entry_box, + &[FourCc::from_bytes(*b"colr")], + )?; + } + if pasp_box.is_some() || colr_box.is_some() { + let mut rebuilt_children = + super::mp4::visual_sample_entry_immediate_children(&rebuilt_sample_entry_box)?; + if let Some(pasp_box) = pasp_box { + let carries_pasp = rebuilt_children.iter().any(|child_box| { + sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"pasp")) + }); + if !carries_pasp { + rebuilt_children.push(pasp_box); + } + } + if let Some(colr_box) = colr_box { + let carries_colr = rebuilt_children.iter().any(|child_box| { + sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"colr")) + }); + if !carries_colr { + rebuilt_children.push(colr_box); + } + } + rebuilt_sample_entry_box = + super::mp4::replace_visual_sample_entry_immediate_children( + &rebuilt_sample_entry_box, + &rebuilt_children, + )?; + } + let rebuilt_sample_entry_box = super::mp4::replace_visual_sample_entry_compressorname( + &rebuilt_sample_entry_box, + source_sample_entry.compressorname, + )?; + let rebuilt_sample_entry_box = if preserve_flat_authority_layout + && source_btrt_box.is_some() + { + let mut rebuilt_children = + super::mp4::visual_sample_entry_immediate_children(&rebuilt_sample_entry_box)?; + if let Some(source_btrt_box) = source_btrt_box.as_ref() { + if let Some(index) = rebuilt_children.iter().position(|child_box| { + sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"btrt")) + }) { + rebuilt_children[index] = source_btrt_box.clone(); + } else { + rebuilt_children.push(source_btrt_box.clone()); + } + super::mp4::replace_visual_sample_entry_immediate_children( + &rebuilt_sample_entry_box, + &rebuilt_children, + )? + } else { + rebuilt_sample_entry_box + } + } else { + rebuilt_sample_entry_box + }; + (rebuilt_sample_entry_box, carries_source_btrt) + } else { + ( + super::mp4::strip_visual_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &[FourCc::from_bytes(*b"btrt")], + )?, + carries_source_btrt, + ) + } + }; + let normalized = if (!sample_entry_box.1 + && !source_had_empty_stts + && imported_track.source_edit_media_time.is_none()) + || (preserve_flat_authority_layout && sample_entry_box.1) + { + sample_entry_box.0 + } else { + let btrt = build_btrt_from_sample_sizes_with_total_duration( + imported_track + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + imported_track.timescale, + imported_sample_media_duration(&imported_track.samples), + )?; + super::mp4::append_visual_sample_entry_btrt(&sample_entry_box.0, &btrt)? + }; + let child_order = if preserve_flat_authority_layout && source_prefers_colr_before_pasp { + [ + FourCc::from_bytes(*b"avcC"), + FourCc::from_bytes(*b"colr"), + FourCc::from_bytes(*b"pasp"), + FourCc::from_bytes(*b"btrt"), + ] + } else { + [ + FourCc::from_bytes(*b"avcC"), + FourCc::from_bytes(*b"pasp"), + FourCc::from_bytes(*b"colr"), + FourCc::from_bytes(*b"btrt"), + ] + }; + let reordered_children = reorder_sample_entry_children_by_type_preference( + &super::mp4::visual_sample_entry_immediate_children(&normalized)?, + &child_order, + ); + super::mp4::replace_visual_sample_entry_immediate_children(&normalized, &reordered_children) +} + +fn imported_mp4_avc_sample_contains_sync_nal(sample_bytes: &[u8], length_size: usize) -> bool { + if length_size == 0 || length_size > 4 { + return false; + } + let mut offset = 0usize; + while offset < sample_bytes.len() { + if sample_bytes.len() - offset < length_size { + break; + } + let mut nal_size = 0usize; + for byte in &sample_bytes[offset..offset + length_size] { + nal_size = (nal_size << 8) | usize::from(*byte); + } + offset += length_size; + if offset >= sample_bytes.len() { + break; + } + let nal_type = sample_bytes[offset] & 0x1F; + if nal_type == 5 { + return true; + } + if nal_size == 0 || sample_bytes.len() - offset < nal_size { + break; + } + let nal = &sample_bytes[offset..offset + nal_size]; + if imported_mp4_avc_nal_is_intra_slice(nal) { + return true; + } + offset += nal_size; + } + false +} +fn normalize_imported_mp4v_sample_entry_box( + imported_track: &ImportedTrack, +) -> Result, MuxError> { + let btrt = build_btrt_from_sample_sizes_with_total_duration( + imported_track + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + imported_track.timescale, + imported_sample_media_duration(&imported_track.samples), + )?; + let child_boxes = + super::mp4::visual_sample_entry_immediate_children(&imported_track.sample_entry_box)?; + let mut normalized_children = Vec::with_capacity(child_boxes.len()); + let mut updated_esds = false; + for child_box in child_boxes { + match sample_entry_box_type(&child_box) { + Some(box_type) if box_type == FourCc::from_bytes(*b"esds") => {} + _ => { + normalized_children.push(child_box); + continue; + } + } + if sample_entry_box_type(&child_box) != Some(FourCc::from_bytes(*b"esds")) { + normalized_children.push(child_box); + continue; + } + let mut esds = super::mp4::decode_typed_box::(&child_box)?; + for descriptor in &mut esds.descriptors { + if descriptor.tag != DECODER_CONFIG_DESCRIPTOR_TAG { + continue; + } + if let Some(config) = descriptor.decoder_config_descriptor.as_mut() { + config.buffer_size_db = btrt.buffer_size_db; + config.max_bitrate = btrt.max_bitrate; + config.avg_bitrate = btrt.avg_bitrate; + updated_esds = true; + } + } + esds.normalize_descriptor_sizes_for_mux() + .map_err(|_| MuxError::LayoutOverflow("MPEG-4 Part 2 esds"))?; + normalized_children.push(super::mp4::encode_typed_box(&esds, &[])?); + } + if !updated_esds { + return Ok(imported_track.sample_entry_box.clone()); + } + super::mp4::replace_visual_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &normalized_children, + ) +} + +fn normalize_imported_flat_hevc_sample_entry_box( + imported_track: &ImportedTrack, + preserve_flat_authority_layout: bool, +) -> Result, MuxError> { + let child_boxes = + super::mp4::visual_sample_entry_immediate_children(&imported_track.sample_entry_box)?; + let source_btrt_box = child_boxes.iter().find_map(|child_box| { + (sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"btrt"))) + .then(|| child_box.clone()) + }); + let sample_entry_type = sample_entry_box_type(&imported_track.sample_entry_box) + .unwrap_or(FourCc::from_bytes(*b"hvc1")); + let carries_dolby_vision_config = child_boxes.iter().any(|child_box| { + matches!( + sample_entry_box_type(child_box), + Some(value) + if value == FourCc::from_bytes(*b"dvcC") + || value == FourCc::from_bytes(*b"dvvC") + ) + }); + if carries_dolby_vision_config + && matches!( + sample_entry_type, + value + if value == FourCc::from_bytes(*b"dvh1") + || value == FourCc::from_bytes(*b"dvhe") + ) + { + let reordered_children = reorder_sample_entry_children_by_type_preference( + &child_boxes, + &[ + FourCc::from_bytes(*b"hvcC"), + FourCc::from_bytes(*b"dvcC"), + FourCc::from_bytes(*b"dvvC"), + FourCc::from_bytes(*b"pasp"), + FourCc::from_bytes(*b"btrt"), + ], + ); + return super::mp4::replace_visual_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &reordered_children, + ); + } + let sample_entry_box = if carries_dolby_vision_config { + let reordered_children = reorder_sample_entry_children_by_type_preference( + &child_boxes, + &[ + FourCc::from_bytes(*b"hvcC"), + FourCc::from_bytes(*b"dvcC"), + FourCc::from_bytes(*b"dvvC"), + FourCc::from_bytes(*b"pasp"), + FourCc::from_bytes(*b"btrt"), + ], + ); + super::mp4::replace_visual_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &reordered_children, + )? + } else { + imported_track.sample_entry_box.clone() + }; + if preserve_flat_authority_layout && let Some(source_btrt_box) = source_btrt_box { + let mut rebuilt_children = + super::mp4::visual_sample_entry_immediate_children(&sample_entry_box)?; + if let Some(index) = rebuilt_children.iter().position(|child_box| { + sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"btrt")) + }) { + rebuilt_children[index] = source_btrt_box; + } else { + rebuilt_children.push(source_btrt_box); + } + return super::mp4::replace_visual_sample_entry_immediate_children( + &sample_entry_box, + &rebuilt_children, + ); + } + let btrt = build_btrt_from_sample_sizes_with_total_duration( + imported_track + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + imported_track.timescale, + imported_track_flat_authority_media_duration(imported_track) + .or_else(|| imported_sample_media_duration(&imported_track.samples)), + )?; + super::mp4::replace_visual_sample_entry_btrt(&sample_entry_box, &btrt) +} + +fn normalize_imported_flat_av1_sample_entry_box( + imported_track: &ImportedTrack, +) -> Result, MuxError> { + let child_boxes = + super::mp4::visual_sample_entry_immediate_children(&imported_track.sample_entry_box)?; + let carries_dolby_vision_config = child_boxes + .iter() + .any(|child_box| sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"dvvC"))); + if !carries_dolby_vision_config { + return Ok(imported_track.sample_entry_box.clone()); + } + let reordered_children = reorder_sample_entry_children_by_type_preference( + &child_boxes, + &[ + FourCc::from_bytes(*b"av1C"), + FourCc::from_bytes(*b"dvcC"), + FourCc::from_bytes(*b"dvvC"), + FourCc::from_bytes(*b"fiel"), + FourCc::from_bytes(*b"colr"), + FourCc::from_bytes(*b"clli"), + FourCc::from_bytes(*b"mdcv"), + FourCc::from_bytes(*b"pasp"), + FourCc::from_bytes(*b"btrt"), + ], + ); + super::mp4::replace_visual_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &reordered_children, + ) +} + +fn reorder_sample_entry_children_by_type_preference( + child_boxes: &[Vec], + preferred_order: &[FourCc], +) -> Vec> { + let mut ordered = Vec::with_capacity(child_boxes.len()); + let mut used = vec![false; child_boxes.len()]; + for preferred_type in preferred_order { + for (index, child_box) in child_boxes.iter().enumerate() { + if used[index] || sample_entry_box_type(child_box) != Some(*preferred_type) { + continue; + } + ordered.push(child_box.clone()); + used[index] = true; + } + } + for (index, child_box) in child_boxes.iter().enumerate() { + if used[index] { + continue; + } + ordered.push(child_box.clone()); + } + ordered +} + +fn normalize_imported_mp4a_sample_entry_box( + imported_track: &ImportedTrack, + output_layout: MuxOutputLayout, + preserve_flat_authority_layout: bool, +) -> Result, MuxError> { + let child_boxes = + super::mp4::audio_sample_entry_immediate_children(&imported_track.sample_entry_box)?; + let mut normalized_children = Vec::with_capacity(child_boxes.len()); + let mut updated_esds = false; + let mut stripped_child = false; + let btrt = build_btrt_from_sample_sizes( + imported_track + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + imported_track.timescale, + )?; + let rebuild_flat_decoder_config_bitrates = matches!(output_layout, MuxOutputLayout::Flat) + && (!preserve_flat_authority_layout + || imported_track_should_rechunk_flat_audio(imported_track)); + for child_box in child_boxes { + let child_box_type = sample_entry_box_type(&child_box); + if matches!(output_layout, MuxOutputLayout::Fragmented) + && child_box_type.is_some_and(imported_fragmented_audio_child_should_be_stripped) + { + stripped_child = true; + continue; + } + if matches!(output_layout, MuxOutputLayout::Flat) + && child_box_type.is_some_and(imported_flat_mp4a_child_should_be_stripped) + { + continue; + } + if child_box_type != Some(FourCc::from_bytes(*b"esds")) { + normalized_children.push(child_box); + continue; + } + let mut esds = super::mp4::decode_typed_box::(&child_box)?; + for descriptor in &mut esds.descriptors { + if descriptor.tag == crate::boxes::iso14496_14::ES_DESCRIPTOR_TAG + && let Some(es_descriptor) = descriptor.es_descriptor.as_mut() + && es_descriptor.es_id != 0 + { + es_descriptor.es_id = 0; + updated_esds = true; + } + if descriptor.tag == DECODER_CONFIG_DESCRIPTOR_TAG + && let Some(config) = descriptor.decoder_config_descriptor.as_mut() + { + if matches!(output_layout, MuxOutputLayout::Fragmented) { + if config.buffer_size_db != 0 { + config.buffer_size_db = 0; + updated_esds = true; + } + } else if rebuild_flat_decoder_config_bitrates { + if config.buffer_size_db != btrt.buffer_size_db { + config.buffer_size_db = btrt.buffer_size_db; + updated_esds = true; + } + if config.max_bitrate != btrt.max_bitrate { + config.max_bitrate = btrt.max_bitrate; + updated_esds = true; + } + if config.avg_bitrate != btrt.avg_bitrate { + config.avg_bitrate = btrt.avg_bitrate; + updated_esds = true; + } + } + } + } + esds.normalize_descriptor_sizes_for_mux() + .map_err(|_| MuxError::LayoutOverflow("AAC esds normalization"))?; + normalized_children.push(super::mp4::encode_typed_box(&esds, &[])?); + } + if !updated_esds && !stripped_child { + return Ok(imported_track.sample_entry_box.clone()); + } + super::mp4::replace_audio_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &normalized_children, + ) +} + +fn normalize_imported_fragmented_dolby_audio_sample_entry_box( + imported_track: &ImportedTrack, +) -> Result, MuxError> { + let child_boxes = + super::mp4::audio_sample_entry_immediate_children(&imported_track.sample_entry_box)?; + let mut normalized_children = Vec::with_capacity(child_boxes.len()); + + for child_box in child_boxes { + let child_box_type = sample_entry_box_type(&child_box); + if child_box_type.is_some_and(imported_fragmented_audio_child_should_be_stripped) { + continue; + } + if child_box_type == Some(FourCc::from_bytes(*b"dec3")) { + let mut dec3 = + super::mp4::decode_typed_box::(&child_box)?; + while dec3.reserved.last() == Some(&0) { + dec3.reserved.pop(); + } + normalized_children.push(super::mp4::encode_typed_box(&dec3, &[])?); + continue; + } + normalized_children.push(child_box); + } + + super::mp4::replace_audio_sample_entry_immediate_children_without_trailing_bytes( + &imported_track.sample_entry_box, + &normalized_children, + ) +} + +fn normalize_imported_flat_dolby_audio_sample_entry_box( + imported_track: &ImportedTrack, +) -> Result, MuxError> { + let child_boxes = + super::mp4::audio_sample_entry_immediate_children(&imported_track.sample_entry_box)?; + let mut normalized_children = Vec::with_capacity(child_boxes.len()); + let btrt = build_btrt_from_sample_sizes( + imported_track + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + imported_track.timescale, + )?; + + for child_box in child_boxes { + let child_box_type = sample_entry_box_type(&child_box); + if child_box_type == Some(FourCc::from_bytes(*b"btrt")) { + continue; + } + if child_box_type == Some(FourCc::from_bytes(*b"dec3")) { + let mut dec3 = + super::mp4::decode_typed_box::(&child_box)?; + while dec3.reserved.last() == Some(&0) { + dec3.reserved.pop(); + } + normalized_children.push(super::mp4::encode_typed_box(&dec3, &[])?); + continue; + } + normalized_children.push(child_box); + } + + normalized_children.push(super::mp4::encode_typed_box(&btrt, &[])?); + super::mp4::replace_audio_sample_entry_immediate_children_without_trailing_bytes( + &imported_track.sample_entry_box, + &normalized_children, + ) +} + +fn normalize_imported_flat_speex_sample_entry_box( + imported_track: &ImportedTrack, +) -> Result, MuxError> { + let mut sample_entry = super::mp4::decode_audio_sample_entry(&imported_track.sample_entry_box)?; + sample_entry.sample_size = 16; + let btrt = build_btrt_from_sample_sizes( + imported_track + .samples + .iter() + .map(|sample| (sample.data_size, sample.duration)), + imported_track.timescale, + )?; + let btrt_box = super::mp4::encode_typed_box(&btrt, &[])?; + let normalized = super::mp4::encode_typed_box(&sample_entry, &btrt_box)?; + if let Some(vendor_code) = + super::mp4::audio_sample_entry_vendor_code(&imported_track.sample_entry_box)? + { + return super::mp4::replace_audio_sample_entry_vendor_code(&normalized, vendor_code); + } + Ok(normalized) +} + +fn imported_flat_mp4a_child_should_be_stripped(box_type: FourCc) -> bool { + matches!( + box_type, + value + if value == FourCc::from_bytes(*b"dmix") + || value == FourCc::from_bytes(*b"udc2") + || value == FourCc::from_bytes(*b"udi2") + || value == FourCc::from_bytes(*b"udex") + || value == FourCc::from_bytes(*b"sbtd") + ) +} + +fn imported_fragmented_audio_child_should_be_stripped(box_type: FourCc) -> bool { + box_type == FourCc::from_bytes(*b"btrt") || box_type == FourCc::from_u32(0) +} + +fn imported_sample_media_duration(samples: &[ImportedSample]) -> Option { + let mut decode_time = 0_u64; + let mut media_duration = 0_u64; + for sample in samples { + let duration = u64::from(sample.duration); + let decode_end = decode_time.checked_add(duration)?; + media_duration = media_duration.max(decode_end); + let presentation_end = i128::from(decode_time) + .saturating_add(i128::from(sample.composition_time_offset)) + .saturating_add(i128::from(sample.duration)); + if presentation_end > 0 { + media_duration = media_duration.max(u64::try_from(presentation_end).ok()?); + } + decode_time = decode_end; + } + Some(media_duration) +} + +fn imported_track_should_strip_single_sample_dts_btrt(imported_track: &ImportedTrack) -> bool { + imported_track.mux_policy.strip_single_sample_dts_btrt() && imported_track.samples.len() == 1 +} + +fn normalize_imported_fragmented_dts_sample_entry_box( + imported_track: &ImportedTrack, +) -> Result, MuxError> { + if sample_entry_box_type(&imported_track.sample_entry_box) != Some(FourCc::from_bytes(*b"dtsc")) + { + return super::mp4::strip_audio_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &[FourCc::from_bytes(*b"btrt")], + ); + } + + let child_boxes = + super::mp4::audio_sample_entry_immediate_children(&imported_track.sample_entry_box)?; + if child_boxes + .iter() + .any(|child_box| sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"ddts"))) + { + return super::mp4::strip_audio_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &[FourCc::from_bytes(*b"btrt")], + ); + } + + let sample_entry = super::mp4::decode_audio_sample_entry(&imported_track.sample_entry_box)?; + let ddts_box = super::mp4::encode_typed_box( + &Ddts { + sampling_frequency: u32::from(sample_entry.sample_rate_int()), + max_bitrate: 0, + avg_bitrate: 0, + sample_depth: u8::try_from(sample_entry.sample_size) + .map_err(|_| MuxError::LayoutOverflow("DTS sample depth"))?, + frame_duration: IMPORTED_DDTS_FRAME_DURATION, + stream_construction: IMPORTED_DDTS_STREAM_CONSTRUCTION, + core_lfe_present: false, + core_layout: IMPORTED_DDTS_CORE_LAYOUT, + core_size: 0, + stereo_downmix: false, + representation_type: IMPORTED_DDTS_REPRESENTATION_TYPE, + channel_layout: IMPORTED_DDTS_CHANNEL_LAYOUT_MASK, + multi_asset_flag: false, + lbr_duration_mod: false, + }, + &[], + )?; + super::mp4::replace_audio_sample_entry_immediate_children( + &imported_track.sample_entry_box, + &[ddts_box], + ) +} + +fn imported_track_uses_avc_family(imported_track: &ImportedTrack) -> bool { + matches!( + sample_entry_box_type(&imported_track.sample_entry_box), + Some(value) + if value == FourCc::from_bytes(*b"avc1") + || value == FourCc::from_bytes(*b"avc2") + || value == FourCc::from_bytes(*b"avc3") + || value == FourCc::from_bytes(*b"avc4") + ) +} + +fn imported_track_uses_mp4a_family(imported_track: &ImportedTrack) -> bool { + sample_entry_box_type(&imported_track.sample_entry_box) == Some(FourCc::from_bytes(*b"mp4a")) +} + +fn imported_track_uses_xhe_aac_family(imported_track: &ImportedTrack) -> bool { + if !imported_track_uses_mp4a_family(imported_track) { + return false; + } + let Ok((_, child_boxes, _)) = + super::mp4::decode_audio_sample_entry_parts(&imported_track.sample_entry_box) + else { + return false; + }; + for child_box in child_boxes { + if sample_entry_box_type(&child_box) != Some(FourCc::from_bytes(*b"esds")) { + continue; + } + let Ok(esds) = super::mp4::decode_typed_box::(&child_box) else { + continue; + }; + let Ok(profile) = detect_aac_profile(&esds) else { + continue; + }; + if profile.is_some_and(|profile| profile.audio_object_type == 42) { + return true; + } + } + false +} + +fn box_header_type(box_bytes: &[u8]) -> Option { + let bytes: [u8; 4] = box_bytes.get(4..8)?.try_into().ok()?; + Some(FourCc::from_bytes(bytes)) +} + +fn imported_track_uses_speex_family(imported_track: &ImportedTrack) -> bool { + sample_entry_box_type(&imported_track.sample_entry_box) == Some(FourCc::from_bytes(*b"spex")) +} + +fn imported_track_uses_mp4v_family(imported_track: &ImportedTrack) -> bool { + sample_entry_box_type(&imported_track.sample_entry_box) == Some(FourCc::from_bytes(*b"mp4v")) +} + +fn imported_track_uses_alac_family(imported_track: &ImportedTrack) -> bool { + sample_entry_box_type(&imported_track.sample_entry_box) == Some(FourCc::from_bytes(*b"alac")) +} + +fn imported_track_uses_audio_btrt_family(imported_track: &ImportedTrack) -> bool { + matches!( + sample_entry_box_type(&imported_track.sample_entry_box), + Some(value) + if value == FourCc::from_bytes(*b"ac-3") + || value == FourCc::from_bytes(*b"ec-3") + || value == FourCc::from_bytes(*b"ac-4") + || value == FourCc::from_bytes(*b"fLaC") + ) +} + +fn imported_track_uses_visual_btrt_family(imported_track: &ImportedTrack) -> bool { + matches!( + sample_entry_box_type(&imported_track.sample_entry_box), + Some(value) + if value == FourCc::from_bytes(*b"hev1") + || value == FourCc::from_bytes(*b"hvc1") + || value == FourCc::from_bytes(*b"vvc1") + || value == FourCc::from_bytes(*b"vvi1") + || value == FourCc::from_bytes(*b"dvh1") + || value == FourCc::from_bytes(*b"dvhe") + ) +} + +fn imported_track_uses_dts_family(imported_track: &ImportedTrack) -> bool { + matches!( + sample_entry_box_type(&imported_track.sample_entry_box), + Some(value) + if value == FourCc::from_bytes(*b"dtsc") + || value == FourCc::from_bytes(*b"dtse") + || value == FourCc::from_bytes(*b"dtsh") + || value == FourCc::from_bytes(*b"dtsl") + || value == FourCc::from_bytes(*b"dtsm") + || value == FourCc::from_bytes(*b"dts-") + || value == FourCc::from_bytes(*b"dtsx") + || value == FourCc::from_bytes(*b"dtsy") + ) +} + +fn imported_track_uses_iamf_family(imported_track: &ImportedTrack) -> bool { + sample_entry_box_type(&imported_track.sample_entry_box) == Some(FourCc::from_bytes(*b"iamf")) +} + +fn imported_track_uses_mpegh_family(imported_track: &ImportedTrack) -> bool { + matches!( + sample_entry_box_type(&imported_track.sample_entry_box), + Some(value) + if value == FourCc::from_bytes(*b"mha1") + || value == FourCc::from_bytes(*b"mha2") + || value == FourCc::from_bytes(*b"mhm1") + || value == FourCc::from_bytes(*b"mhm2") + ) +} + +fn imported_track_uses_mp3_family(imported_track: &ImportedTrack) -> bool { + match sample_entry_box_type(&imported_track.sample_entry_box) { + Some(value) if value == FourCc::from_bytes(*b".mp3") => true, + Some(value) if value == FourCc::from_bytes(*b"mp4a") => { + sample_entry_carries_oti(&imported_track.sample_entry_box, 0x6B) + } + _ => false, + } +} + +fn imported_track_uses_vvc_family(imported_track: &ImportedTrack) -> bool { + matches!( + sample_entry_box_type(&imported_track.sample_entry_box), + Some(value) + if value == FourCc::from_bytes(*b"vvc1") || value == FourCc::from_bytes(*b"vvi1") + ) +} + +fn sample_entry_carries_oti(sample_entry_box: &[u8], object_type_indication: u8) -> bool { + let sample_entry_type = match sample_entry_box_type(sample_entry_box) { + Some(value) => value, + None => return false, + }; + let child_boxes = match sample_entry_type { + value if value == FourCc::from_bytes(*b"mp4a") => { + match super::mp4::decode_audio_sample_entry_parts(sample_entry_box) { + Ok((_, child_boxes, _)) => child_boxes, + Err(_) => return false, + } + } + value if value == FourCc::from_bytes(*b"mp4v") => { + match super::mp4::decode_visual_sample_entry_parts(sample_entry_box) { + Ok((_, child_boxes, _)) => child_boxes, + Err(_) => return false, + } + } + _ => return false, + }; + for child_box in child_boxes { + if sample_entry_box_type(&child_box) != Some(FourCc::from_bytes(*b"esds")) { + continue; + } + let Ok(esds) = super::mp4::decode_typed_box::(&child_box) else { + continue; + }; + for descriptor in esds.descriptors { + if let Some(decoder_config) = descriptor.decoder_config_descriptor + && decoder_config.object_type_indication == object_type_indication + { + return true; + } + } + } + false +} + +fn imported_track_uses_packet_clocked_flac_family(imported_track: &ImportedTrack) -> bool { + sample_entry_box_type(&imported_track.sample_entry_box) == Some(FourCc::from_bytes(*b"fLaC")) + && imported_track.timescale == 1_000 + && imported_track_sample_entry_audio_sample_rate(&imported_track.sample_entry_box) + == Some(1_000) +} + +fn imported_track_uses_hevc_family(imported_track: &ImportedTrack) -> bool { + matches!( + sample_entry_box_type(&imported_track.sample_entry_box), + Some(value) + if value == FourCc::from_bytes(*b"hev1") + || value == FourCc::from_bytes(*b"hvc1") + || value == FourCc::from_bytes(*b"dvh1") + || value == FourCc::from_bytes(*b"dvhe") + ) +} + +fn imported_track_uses_av1_family(imported_track: &ImportedTrack) -> bool { + matches!( + sample_entry_box_type(&imported_track.sample_entry_box), + Some(value) + if value == FourCc::from_bytes(*b"av01") + || value == FourCc::from_bytes(*b"dav1") + ) +} + +fn infer_imported_mp4_authority_flat_ftyp_profile( + imported_tracks: &[ImportedTrack], +) -> (FourCc, u32, Vec) { + if imported_tracks + .iter() + .all(imported_track_uses_layered_hevc_family) + { + return ( + FourCc::from_bytes(*b"isom"), + 1, + vec![FourCc::from_bytes(*b"isom"), FourCc::from_bytes(*b"iso4")], + ); + } + ( + FourCc::from_bytes(*b"isom"), + 1, + vec![FourCc::from_bytes(*b"isom")], + ) +} + +fn imported_track_should_preserve_auto_flat_movie_timescale( + imported_track: &ImportedTrack, +) -> bool { + matches!( + sample_entry_box_type(&imported_track.sample_entry_box), + Some(value) + if value == FourCc::from_bytes(*b"apco") + || value == FourCc::from_bytes(*b"apcn") + || value == FourCc::from_bytes(*b"apch") + || value == FourCc::from_bytes(*b"apcs") + || value == FourCc::from_bytes(*b"ap4x") + || value == FourCc::from_bytes(*b"ap4h") + ) +} + +fn imported_track_sample_entry_audio_sample_rate(sample_entry_box: &[u8]) -> Option { + let rate_bytes = sample_entry_box.get(32..36)?; + let rate = u32::from_be_bytes(rate_bytes.try_into().ok()?); + Some(rate >> 16) +} + +fn track_candidate_uses_dts_family(track: &TrackCandidate) -> bool { + matches!( + sample_entry_box_type(&track.sample_entry_box), + Some(value) + if value == FourCc::from_bytes(*b"dtsc") + || value == FourCc::from_bytes(*b"dtse") + || value == FourCc::from_bytes(*b"dtsh") + || value == FourCc::from_bytes(*b"dtsl") + || value == FourCc::from_bytes(*b"dtsm") + || value == FourCc::from_bytes(*b"dts-") + || value == FourCc::from_bytes(*b"dtsx") + || value == FourCc::from_bytes(*b"dtsy") + ) +} + +fn sample_entry_box_type(sample_entry_box: &[u8]) -> Option { + Some(FourCc::from_bytes( + sample_entry_box.get(4..8)?.try_into().ok()?, + )) +} + +fn validate_request_shape(request: &MuxRequest, output_path: &Path) -> Result<(), MuxError> { + if request.tracks().is_empty() { + return Err(MuxError::MissingTrackSpecs); + } + if matches!( + request.destination_mode(), + MuxDestinationMode::UpdateOrCreateDestination + ) { + if !matches!(request.output_layout(), MuxOutputLayout::Flat) { + return Err(MuxError::InvalidDestinationMode { + mode: request.destination_mode().label(), + message: "the current destination-path mux mode only supports flat output; use `--out PATH` for create-new fragmented output".to_string(), + }); + } + let output_absolute = absolute_path(output_path)?; + for track in request.tracks() { + let input_absolute = absolute_path(track.input_path())?; + if input_absolute == output_absolute { + return Err(MuxError::InvalidDestinationMode { + mode: request.destination_mode().label(), + message: "destination-path mux mode does not accept the destination file as an explicit input track".to_string(), + }); + } + } + } + match (request.output_layout(), request.duration_mode()) { + (MuxOutputLayout::Flat, Some(duration_mode)) => { + return Err(MuxError::InvalidOutputLayout { + layout: request.output_layout().label(), + message: format!( + "flat output does not support `--{}`; use `--layout fragmented` instead", + duration_mode.label() + ), + }); + } + (MuxOutputLayout::Fragmented, None) => { + return Err(MuxError::InvalidOutputLayout { + layout: request.output_layout().label(), + message: "fragmented output requires exactly one of `--segment_duration` or `--fragment_duration`".to_string(), + }); + } + _ => {} + } + let video_count = request + .tracks() + .iter() + .filter(|track| { + matches!( + track, + MuxTrackSpec::Path { + selector: Some(MuxMp4TrackSelector::Video), + .. + } + ) + }) + .count(); + if matches!(request.output_layout(), MuxOutputLayout::Fragmented) && video_count > 1 { + return Err(MuxError::MultipleVideoTracks { count: video_count }); + } + + let output_absolute = absolute_path(output_path)?; + for track in request.tracks() { + let input_absolute = absolute_path(track.input_path())?; + if input_absolute == output_absolute { + return Err(MuxError::OutputPathConflict { + output: output_absolute, + input: input_absolute, + }); + } + } + Ok(()) +} + +fn validate_fragmented_split_paths( + request: &MuxRequest, + init_path: &Path, + media_path: &Path, +) -> Result<(), MuxError> { + if !matches!(request.output_layout(), MuxOutputLayout::Fragmented) { + return Err(MuxError::InvalidOutputLayout { + layout: request.output_layout().label(), + message: "separate fragmented output requires fragmented layout".to_string(), + }); + } + validate_request_shape(request, media_path)?; + let init_absolute = absolute_path(init_path)?; + let media_absolute = absolute_path(media_path)?; + if init_absolute == media_absolute { + return Err(MuxError::InvalidDestinationMode { + mode: MuxDestinationMode::CreateNew.label(), + message: "separate fragmented output paths must be distinct".to_string(), + }); + } + for (label, path) in [("init", init_path), ("media", media_path)] { + if path.exists() { + return Err(MuxError::InvalidDestinationMode { + mode: MuxDestinationMode::CreateNew.label(), + message: format!("separate fragmented {label} output path already exists"), + }); + } + } + for track in request.tracks() { + let input_absolute = absolute_path(track.input_path())?; + if input_absolute == init_absolute { + return Err(MuxError::OutputPathConflict { + output: init_absolute, + input: input_absolute, + }); + } + if input_absolute == media_absolute { + return Err(MuxError::OutputPathConflict { + output: media_absolute, + input: input_absolute, + }); + } + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn validate_fragmented_split_paths_async( + request: &MuxRequest, + init_path: &Path, + media_path: &Path, +) -> Result<(), MuxError> { + if !matches!(request.output_layout(), MuxOutputLayout::Fragmented) { + return Err(MuxError::InvalidOutputLayout { + layout: request.output_layout().label(), + message: "separate fragmented output requires fragmented layout".to_string(), + }); + } + validate_request_shape(request, media_path)?; + let init_absolute = absolute_path(init_path)?; + let media_absolute = absolute_path(media_path)?; + if init_absolute == media_absolute { + return Err(MuxError::InvalidDestinationMode { + mode: MuxDestinationMode::CreateNew.label(), + message: "separate fragmented output paths must be distinct".to_string(), + }); + } + for (label, path) in [("init", init_path), ("media", media_path)] { + if tokio::fs::try_exists(path) + .await + .map_err(|error| mux_io_at_path("inspect mux output", path, error))? + { + return Err(MuxError::InvalidDestinationMode { + mode: MuxDestinationMode::CreateNew.label(), + message: format!("separate fragmented {label} output path already exists"), + }); + } + } + for track in request.tracks() { + let input_absolute = absolute_path(track.input_path())?; + if input_absolute == init_absolute { + return Err(MuxError::OutputPathConflict { + output: init_absolute, + input: input_absolute, + }); + } + if input_absolute == media_absolute { + return Err(MuxError::OutputPathConflict { + output: media_absolute, + input: input_absolute, + }); + } + } + Ok(()) +} + +fn build_destination_preserving_request( + request: &MuxRequest, + destination_path: &Path, +) -> Result { + if !matches!( + request.destination_mode(), + MuxDestinationMode::UpdateOrCreateDestination + ) { + return Err(MuxError::InvalidDestinationMode { + mode: request.destination_mode().label(), + message: "request did not opt into the destination-path mux mode".to_string(), + }); + } + let mut tracks = Vec::with_capacity(request.tracks().len() + 1); + tracks.push(MuxTrackSpec::path(destination_path.to_path_buf())); + tracks.extend(request.tracks().iter().cloned()); + let mut amended = MuxRequest::new(tracks) + .with_output_layout(request.output_layout()) + .with_destination_mode(MuxDestinationMode::CreateNew) + .with_preserve_flat_authority_layout(true); + if let Some(duration_mode) = request.duration_mode() { + amended = amended.with_duration_mode(duration_mode); + } + Ok(amended) +} + +fn should_preserve_destination_mp4(destination_path: &Path) -> bool { + is_mp4_like_path(destination_path) +} + +#[cfg(feature = "async")] +async fn should_preserve_destination_mp4_async(destination_path: &Path) -> bool { + matches!( + detect_path_track_kind_async(destination_path).await, + Ok(DetectedPathTrackKind::Mp4) + ) +} + +fn create_update_temp_path( + output_path: &Path, + mode: MuxDestinationMode, +) -> Result { + let parent = output_path + .parent() + .ok_or_else(|| MuxError::InvalidDestinationMode { + mode: mode.label(), + message: format!( + "cannot derive a temporary rewrite path for `{}`", + output_path.display() + ), + })?; + let file_name = output_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| MuxError::InvalidDestinationMode { + mode: mode.label(), + message: format!( + "cannot derive a temporary rewrite path for `{}`", + output_path.display() + ), + })?; + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| MuxError::InvalidDestinationMode { + mode: mode.label(), + message: "system clock is earlier than the Unix epoch".to_string(), + })? + .as_nanos(); + Ok(parent.join(format!("{file_name}.mp4forge-rewrite-{stamp}.tmp"))) +} + +fn ensure_output_parent_dir(output_path: &Path) -> Result<(), MuxError> { + let Some(parent) = output_path.parent() else { + return Ok(()); + }; + if parent.as_os_str().is_empty() { + return Ok(()); + } + std::fs::create_dir_all(parent) + .map_err(|error| mux_io_at_path("create mux output directory", parent, error)) +} + +fn finalize_new_split_output( + temp_path: &Path, + output_path: &Path, + already_created_path: Option<&Path>, +) -> Result<(), MuxError> { + if let Err(error) = std::fs::rename(temp_path, output_path) { + let _ = std::fs::remove_file(temp_path); + if let Some(path) = already_created_path { + let _ = std::fs::remove_file(path); + } + return Err(mux_io_at_path("finalize mux output", output_path, error)); + } + Ok(()) +} + +fn replace_output_path(temp_path: &Path, output_path: &Path) -> Result<(), MuxError> { + let backup_path = temp_path.with_extension("backup"); + if backup_path.exists() { + std::fs::remove_file(&backup_path)?; + } + std::fs::rename(output_path, &backup_path)?; + match std::fs::rename(temp_path, output_path) { + Ok(()) => { + let _ = std::fs::remove_file(&backup_path); + Ok(()) + } + Err(error) => { + let _ = std::fs::rename(&backup_path, output_path); + Err(MuxError::Io(error)) + } + } +} + +#[cfg(feature = "async")] +async fn ensure_output_parent_dir_async(output_path: &Path) -> Result<(), MuxError> { + let Some(parent) = output_path.parent() else { + return Ok(()); + }; + if parent.as_os_str().is_empty() { + return Ok(()); + } + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| mux_io_at_path("create mux output directory", parent, error)) +} + +#[cfg(feature = "async")] +async fn finalize_new_split_output_async( + temp_path: &Path, + output_path: &Path, + already_created_path: Option<&Path>, +) -> Result<(), MuxError> { + if let Err(error) = tokio::fs::rename(temp_path, output_path).await { + let _ = tokio::fs::remove_file(temp_path).await; + if let Some(path) = already_created_path { + let _ = tokio::fs::remove_file(path).await; + } + return Err(mux_io_at_path("finalize mux output", output_path, error)); + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn replace_output_path_async(temp_path: &Path, output_path: &Path) -> Result<(), MuxError> { + let backup_path = temp_path.with_extension("backup"); + if tokio::fs::try_exists(&backup_path).await? { + tokio::fs::remove_file(&backup_path).await?; + } + tokio::fs::rename(output_path, &backup_path).await?; + match tokio::fs::rename(temp_path, output_path).await { + Ok(()) => { + let _ = tokio::fs::remove_file(&backup_path).await; + Ok(()) + } + Err(error) => { + let _ = tokio::fs::rename(&backup_path, output_path).await; + Err(MuxError::Io(error)) + } + } +} + +fn display_track_spec(track: &MuxTrackSpec) -> String { + match track { + MuxTrackSpec::Path { path, selector } => match selector { + Some(selector) => format!("{}#{}", path.display(), format_mp4_selector(*selector)), + None => path.display().to_string(), + }, + MuxTrackSpec::RawVideo { path, params } => { + format!("{}#{}", path.display(), params.format_suffix()) + } + } +} + +fn format_mp4_selector(selector: MuxMp4TrackSelector) -> String { + match selector { + MuxMp4TrackSelector::Video => "video".to_string(), + MuxMp4TrackSelector::Audio { occurrence: 1 } => "audio".to_string(), + MuxMp4TrackSelector::Audio { occurrence } => format!("audio:{occurrence}"), + MuxMp4TrackSelector::Text { occurrence: 1 } => "text".to_string(), + MuxMp4TrackSelector::Text { occurrence } => format!("text:{occurrence}"), + MuxMp4TrackSelector::TrackId { track_id } => format!("track:{track_id}"), + } +} + +fn detect_path_track_kind_sync(path: &Path) -> Result { + let mut file = + File::open(path).map_err(|error| mux_io_at_path("open mux input", path, error))?; + let mut prefix = [0_u8; PATH_KIND_PREFIX_BYTES]; + let read = file.read(&mut prefix)?; + let prefix = &prefix[..read]; + if prefix.starts_with(b"OggS") { + file.seek(SeekFrom::Start(0))?; + return detect_ogg_track_kind_sync(&mut file); + } + if prefix.starts_with(b"caff") { + file.seek(SeekFrom::Start(0))?; + return detect_caf_track_kind_sync(&mut file); + } + if let Some(kind) = detect_container_path_kind_from_path_and_prefix(path, prefix) { + return Ok(DetectedPathTrackKind::Container(kind)); + } + if let Some(kind) = detect_nhml_sidecar_kind(path, prefix) { + return Ok(DetectedPathTrackKind::Container(kind)); + } + if let Some(kind) = detect_id3_wrapped_audio_sync(&mut file, prefix)? { + return Ok(kind); + } + if let Some(kind) = detect_vobsub_track_kind_sync(path, prefix)? { + return Ok(kind); + } + let detected = detect_path_track_kind_from_prefix(prefix); + if matches!(detected, DetectedPathTrackKind::Mp4ImportOnly(_)) + && prefix.starts_with(b"DTSHDHDR") + { + file.seek(SeekFrom::Start(0))?; + let file_size = file.metadata()?.len(); + if wrapped_dts_family_has_native_core_sync_sync( + &mut file, + file_size, + &path.display().to_string(), + )? { + return Ok(DetectedPathTrackKind::Raw(MuxRawCodec::Dts)); + } + } + if detected != DetectedPathTrackKind::Unknown { + return Ok(detected); + } + Ok(detect_av1_extension_fallback(path).unwrap_or(DetectedPathTrackKind::Unknown)) +} + +fn is_mp4_like_path(path: &Path) -> bool { + matches!( + detect_path_track_kind_sync(path), + Ok(DetectedPathTrackKind::Mp4) + ) +} + +#[cfg(feature = "async")] +async fn detect_path_track_kind_async(path: &Path) -> Result { + let mut file = TokioFile::open(path) + .await + .map_err(|error| mux_io_at_path("open mux input", path, error))?; + let mut prefix = [0_u8; PATH_KIND_PREFIX_BYTES]; + let read = file.read(&mut prefix).await?; + let prefix = &prefix[..read]; + if prefix.starts_with(b"OggS") { + file.seek(SeekFrom::Start(0)).await?; + return detect_ogg_track_kind_async(&mut file).await; + } + if prefix.starts_with(b"caff") { + file.seek(SeekFrom::Start(0)).await?; + return detect_caf_track_kind_async(&mut file).await; + } + if let Some(kind) = detect_container_path_kind_from_path_and_prefix(path, prefix) { + return Ok(DetectedPathTrackKind::Container(kind)); + } + if let Some(kind) = detect_nhml_sidecar_kind(path, prefix) { + return Ok(DetectedPathTrackKind::Container(kind)); + } + if let Some(kind) = detect_id3_wrapped_audio_async(&mut file, prefix).await? { + return Ok(kind); + } + if let Some(kind) = detect_vobsub_track_kind_async(path, prefix).await? { + return Ok(kind); + } + let detected = detect_path_track_kind_from_prefix(prefix); + if matches!(detected, DetectedPathTrackKind::Mp4ImportOnly(_)) + && prefix.starts_with(b"DTSHDHDR") + { + file.seek(SeekFrom::Start(0)).await?; + let file_size = file.metadata().await?.len(); + if wrapped_dts_family_has_native_core_sync_async( + &mut file, + file_size, + &path.display().to_string(), + ) + .await? + { + return Ok(DetectedPathTrackKind::Raw(MuxRawCodec::Dts)); + } + } + if detected != DetectedPathTrackKind::Unknown { + return Ok(detected); + } + Ok(detect_av1_extension_fallback(path).unwrap_or(DetectedPathTrackKind::Unknown)) +} + +fn detect_av1_extension_fallback(path: &Path) -> Option { + let extension = path.extension()?.to_str()?; + if extension.eq_ignore_ascii_case("obu") + || extension.eq_ignore_ascii_case("av1") + || extension.eq_ignore_ascii_case("av1b") + { + return Some(DetectedPathTrackKind::Raw(MuxRawCodec::Av1)); + } + None +} + +fn detect_vobsub_track_kind_sync( + path: &Path, + prefix: &[u8], +) -> Result, MuxError> { + if detect_path_track_kind_from_prefix(prefix) + == DetectedPathTrackKind::Container(DetectedContainerPathKind::VobSub) + { + return Ok(Some(DetectedPathTrackKind::Container( + DetectedContainerPathKind::VobSub, + ))); + } + let Some(extension) = path.extension().and_then(|value| value.to_str()) else { + return Ok(None); + }; + if extension.eq_ignore_ascii_case("sub") { + let idx_path = path.with_extension("idx"); + if idx_path.is_file() && path_starts_with_sync(&idx_path, b"# VobSub")? { + return Ok(Some(DetectedPathTrackKind::Container( + DetectedContainerPathKind::VobSub, + ))); + } + } + Ok(None) +} + +#[cfg(feature = "async")] +async fn detect_vobsub_track_kind_async( + path: &Path, + prefix: &[u8], +) -> Result, MuxError> { + if detect_path_track_kind_from_prefix(prefix) + == DetectedPathTrackKind::Container(DetectedContainerPathKind::VobSub) + { + return Ok(Some(DetectedPathTrackKind::Container( + DetectedContainerPathKind::VobSub, + ))); + } + let Some(extension) = path.extension().and_then(|value| value.to_str()) else { + return Ok(None); + }; + if extension.eq_ignore_ascii_case("sub") { + let idx_path = path.with_extension("idx"); + let idx_exists = tokio::fs::metadata(&idx_path) + .await + .map(|metadata| metadata.is_file()) + .unwrap_or(false); + if idx_exists && path_starts_with_async(&idx_path, b"# VobSub").await? { + return Ok(Some(DetectedPathTrackKind::Container( + DetectedContainerPathKind::VobSub, + ))); + } + } + Ok(None) +} + +fn detect_id3_wrapped_audio_sync( + file: &mut File, + prefix: &[u8], +) -> Result, MuxError> { + let Some(id3_offset) = id3v2_size_from_prefix(prefix) else { + return Ok(None); + }; + if let Some(kind) = detect_id3_wrapped_audio_from_prefix(prefix, id3_offset) { + return Ok(Some(kind)); + } + let mut header = [0_u8; 7]; + file.seek(SeekFrom::Start( + u64::try_from(id3_offset).map_err(|_| MuxError::LayoutOverflow("ID3v2 size"))?, + ))?; + let read = file.read(&mut header)?; + Ok(detect_id3_wrapped_audio_from_prefix(&header[..read], 0)) +} + +#[cfg(feature = "async")] +async fn detect_id3_wrapped_audio_async( + file: &mut TokioFile, + prefix: &[u8], +) -> Result, MuxError> { + let Some(id3_offset) = id3v2_size_from_prefix(prefix) else { + return Ok(None); + }; + if let Some(kind) = detect_id3_wrapped_audio_from_prefix(prefix, id3_offset) { + return Ok(Some(kind)); + } + file.seek(SeekFrom::Start( + u64::try_from(id3_offset).map_err(|_| MuxError::LayoutOverflow("ID3v2 size"))?, + )) + .await?; + let mut header = [0_u8; 7]; + let read = file.read(&mut header).await?; + Ok(detect_id3_wrapped_audio_from_prefix(&header[..read], 0)) +} + +fn path_starts_with_sync(path: &Path, signature: &[u8]) -> Result { + let mut file = + File::open(path).map_err(|error| mux_io_at_path("open mux input", path, error))?; + let mut prefix = vec![0_u8; signature.len()]; + let read = file.read(&mut prefix)?; + Ok(read == signature.len() && prefix == signature) +} + +#[cfg(feature = "async")] +async fn path_starts_with_async(path: &Path, signature: &[u8]) -> Result { + let mut file = TokioFile::open(path) + .await + .map_err(|error| mux_io_at_path("open mux input", path, error))?; + let mut prefix = vec![0_u8; signature.len()]; + let read = file.read(&mut prefix).await?; + Ok(read == signature.len() && prefix == signature) +} + +fn direct_ingest_container_label(kind: DetectedContainerPathKind) -> &'static str { + match kind { + DetectedContainerPathKind::Avi => "avi", + DetectedContainerPathKind::Dash => "dash", + DetectedContainerPathKind::Ghi => "ghi", + DetectedContainerPathKind::Gsf => "gsf", + DetectedContainerPathKind::Nhml => "nhml", + DetectedContainerPathKind::Nhnt => "nhnt", + DetectedContainerPathKind::ProgramStream => "program_stream", + DetectedContainerPathKind::Saf => "saf", + DetectedContainerPathKind::TransportStream => "transport_stream", + DetectedContainerPathKind::VobSub => "vobsub", + } +} + +fn detected_kind_supports_flat_mux(kind: DetectedPathTrackKind) -> bool { + matches!( + kind, + DetectedPathTrackKind::Mp4 + | DetectedPathTrackKind::Raw(_) + | DetectedPathTrackKind::Container(DetectedContainerPathKind::Avi) + | DetectedPathTrackKind::Container(DetectedContainerPathKind::Dash) + | DetectedPathTrackKind::Container(DetectedContainerPathKind::Nhml) + | DetectedPathTrackKind::Container(DetectedContainerPathKind::Nhnt) + | DetectedPathTrackKind::Container(DetectedContainerPathKind::ProgramStream) + | DetectedPathTrackKind::Container(DetectedContainerPathKind::Saf) + | DetectedPathTrackKind::Container(DetectedContainerPathKind::TransportStream) + | DetectedPathTrackKind::Container(DetectedContainerPathKind::VobSub) + ) +} + +fn unsupported_gsf_container_message() -> &'static str { + "GSF is a serialized multi-PID transport surface rather than a local authored-media input on the current path-only mux lane; import the authored files or authored MP4 tracks directly instead" +} + +fn unsupported_ghi_container_message() -> &'static str { + "GHI is a segment-index or manifest transport surface rather than a local authored-media input on the current path-only mux lane; import the authored media files or local MPD inputs directly instead" +} + +fn direct_ingest_report_kind(kind: DetectedPathTrackKind) -> DirectIngestDetectedKind { + match kind { + DetectedPathTrackKind::Mp4 => DirectIngestDetectedKind::Mp4, + DetectedPathTrackKind::Container(container) => DirectIngestDetectedKind::Container { + container: direct_ingest_container_label(container).to_string(), + }, + DetectedPathTrackKind::Raw(codec) => DirectIngestDetectedKind::Raw { + codec: codec.prefix().to_string(), + }, + DetectedPathTrackKind::Mp4ImportOnly(family) => DirectIngestDetectedKind::ImportOnly { + family: family.to_string(), + }, + DetectedPathTrackKind::Unknown => DirectIngestDetectedKind::Unknown, + } +} + +fn direct_ingest_report_note(kind: DetectedPathTrackKind) -> Option { + match kind { + DetectedPathTrackKind::Container(DetectedContainerPathKind::Ghi) => { + Some(unsupported_ghi_container_message().to_string()) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Gsf) => { + Some(unsupported_gsf_container_message().to_string()) + } + DetectedPathTrackKind::Mp4ImportOnly(kind) => Some(format!( + "path-only mux import for `{kind}` is not supported; import this family from an MP4 source with `#audio` or `#track:ID` instead" + )), + DetectedPathTrackKind::Unknown => Some("path-only mux input is not currently recognized as MP4, VobSub, supported AVI audio or MPEG-4 Part 2 video, supported MPEG-PS MPEG audio, AC-3, or MPEG-4 Part 2/H.264/H.265/VVC video, supported MPEG-TS MPEG audio, AAC LATM, MHAS, AC-3, E-AC-3, AC-4, DTS, TrueHD, MPEG-2 video, AV1, MPEG-4 Part 2, H.264, H.265, VVC, DVB subtitle, or DVB teletext video or subtitle carriage, JPEG still images, PNG still images, BMP still images, JPEG 2000 image or codestream input, self-describing YUV4MPEG raw video, raw ProRes, WAVE/AIFF/AIFC PCM, AAC ADTS, AAC LATM, MP3, AC-3, E-AC-3, AC-4, AMR, AMR-WB, QCP voice audio, DTS core audio, Dolby TrueHD, leading-sync MHAS MPEG-H, FLAC, IAMF, H.263 elementary video, MPEG-2 elementary video, MPEG-4 Part 2 elementary video, H.264 Annex B, H.265 Annex B, IVF-backed AV1/VP8/VP9/VP10, Ogg FLAC, Ogg Opus, Ogg Vorbis, Ogg Speex, Ogg Theora, or CAF ALAC".to_string()), + _ => None, + } +} + +fn direct_ingest_sample_entry_type(sample_entry_box: &[u8]) -> String { + if sample_entry_box.len() >= 8 { + String::from_utf8_lossy(&sample_entry_box[4..8]).into_owned() + } else { + "????".to_string() + } +} + +fn lowercase_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut rendered = String::with_capacity(bytes.len() * 2); + for byte in bytes { + rendered.push(HEX[usize::from(byte >> 4)] as char); + rendered.push(HEX[usize::from(byte & 0x0f)] as char); + } + rendered +} + +fn source_segment_to_direct_ingest_report( + segment: &SegmentedMuxSourceSegment, +) -> DirectIngestSourceSegmentReport { + let (kind, source_offset, source_path, data_hex) = match &segment.data { + SegmentedMuxSourceSegmentData::Prefix(prefix) => ( + "prefix".to_string(), + None, + None, + Some(lowercase_hex(prefix)), + ), + SegmentedMuxSourceSegmentData::Bytes(bytes) => { + ("bytes".to_string(), None, None, Some(lowercase_hex(bytes))) + } + SegmentedMuxSourceSegmentData::FileRange { source_offset, .. } => { + ("file_range".to_string(), Some(*source_offset), None, None) + } + SegmentedMuxSourceSegmentData::ExternalFileRange { + path, + source_offset, + .. + } => ( + "file_range".to_string(), + Some(*source_offset), + Some(path.clone()), + None, + ), + }; + DirectIngestSourceSegmentReport { + kind, + logical_offset: segment.logical_offset, + logical_size: segment.logical_size(), + source_offset, + source_path, + data_hex, + } +} + +fn u32_bounds(values: I) -> (Option, Option) +where + I: IntoIterator, +{ + let mut minimum = None::; + let mut maximum = None::; + for value in values { + minimum = Some(minimum.map_or(value, |current| current.min(value))); + maximum = Some(maximum.map_or(value, |current| current.max(value))); + } + (minimum, maximum) +} + +fn u64_bounds(values: I) -> (Option, Option) +where + I: IntoIterator, +{ + let mut minimum = None::; + let mut maximum = None::; + for value in values { + minimum = Some(minimum.map_or(value, |current| current.min(value))); + maximum = Some(maximum.map_or(value, |current| current.max(value))); + } + (minimum, maximum) +} + +fn i32_bounds(values: I) -> (Option, Option) +where + I: IntoIterator, +{ + let mut minimum = None::; + let mut maximum = None::; + for value in values { + minimum = Some(match minimum { + Some(current) => current.min(value), + None => value, + }); + maximum = Some(match maximum { + Some(current) => current.max(value), + None => value, + }); + } + (minimum, maximum) +} + +fn i64_bounds(values: I) -> (Option, Option) +where + I: IntoIterator, +{ + let mut minimum = None::; + let mut maximum = None::; + for value in values { + minimum = Some(minimum.map_or(value, |current| current.min(value))); + maximum = Some(maximum.map_or(value, |current| current.max(value))); + } + (minimum, maximum) +} + +fn report_presentation_time(decode_time: u64, composition_time_offset: i32) -> i64 { + i64::try_from(decode_time) + .unwrap_or(i64::MAX) + .saturating_add(i64::from(composition_time_offset)) +} + +fn report_presentation_end_time( + decode_time: u64, + composition_time_offset: i32, + duration: u32, +) -> i64 { + report_presentation_time(decode_time, composition_time_offset) + .saturating_add(i64::from(duration)) +} + +fn average_bitrate_bits_per_second( + total_payload_size: u64, + total_duration: u64, + timescale: u32, +) -> Option { + if total_duration == 0 || timescale == 0 { + return None; + } + let bits = u128::from(total_payload_size).checked_mul(8)?; + let scaled = bits.checked_mul(u128::from(timescale))?; + u64::try_from(scaled / u128::from(total_duration)).ok() +} + +fn average_size(total_payload_size: u64, count: usize) -> Option { + let count = u64::try_from(count).ok()?; + total_payload_size.checked_div(count) +} + +fn average_non_sync_sample_size(samples: &[DirectIngestSampleReport]) -> Option { + let mut total = 0_u64; + let mut count = 0_u64; + for sample in samples { + if sample.is_sync_sample { + continue; + } + total = total.saturating_add(u64::from(sample.data_size)); + count = count.saturating_add(1); + } + total.checked_div(count) +} + +fn sync_sample_distance_summary( + samples: &[DirectIngestSampleReport], +) -> (Option, Option, Option) { + let mut previous_sync_index = None::; + let mut minimum = None::; + let mut maximum = None::; + let mut total = 0_u64; + let mut count = 0_u64; + for (index, sample) in samples.iter().enumerate() { + if !sample.is_sync_sample { + continue; + } + if let Some(previous_index) = previous_sync_index { + let distance = u32::try_from(index.saturating_sub(previous_index)).unwrap_or(u32::MAX); + minimum = Some(minimum.map_or(distance, |current| current.min(distance))); + maximum = Some(maximum.map_or(distance, |current| current.max(distance))); + total = total.saturating_add(u64::from(distance)); + count = count.saturating_add(1); + } + previous_sync_index = Some(index); + } + let average = total.checked_div(count); + (minimum, maximum, average) +} + +fn sync_sample_size_summary( + samples: &[DirectIngestSampleReport], +) -> (Option, Option, Option) { + let sync_sizes = samples + .iter() + .filter(|sample| sample.is_sync_sample) + .map(|sample| sample.data_size); + let (minimum, maximum) = u32_bounds(sync_sizes.clone()); + let mut total = 0_u64; + let mut count = 0_u64; + for size in sync_sizes { + total = total.saturating_add(u64::from(size)); + count = count.saturating_add(1); + } + let average = total.checked_div(count); + (minimum, maximum, average) +} + +fn sync_sample_decode_delta_summary( + samples: &[DirectIngestSampleReport], +) -> (Option, Option, Option) { + let mut previous_sync_decode_time = None::; + let mut minimum = None::; + let mut maximum = None::; + let mut total = 0_u64; + let mut count = 0_u64; + for sample in samples { + if !sample.is_sync_sample { + continue; + } + if let Some(previous_decode_time) = previous_sync_decode_time { + let delta = sample.decode_time.saturating_sub(previous_decode_time); + minimum = Some(minimum.map_or(delta, |current| current.min(delta))); + maximum = Some(maximum.map_or(delta, |current| current.max(delta))); + total = total.saturating_add(delta); + count = count.saturating_add(1); + } + previous_sync_decode_time = Some(sample.decode_time); + } + let average = total.checked_div(count); + (minimum, maximum, average) +} + +type SyncSampleAnchorSummary = ( + Option, + Option, + Option, + Option, + Option, + Option, +); + +fn sync_sample_anchor_summary(samples: &[DirectIngestSampleReport]) -> SyncSampleAnchorSummary { + let mut first_index = None::; + let mut last_index = None::; + let mut first_decode_time = None::; + let mut last_decode_time = None::; + let mut first_presentation_time = None::; + let mut last_presentation_time = None::; + for (index, sample) in samples.iter().enumerate() { + if !sample.is_sync_sample { + continue; + } + if first_index.is_none() { + first_index = Some(index); + first_decode_time = Some(sample.decode_time); + first_presentation_time = Some(sample.presentation_time); + } + last_index = Some(index); + last_decode_time = Some(sample.decode_time); + last_presentation_time = Some(sample.presentation_time); + } + ( + first_index, + last_index, + first_decode_time, + last_decode_time, + first_presentation_time, + last_presentation_time, + ) +} + +type SyncPacketAnchorSummary = ( + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, +); + +fn sync_packet_anchor_summary(packets: &[DirectIngestPacketEntry]) -> SyncPacketAnchorSummary { + let mut first_track_id = None::; + let mut first_packet_index = None::; + let mut last_track_id = None::; + let mut last_packet_index = None::; + let mut first_decode_time = None::; + let mut last_decode_time = None::; + let mut first_presentation_time = None::; + let mut last_presentation_time = None::; + for packet in packets { + if !packet.is_sync_sample { + continue; + } + if first_track_id.is_none() { + first_track_id = Some(packet.track_id); + first_packet_index = Some(packet.packet_index); + first_decode_time = Some(packet.decode_time); + first_presentation_time = Some(packet.presentation_time); + } + last_track_id = Some(packet.track_id); + last_packet_index = Some(packet.packet_index); + last_decode_time = Some(packet.decode_time); + last_presentation_time = Some(packet.presentation_time); + } + ( + first_track_id, + first_packet_index, + last_track_id, + last_packet_index, + first_decode_time, + last_decode_time, + first_presentation_time, + last_presentation_time, + ) +} + +fn track_candidate_to_direct_ingest_report(track: &TrackCandidate) -> DirectIngestTrackReport { + let mut decode_time = 0_u64; + let mut previous_decode_time = None::; + let mut previous_presentation_time = None::; + let mut previous_presentation_end_time = None::; + let mut previous_duration = None::; + let mut previous_composition_time_offset = None::; + let mut minimum_previous_decode_delta = None::; + let mut maximum_previous_decode_delta = None::; + let mut minimum_previous_presentation_delta = None::; + let mut maximum_previous_presentation_delta = None::; + let mut presentation_gap_count = 0usize; + let mut presentation_overlap_count = 0usize; + let mut presentation_regression_count = 0usize; + let mut duration_change_count = 0usize; + let mut composition_time_offset_change_count = 0usize; + let samples = track + .samples + .iter() + .map(|sample| { + let previous_decode_delta = + previous_decode_time.map(|value| decode_time.saturating_sub(value)); + if let Some(delta) = previous_decode_delta { + minimum_previous_decode_delta = + Some(minimum_previous_decode_delta.map_or(delta, |current| current.min(delta))); + maximum_previous_decode_delta = + Some(maximum_previous_decode_delta.map_or(delta, |current| current.max(delta))); + } + let presentation_time = + report_presentation_time(decode_time, sample.composition_time_offset); + let presentation_end_time = report_presentation_end_time( + decode_time, + sample.composition_time_offset, + sample.duration, + ); + let previous_presentation_delta = + previous_presentation_time.map(|value| presentation_time.saturating_sub(value)); + if let Some(delta) = previous_presentation_delta { + minimum_previous_presentation_delta = Some( + minimum_previous_presentation_delta.map_or(delta, |current| current.min(delta)), + ); + maximum_previous_presentation_delta = Some( + maximum_previous_presentation_delta.map_or(delta, |current| current.max(delta)), + ); + } + if let Some(previous_time) = previous_presentation_time + && presentation_time < previous_time + { + presentation_regression_count += 1; + } + if let Some(previous_end_time) = previous_presentation_end_time { + if presentation_time > previous_end_time { + presentation_gap_count += 1; + } else if presentation_time < previous_end_time { + presentation_overlap_count += 1; + } + } + if let Some(duration) = previous_duration + && sample.duration != duration + { + duration_change_count += 1; + } + if let Some(composition_time_offset) = previous_composition_time_offset + && sample.composition_time_offset != composition_time_offset + { + composition_time_offset_change_count += 1; + } + let report = DirectIngestSampleReport { + source_index: sample.source_index, + data_offset: sample.data_offset, + data_size: sample.data_size, + decode_time, + previous_decode_delta, + composition_time_offset: sample.composition_time_offset, + presentation_time, + presentation_end_time, + previous_presentation_delta, + duration: sample.duration, + is_sync_sample: sample.is_sync_sample, + }; + previous_decode_time = Some(decode_time); + decode_time += u64::from(sample.duration); + previous_presentation_time = Some(presentation_time); + previous_presentation_end_time = Some(presentation_end_time); + previous_duration = Some(sample.duration); + previous_composition_time_offset = Some(sample.composition_time_offset); + report + }) + .collect::>(); + let total_duration = track + .samples + .iter() + .map(|sample| u64::from(sample.duration)) + .sum::(); + let sync_sample_count = track + .samples + .iter() + .filter(|sample| sample.is_sync_sample) + .count(); + let starts_with_sync_sample = track + .samples + .first() + .map(|sample| sample.is_sync_sample) + .unwrap_or(false); + let total_payload_size = track + .samples + .iter() + .map(|sample| u64::from(sample.data_size)) + .sum::(); + let average_sample_size = average_size(total_payload_size, track.samples.len()); + let (minimum_sample_size, maximum_sample_size) = + u32_bounds(track.samples.iter().map(|sample| sample.data_size)); + let (minimum_sample_duration, maximum_sample_duration) = + u32_bounds(track.samples.iter().map(|sample| sample.duration)); + let (minimum_composition_time_offset, maximum_composition_time_offset) = i32_bounds( + track + .samples + .iter() + .map(|sample| sample.composition_time_offset), + ); + let (minimum_presentation_time, maximum_presentation_end_time) = i64_bounds( + samples + .iter() + .flat_map(|sample| [sample.presentation_time, sample.presentation_end_time]), + ); + let average_bitrate_bits_per_second = + average_bitrate_bits_per_second(total_payload_size, total_duration, track.timescale); + let (minimum_sync_sample_size, maximum_sync_sample_size, average_sync_sample_size) = + sync_sample_size_summary(&samples); + let average_non_sync_sample_size = average_non_sync_sample_size(&samples); + let (minimum_sync_sample_distance, maximum_sync_sample_distance, average_sync_sample_distance) = + sync_sample_distance_summary(&samples); + let ( + minimum_sync_sample_decode_delta, + maximum_sync_sample_decode_delta, + average_sync_sample_decode_delta, + ) = sync_sample_decode_delta_summary(&samples); + let ( + first_sync_sample_index, + last_sync_sample_index, + first_sync_decode_time, + last_sync_decode_time, + first_sync_presentation_time, + last_sync_presentation_time, + ) = sync_sample_anchor_summary(&samples); + DirectIngestTrackReport { + track_id: track.track_id, + kind: match track.kind { + MuxTrackKind::Audio => "audio", + MuxTrackKind::Video => "video", + MuxTrackKind::Text => "text", + MuxTrackKind::Subtitle => "subtitle", + } + .to_string(), + timescale: track.timescale, + language: String::from_utf8_lossy(&track.language).into_owned(), + handler_name: track.handler_name.clone(), + sample_entry_type: direct_ingest_sample_entry_type(&track.sample_entry_box), + sample_entry_box_hex: lowercase_hex(&track.sample_entry_box), + width: if track.kind.is_video() || track.kind.is_textual() { + Some(track.width) + } else { + None + }, + height: if track.kind.is_video() || track.kind.is_textual() { + Some(track.height) + } else { + None + }, + source_edit_media_time: track.source_edit_media_time, + sample_roll_distance: track.mux_policy.sample_roll_distance(), + sample_count: track.samples.len(), + sync_sample_count, + starts_with_sync_sample, + total_duration, + total_payload_size, + average_sample_size, + minimum_sample_size, + maximum_sample_size, + minimum_sample_duration, + maximum_sample_duration, + average_bitrate_bits_per_second, + minimum_sync_sample_size, + maximum_sync_sample_size, + average_sync_sample_size, + average_non_sync_sample_size, + minimum_composition_time_offset, + maximum_composition_time_offset, + minimum_presentation_time, + maximum_presentation_end_time, + minimum_previous_decode_delta, + maximum_previous_decode_delta, + minimum_previous_presentation_delta, + maximum_previous_presentation_delta, + presentation_gap_count, + presentation_overlap_count, + presentation_regression_count, + duration_change_count, + composition_time_offset_change_count, + minimum_sync_sample_distance, + maximum_sync_sample_distance, + average_sync_sample_distance, + minimum_sync_sample_decode_delta, + maximum_sync_sample_decode_delta, + average_sync_sample_decode_delta, + first_sync_sample_index, + last_sync_sample_index, + first_sync_decode_time, + last_sync_decode_time, + first_sync_presentation_time, + last_sync_presentation_time, + first_decode_time: 0, + end_decode_time: total_duration, + samples, + } +} + +fn imported_track_to_direct_ingest_report(track: &ImportedTrack) -> DirectIngestTrackReport { + let mut decode_time = 0_u64; + let mut previous_decode_time = None::; + let mut previous_presentation_time = None::; + let mut previous_presentation_end_time = None::; + let mut previous_duration = None::; + let mut previous_composition_time_offset = None::; + let mut minimum_previous_decode_delta = None::; + let mut maximum_previous_decode_delta = None::; + let mut minimum_previous_presentation_delta = None::; + let mut maximum_previous_presentation_delta = None::; + let mut presentation_gap_count = 0usize; + let mut presentation_overlap_count = 0usize; + let mut presentation_regression_count = 0usize; + let mut duration_change_count = 0usize; + let mut composition_time_offset_change_count = 0usize; + let samples = track + .samples + .iter() + .map(|sample| { + let previous_decode_delta = + previous_decode_time.map(|value| decode_time.saturating_sub(value)); + if let Some(delta) = previous_decode_delta { + minimum_previous_decode_delta = + Some(minimum_previous_decode_delta.map_or(delta, |current| current.min(delta))); + maximum_previous_decode_delta = + Some(maximum_previous_decode_delta.map_or(delta, |current| current.max(delta))); + } + let presentation_time = + report_presentation_time(decode_time, sample.composition_time_offset); + let presentation_end_time = report_presentation_end_time( + decode_time, + sample.composition_time_offset, + sample.duration, + ); + let previous_presentation_delta = + previous_presentation_time.map(|value| presentation_time.saturating_sub(value)); + if let Some(delta) = previous_presentation_delta { + minimum_previous_presentation_delta = Some( + minimum_previous_presentation_delta.map_or(delta, |current| current.min(delta)), + ); + maximum_previous_presentation_delta = Some( + maximum_previous_presentation_delta.map_or(delta, |current| current.max(delta)), + ); + } + if let Some(previous_time) = previous_presentation_time + && presentation_time < previous_time + { + presentation_regression_count += 1; + } + if let Some(previous_end_time) = previous_presentation_end_time { + if presentation_time > previous_end_time { + presentation_gap_count += 1; + } else if presentation_time < previous_end_time { + presentation_overlap_count += 1; + } + } + if let Some(duration) = previous_duration + && sample.duration != duration + { + duration_change_count += 1; + } + if let Some(composition_time_offset) = previous_composition_time_offset + && sample.composition_time_offset != composition_time_offset + { + composition_time_offset_change_count += 1; + } + let report = DirectIngestSampleReport { + source_index: sample.source_index, + data_offset: sample.data_offset, + data_size: sample.data_size, + decode_time, + previous_decode_delta, + composition_time_offset: sample.composition_time_offset, + presentation_time, + presentation_end_time, + previous_presentation_delta, + duration: sample.duration, + is_sync_sample: sample.is_sync_sample, + }; + previous_decode_time = Some(decode_time); + decode_time += u64::from(sample.duration); + previous_presentation_time = Some(presentation_time); + previous_presentation_end_time = Some(presentation_end_time); + previous_duration = Some(sample.duration); + previous_composition_time_offset = Some(sample.composition_time_offset); + report + }) + .collect::>(); + let total_duration = track + .samples + .iter() + .map(|sample| u64::from(sample.duration)) + .sum::(); + let sync_sample_count = track + .samples + .iter() + .filter(|sample| sample.is_sync_sample) + .count(); + let starts_with_sync_sample = track + .samples + .first() + .map(|sample| sample.is_sync_sample) + .unwrap_or(false); + let total_payload_size = track + .samples + .iter() + .map(|sample| u64::from(sample.data_size)) + .sum::(); + let average_sample_size = average_size(total_payload_size, track.samples.len()); + let (minimum_sample_size, maximum_sample_size) = + u32_bounds(track.samples.iter().map(|sample| sample.data_size)); + let (minimum_sample_duration, maximum_sample_duration) = + u32_bounds(track.samples.iter().map(|sample| sample.duration)); + let (minimum_composition_time_offset, maximum_composition_time_offset) = i32_bounds( + track + .samples + .iter() + .map(|sample| sample.composition_time_offset), + ); + let (minimum_presentation_time, maximum_presentation_end_time) = i64_bounds( + samples + .iter() + .flat_map(|sample| [sample.presentation_time, sample.presentation_end_time]), + ); + let average_bitrate_bits_per_second = + average_bitrate_bits_per_second(total_payload_size, total_duration, track.timescale); + let (minimum_sync_sample_size, maximum_sync_sample_size, average_sync_sample_size) = + sync_sample_size_summary(&samples); + let average_non_sync_sample_size = average_non_sync_sample_size(&samples); + let (minimum_sync_sample_distance, maximum_sync_sample_distance, average_sync_sample_distance) = + sync_sample_distance_summary(&samples); + let ( + minimum_sync_sample_decode_delta, + maximum_sync_sample_decode_delta, + average_sync_sample_decode_delta, + ) = sync_sample_decode_delta_summary(&samples); + let ( + first_sync_sample_index, + last_sync_sample_index, + first_sync_decode_time, + last_sync_decode_time, + first_sync_presentation_time, + last_sync_presentation_time, + ) = sync_sample_anchor_summary(&samples); + DirectIngestTrackReport { + track_id: 1, + kind: match track.kind { + MuxTrackKind::Audio => "audio", + MuxTrackKind::Video => "video", + MuxTrackKind::Text => "text", + MuxTrackKind::Subtitle => "subtitle", + } + .to_string(), + timescale: track.timescale, + language: String::from_utf8_lossy(&track.language).into_owned(), + handler_name: track.handler_name.clone(), + sample_entry_type: direct_ingest_sample_entry_type(&track.sample_entry_box), + sample_entry_box_hex: lowercase_hex(&track.sample_entry_box), + width: if track.kind.is_video() || track.kind.is_textual() { + Some(track.width) + } else { + None + }, + height: if track.kind.is_video() || track.kind.is_textual() { + Some(track.height) + } else { + None + }, + source_edit_media_time: track.source_edit_media_time, + sample_roll_distance: track.sample_roll_distance, + sample_count: track.samples.len(), + sync_sample_count, + starts_with_sync_sample, + total_duration, + total_payload_size, + average_sample_size, + minimum_sample_size, + maximum_sample_size, + minimum_sample_duration, + maximum_sample_duration, + average_bitrate_bits_per_second, + minimum_sync_sample_size, + maximum_sync_sample_size, + average_sync_sample_size, + average_non_sync_sample_size, + minimum_composition_time_offset, + maximum_composition_time_offset, + minimum_presentation_time, + maximum_presentation_end_time, + minimum_previous_decode_delta, + maximum_previous_decode_delta, + minimum_previous_presentation_delta, + maximum_previous_presentation_delta, + presentation_gap_count, + presentation_overlap_count, + presentation_regression_count, + duration_change_count, + composition_time_offset_change_count, + minimum_sync_sample_distance, + maximum_sync_sample_distance, + average_sync_sample_distance, + minimum_sync_sample_decode_delta, + maximum_sync_sample_decode_delta, + average_sync_sample_decode_delta, + first_sync_sample_index, + last_sync_sample_index, + first_sync_decode_time, + last_sync_decode_time, + first_sync_presentation_time, + last_sync_presentation_time, + first_decode_time: 0, + end_decode_time: total_duration, + samples, + } +} + +fn source_catalog_to_direct_ingest_reports( + sources: &SourceCatalog, +) -> Vec { + sources + .specs + .iter() + .enumerate() + .map(|(source_index, spec)| match spec { + SourceSpec::File(path) => DirectIngestStagedSourceReport { + source_index, + path: path.clone(), + segmented: false, + total_size: std::fs::metadata(path) + .map(|metadata| metadata.len()) + .unwrap_or(0), + segment_count: None, + segments: None, + }, + SourceSpec::Segmented(spec) => DirectIngestStagedSourceReport { + source_index, + path: spec.path.clone(), + segmented: true, + total_size: spec.total_size, + segment_count: Some(spec.segments.len()), + segments: Some( + spec.segments + .iter() + .map(source_segment_to_direct_ingest_report) + .collect(), + ), + }, + }) + .collect() +} + +#[cfg(feature = "async")] +async fn source_catalog_to_direct_ingest_reports_async( + sources: &SourceCatalog, +) -> Vec { + let mut reports = Vec::with_capacity(sources.specs.len()); + for (source_index, spec) in sources.specs.iter().enumerate() { + reports.push(match spec { + SourceSpec::File(path) => DirectIngestStagedSourceReport { + source_index, + path: path.clone(), + segmented: false, + total_size: tokio::fs::metadata(path) + .await + .map(|metadata| metadata.len()) + .unwrap_or(0), + segment_count: None, + segments: None, + }, + SourceSpec::Segmented(spec) => DirectIngestStagedSourceReport { + source_index, + path: spec.path.clone(), + segmented: true, + total_size: spec.total_size, + segment_count: Some(spec.segments.len()), + segments: Some( + spec.segments + .iter() + .map(source_segment_to_direct_ingest_report) + .collect(), + ), + }, + }); + } + reports +} + +struct DirectIngestInspectionState { + report: DirectIngestReport, + sources: SourceCatalog, +} + +pub(in crate::mux) fn inspect_direct_ingest_path_sync( + path: &Path, +) -> Result { + Ok(inspect_direct_ingest_state_sync(path)?.report) +} + +pub(in crate::mux) fn inspect_direct_ingest_packets_sync( + path: &Path, +) -> Result { + direct_ingest_packet_report_sync(inspect_direct_ingest_state_sync(path)?) +} + +fn inspect_direct_ingest_state_sync(path: &Path) -> Result { + let absolute = absolute_path(path)?; + let detected_kind = detect_path_track_kind_sync(&absolute)?; + let mut report = DirectIngestReport { + input_path: absolute.clone(), + detected_kind: direct_ingest_report_kind(detected_kind), + supports_flat_mux: detected_kind_supports_flat_mux(detected_kind), + note: direct_ingest_report_note(detected_kind), + track_count: 0, + total_sample_count: 0, + total_sync_sample_count: 0, + total_payload_size: 0, + staged_sources: Vec::new(), + tracks: Vec::new(), + }; + let mut sources = SourceCatalog::default(); + match detected_kind { + DetectedPathTrackKind::Mp4 => { + let mut cache = BTreeMap::new(); + let source = load_mp4_source_sync(&absolute, &mut cache, &mut sources)?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Avi) => { + let mut cache = BTreeMap::new(); + let source = load_avi_source_sync(&absolute, &mut cache, &mut sources)?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Dash) => { + let mut cache = BTreeMap::new(); + let source = load_dash_source_sync(&absolute, &mut cache, &mut sources)?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Ghi) + | DetectedPathTrackKind::Container(DetectedContainerPathKind::Gsf) => {} + DetectedPathTrackKind::Container(DetectedContainerPathKind::Nhml) => { + let mut cache = BTreeMap::new(); + let source = load_nhml_source_sync( + &absolute, + DetectedNhmlSidecarKind::Nhml, + &mut cache, + &mut sources, + )?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Nhnt) => { + let mut cache = BTreeMap::new(); + let source = load_nhml_source_sync( + &absolute, + DetectedNhmlSidecarKind::Nhnt, + &mut cache, + &mut sources, + )?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::ProgramStream) => { + let mut cache = BTreeMap::new(); + let source = load_program_stream_source_sync(&absolute, &mut cache, &mut sources)?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Saf) => { + let mut cache = BTreeMap::new(); + let source = load_saf_source_sync(&absolute, &mut cache, &mut sources)?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::TransportStream) => { + let mut cache = BTreeMap::new(); + let source = load_transport_stream_source_sync(&absolute, &mut cache, &mut sources)?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::VobSub) => { + let mut cache = BTreeMap::new(); + let source = load_vobsub_source_sync(&absolute, &mut cache, &mut sources)?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Raw(codec) => { + let imported = import_detected_raw_codec_sync( + &absolute, + codec, + &absolute.display().to_string(), + &mut sources, + )?; + report + .tracks + .push(imported_track_to_direct_ingest_report(&imported)); + } + DetectedPathTrackKind::Mp4ImportOnly(_) | DetectedPathTrackKind::Unknown => {} + } + report.track_count = report.tracks.len(); + report.total_sample_count = report.tracks.iter().map(|track| track.sample_count).sum(); + report.total_sync_sample_count = report + .tracks + .iter() + .map(|track| track.sync_sample_count) + .sum(); + report.total_payload_size = report + .tracks + .iter() + .map(|track| track.total_payload_size) + .sum(); + report.staged_sources = source_catalog_to_direct_ingest_reports(&sources); + Ok(DirectIngestInspectionState { report, sources }) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn inspect_direct_ingest_path_async( + path: &Path, +) -> Result { + Ok(inspect_direct_ingest_state_async(path).await?.report) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn inspect_direct_ingest_packets_async( + path: &Path, +) -> Result { + direct_ingest_packet_report_async(inspect_direct_ingest_state_async(path).await?).await +} + +#[cfg(feature = "async")] +async fn inspect_direct_ingest_state_async( + path: &Path, +) -> Result { + let absolute = absolute_path(path)?; + let detected_kind = detect_path_track_kind_async(&absolute).await?; + let mut report = DirectIngestReport { + input_path: absolute.clone(), + detected_kind: direct_ingest_report_kind(detected_kind), + supports_flat_mux: detected_kind_supports_flat_mux(detected_kind), + note: direct_ingest_report_note(detected_kind), + track_count: 0, + total_sample_count: 0, + total_sync_sample_count: 0, + total_payload_size: 0, + staged_sources: Vec::new(), + tracks: Vec::new(), + }; + let mut sources = SourceCatalog::default(); + match detected_kind { + DetectedPathTrackKind::Mp4 => { + let mut cache = BTreeMap::new(); + let source = load_mp4_source_async(&absolute, &mut cache, &mut sources).await?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Avi) => { + let mut cache = BTreeMap::new(); + let source = load_avi_source_async(&absolute, &mut cache, &mut sources).await?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Dash) => { + let mut cache = BTreeMap::new(); + let source = load_dash_source_async(&absolute, &mut cache, &mut sources).await?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Ghi) + | DetectedPathTrackKind::Container(DetectedContainerPathKind::Gsf) => {} + DetectedPathTrackKind::Container(DetectedContainerPathKind::Nhml) => { + let mut cache = BTreeMap::new(); + let source = load_nhml_source_async( + &absolute, + DetectedNhmlSidecarKind::Nhml, + &mut cache, + &mut sources, + ) + .await?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Nhnt) => { + let mut cache = BTreeMap::new(); + let source = load_nhml_source_async( + &absolute, + DetectedNhmlSidecarKind::Nhnt, + &mut cache, + &mut sources, + ) + .await?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::ProgramStream) => { + let mut cache = BTreeMap::new(); + let source = + load_program_stream_source_async(&absolute, &mut cache, &mut sources).await?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Saf) => { + let mut cache = BTreeMap::new(); + let source = load_saf_source_async(&absolute, &mut cache, &mut sources).await?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::TransportStream) => { + let mut cache = BTreeMap::new(); + let source = + load_transport_stream_source_async(&absolute, &mut cache, &mut sources).await?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::VobSub) => { + let mut cache = BTreeMap::new(); + let source = load_vobsub_source_async(&absolute, &mut cache, &mut sources).await?; + report.tracks = source + .tracks + .iter() + .map(track_candidate_to_direct_ingest_report) + .collect(); + } + DetectedPathTrackKind::Raw(codec) => { + let imported = import_detected_raw_codec_async( + &absolute, + codec, + &absolute.display().to_string(), + &mut sources, + ) + .await?; + report + .tracks + .push(imported_track_to_direct_ingest_report(&imported)); + } + DetectedPathTrackKind::Mp4ImportOnly(_) | DetectedPathTrackKind::Unknown => {} + } + report.track_count = report.tracks.len(); + report.total_sample_count = report.tracks.iter().map(|track| track.sample_count).sum(); + report.total_sync_sample_count = report + .tracks + .iter() + .map(|track| track.sync_sample_count) + .sum(); + report.total_payload_size = report + .tracks + .iter() + .map(|track| track.total_payload_size) + .sum(); + report.staged_sources = source_catalog_to_direct_ingest_reports_async(&sources).await; + Ok(DirectIngestInspectionState { report, sources }) +} + +fn direct_ingest_packet_report_sync( + state: DirectIngestInspectionState, +) -> Result { + let DirectIngestInspectionState { report, sources } = state; + let mut source_readers = sources + .specs + .iter() + .map(SyncMuxSource::open) + .collect::, _>>()?; + let mut packets = Vec::new(); + let mut minimum_sync_packet_distance = None::; + let mut maximum_sync_packet_distance = None::; + for track in &report.tracks { + let mut previous_decode_time = None::; + let mut previous_presentation_time = None::; + let ( + track_minimum_sync_packet_distance, + track_maximum_sync_packet_distance, + _track_average_sync_packet_distance, + ) = sync_sample_distance_summary(&track.samples); + if let Some(distance) = track_minimum_sync_packet_distance { + minimum_sync_packet_distance = Some( + minimum_sync_packet_distance.map_or(distance, |current| current.min(distance)), + ); + } + if let Some(distance) = track_maximum_sync_packet_distance { + maximum_sync_packet_distance = Some( + maximum_sync_packet_distance.map_or(distance, |current| current.max(distance)), + ); + } + for (packet_index, sample) in track.samples.iter().enumerate() { + let payload_crc32 = crc32_from_sync_source( + &mut source_readers[sample.source_index], + sample.data_offset, + sample.data_size, + )?; + let previous_presentation_delta = previous_presentation_time + .map(|value| sample.presentation_time.saturating_sub(value)); + packets.push(DirectIngestPacketEntry { + track_id: track.track_id, + packet_index, + track_kind: track.kind.clone(), + timescale: track.timescale, + sample_entry_type: track.sample_entry_type.clone(), + source_index: sample.source_index, + data_offset: sample.data_offset, + data_size: sample.data_size, + decode_time: sample.decode_time, + composition_time_offset: sample.composition_time_offset, + presentation_time: sample.presentation_time, + presentation_end_time: sample.presentation_end_time, + previous_presentation_delta, + duration: sample.duration, + previous_decode_delta: previous_decode_time + .map(|value| sample.decode_time.saturating_sub(value)), + payload_crc32, + is_sync_sample: sample.is_sync_sample, + }); + previous_decode_time = Some(sample.decode_time); + previous_presentation_time = Some(sample.presentation_time); + } + } + let sync_packet_count = packets + .iter() + .filter(|packet| packet.is_sync_sample) + .count(); + let starts_with_sync_packet = packets + .first() + .map(|packet| packet.is_sync_sample) + .unwrap_or(false); + let total_payload_size = packets + .iter() + .map(|packet| u64::from(packet.data_size)) + .sum::(); + let (minimum_packet_size, maximum_packet_size) = + u32_bounds(packets.iter().map(|packet| packet.data_size)); + let average_non_sync_packet_size = { + let mut total = 0_u64; + let mut count = 0_u64; + for packet in &packets { + if packet.is_sync_sample { + continue; + } + total = total.saturating_add(u64::from(packet.data_size)); + count = count.saturating_add(1); + } + total.checked_div(count) + }; + let (minimum_sync_packet_size, maximum_sync_packet_size, average_sync_packet_size) = { + let sync_sizes = packets + .iter() + .filter(|packet| packet.is_sync_sample) + .map(|packet| packet.data_size); + let (minimum, maximum) = u32_bounds(sync_sizes.clone()); + let mut total = 0_u64; + let mut count = 0_u64; + for size in sync_sizes { + total = total.saturating_add(u64::from(size)); + count = count.saturating_add(1); + } + let average = total.checked_div(count); + (minimum, maximum, average) + }; + let (minimum_packet_duration, maximum_packet_duration) = + u32_bounds(packets.iter().map(|packet| packet.duration)); + let (minimum_previous_decode_delta, maximum_previous_decode_delta) = u64_bounds( + packets + .iter() + .filter_map(|packet| packet.previous_decode_delta), + ); + let (minimum_composition_time_offset, maximum_composition_time_offset) = + i32_bounds(packets.iter().map(|packet| packet.composition_time_offset)); + let (minimum_presentation_time, maximum_presentation_end_time) = i64_bounds( + packets + .iter() + .flat_map(|packet| [packet.presentation_time, packet.presentation_end_time]), + ); + let (minimum_previous_presentation_delta, maximum_previous_presentation_delta) = i64_bounds( + packets + .iter() + .filter_map(|packet| packet.previous_presentation_delta), + ); + let mut presentation_gap_count = 0usize; + let mut presentation_overlap_count = 0usize; + let mut presentation_regression_count = 0usize; + let mut duration_change_count = 0usize; + let mut composition_time_offset_change_count = 0usize; + for track in &report.tracks { + for window in track.samples.windows(2) { + let previous = &window[0]; + let current = &window[1]; + if current.presentation_time < previous.presentation_time { + presentation_regression_count += 1; + } + if current.presentation_time > previous.presentation_end_time { + presentation_gap_count += 1; + } else if current.presentation_time < previous.presentation_end_time { + presentation_overlap_count += 1; + } + if current.duration != previous.duration { + duration_change_count += 1; + } + if current.composition_time_offset != previous.composition_time_offset { + composition_time_offset_change_count += 1; + } + } + } + let ( + minimum_sync_packet_decode_delta, + maximum_sync_packet_decode_delta, + average_sync_packet_decode_delta, + ) = { + let mut previous_sync_decode_time = None::; + let mut minimum = None::; + let mut maximum = None::; + let mut total = 0_u64; + let mut count = 0_u64; + for packet in &packets { + if !packet.is_sync_sample { + continue; + } + if let Some(previous_decode_time) = previous_sync_decode_time { + let delta = packet.decode_time.saturating_sub(previous_decode_time); + minimum = Some(minimum.map_or(delta, |current| current.min(delta))); + maximum = Some(maximum.map_or(delta, |current| current.max(delta))); + total = total.saturating_add(delta); + count = count.saturating_add(1); + } + previous_sync_decode_time = Some(packet.decode_time); + } + let average = total.checked_div(count); + (minimum, maximum, average) + }; + let average_sync_packet_distance = { + let mut previous_sync_index = None::; + let mut total = 0_u64; + let mut count = 0_u64; + for (index, packet) in packets.iter().enumerate() { + if !packet.is_sync_sample { + continue; + } + if let Some(previous_index) = previous_sync_index { + let distance = + u64::try_from(index.saturating_sub(previous_index)).unwrap_or(u64::MAX); + total = total.saturating_add(distance); + count = count.saturating_add(1); + } + previous_sync_index = Some(index); + } + total.checked_div(count) + }; + let ( + first_sync_packet_track_id, + first_sync_packet_index, + last_sync_packet_track_id, + last_sync_packet_index, + first_sync_decode_time, + last_sync_decode_time, + first_sync_presentation_time, + last_sync_presentation_time, + ) = sync_packet_anchor_summary(&packets); + Ok(DirectIngestPacketReport { + input_path: report.input_path, + detected_kind: report.detected_kind, + supports_flat_mux: report.supports_flat_mux, + note: report.note, + track_count: report.track_count, + packet_count: packets.len(), + sync_packet_count, + starts_with_sync_packet, + total_payload_size, + minimum_packet_size, + maximum_packet_size, + minimum_sync_packet_size, + maximum_sync_packet_size, + average_sync_packet_size, + average_non_sync_packet_size, + minimum_packet_duration, + maximum_packet_duration, + minimum_previous_decode_delta, + maximum_previous_decode_delta, + minimum_composition_time_offset, + maximum_composition_time_offset, + minimum_presentation_time, + maximum_presentation_end_time, + minimum_previous_presentation_delta, + maximum_previous_presentation_delta, + presentation_gap_count, + presentation_overlap_count, + presentation_regression_count, + duration_change_count, + composition_time_offset_change_count, + minimum_sync_packet_distance, + maximum_sync_packet_distance, + average_sync_packet_distance, + minimum_sync_packet_decode_delta, + maximum_sync_packet_decode_delta, + average_sync_packet_decode_delta, + first_sync_packet_track_id, + first_sync_packet_index, + last_sync_packet_track_id, + last_sync_packet_index, + first_sync_decode_time, + last_sync_decode_time, + first_sync_presentation_time, + last_sync_presentation_time, + tracks: report.tracks, + staged_sources: report.staged_sources, + packets, + }) +} + +#[cfg(feature = "async")] +async fn direct_ingest_packet_report_async( + state: DirectIngestInspectionState, +) -> Result { + let DirectIngestInspectionState { report, sources } = state; + let mut source_readers = Vec::with_capacity(sources.specs.len()); + for spec in &sources.specs { + source_readers.push(AsyncMuxSource::open(spec).await?); + } + let mut packets = Vec::new(); + let mut minimum_sync_packet_distance = None::; + let mut maximum_sync_packet_distance = None::; + for track in &report.tracks { + let mut previous_decode_time = None::; + let mut previous_presentation_time = None::; + let ( + track_minimum_sync_packet_distance, + track_maximum_sync_packet_distance, + _track_average_sync_packet_distance, + ) = sync_sample_distance_summary(&track.samples); + if let Some(distance) = track_minimum_sync_packet_distance { + minimum_sync_packet_distance = Some( + minimum_sync_packet_distance.map_or(distance, |current| current.min(distance)), + ); + } + if let Some(distance) = track_maximum_sync_packet_distance { + maximum_sync_packet_distance = Some( + maximum_sync_packet_distance.map_or(distance, |current| current.max(distance)), + ); + } + for (packet_index, sample) in track.samples.iter().enumerate() { + let payload_crc32 = crc32_from_async_source( + &mut source_readers[sample.source_index], + sample.data_offset, + sample.data_size, + ) + .await?; + let previous_presentation_delta = previous_presentation_time + .map(|value| sample.presentation_time.saturating_sub(value)); + packets.push(DirectIngestPacketEntry { + track_id: track.track_id, + packet_index, + track_kind: track.kind.clone(), + timescale: track.timescale, + sample_entry_type: track.sample_entry_type.clone(), + source_index: sample.source_index, + data_offset: sample.data_offset, + data_size: sample.data_size, + decode_time: sample.decode_time, + composition_time_offset: sample.composition_time_offset, + presentation_time: sample.presentation_time, + presentation_end_time: sample.presentation_end_time, + previous_presentation_delta, + duration: sample.duration, + previous_decode_delta: previous_decode_time + .map(|value| sample.decode_time.saturating_sub(value)), + payload_crc32, + is_sync_sample: sample.is_sync_sample, + }); + previous_decode_time = Some(sample.decode_time); + previous_presentation_time = Some(sample.presentation_time); + } + } + let sync_packet_count = packets + .iter() + .filter(|packet| packet.is_sync_sample) + .count(); + let starts_with_sync_packet = packets + .first() + .map(|packet| packet.is_sync_sample) + .unwrap_or(false); + let total_payload_size = packets + .iter() + .map(|packet| u64::from(packet.data_size)) + .sum::(); + let (minimum_packet_size, maximum_packet_size) = + u32_bounds(packets.iter().map(|packet| packet.data_size)); + let average_non_sync_packet_size = { + let mut total = 0_u64; + let mut count = 0_u64; + for packet in &packets { + if packet.is_sync_sample { + continue; + } + total = total.saturating_add(u64::from(packet.data_size)); + count = count.saturating_add(1); + } + total.checked_div(count) + }; + let (minimum_sync_packet_size, maximum_sync_packet_size, average_sync_packet_size) = { + let sync_sizes = packets + .iter() + .filter(|packet| packet.is_sync_sample) + .map(|packet| packet.data_size); + let (minimum, maximum) = u32_bounds(sync_sizes.clone()); + let mut total = 0_u64; + let mut count = 0_u64; + for size in sync_sizes { + total = total.saturating_add(u64::from(size)); + count = count.saturating_add(1); + } + let average = total.checked_div(count); + (minimum, maximum, average) + }; + let (minimum_packet_duration, maximum_packet_duration) = + u32_bounds(packets.iter().map(|packet| packet.duration)); + let (minimum_previous_decode_delta, maximum_previous_decode_delta) = u64_bounds( + packets + .iter() + .filter_map(|packet| packet.previous_decode_delta), + ); + let (minimum_composition_time_offset, maximum_composition_time_offset) = + i32_bounds(packets.iter().map(|packet| packet.composition_time_offset)); + let (minimum_presentation_time, maximum_presentation_end_time) = i64_bounds( + packets + .iter() + .flat_map(|packet| [packet.presentation_time, packet.presentation_end_time]), + ); + let (minimum_previous_presentation_delta, maximum_previous_presentation_delta) = i64_bounds( + packets + .iter() + .filter_map(|packet| packet.previous_presentation_delta), + ); + let mut presentation_gap_count = 0usize; + let mut presentation_overlap_count = 0usize; + let mut presentation_regression_count = 0usize; + let mut duration_change_count = 0usize; + let mut composition_time_offset_change_count = 0usize; + for track in &report.tracks { + for window in track.samples.windows(2) { + let previous = &window[0]; + let current = &window[1]; + if current.presentation_time < previous.presentation_time { + presentation_regression_count += 1; + } + if current.presentation_time > previous.presentation_end_time { + presentation_gap_count += 1; + } else if current.presentation_time < previous.presentation_end_time { + presentation_overlap_count += 1; + } + if current.duration != previous.duration { + duration_change_count += 1; + } + if current.composition_time_offset != previous.composition_time_offset { + composition_time_offset_change_count += 1; + } + } + } + let ( + minimum_sync_packet_decode_delta, + maximum_sync_packet_decode_delta, + average_sync_packet_decode_delta, + ) = { + let mut previous_sync_decode_time = None::; + let mut minimum = None::; + let mut maximum = None::; + let mut total = 0_u64; + let mut count = 0_u64; + for packet in &packets { + if !packet.is_sync_sample { + continue; + } + if let Some(previous_decode_time) = previous_sync_decode_time { + let delta = packet.decode_time.saturating_sub(previous_decode_time); + minimum = Some(minimum.map_or(delta, |current| current.min(delta))); + maximum = Some(maximum.map_or(delta, |current| current.max(delta))); + total = total.saturating_add(delta); + count = count.saturating_add(1); + } + previous_sync_decode_time = Some(packet.decode_time); + } + let average = total.checked_div(count); + (minimum, maximum, average) + }; + let average_sync_packet_distance = { + let mut previous_sync_index = None::; + let mut total = 0_u64; + let mut count = 0_u64; + for (index, packet) in packets.iter().enumerate() { + if !packet.is_sync_sample { + continue; + } + if let Some(previous_index) = previous_sync_index { + let distance = + u64::try_from(index.saturating_sub(previous_index)).unwrap_or(u64::MAX); + total = total.saturating_add(distance); + count = count.saturating_add(1); + } + previous_sync_index = Some(index); + } + total.checked_div(count) + }; + let ( + first_sync_packet_track_id, + first_sync_packet_index, + last_sync_packet_track_id, + last_sync_packet_index, + first_sync_decode_time, + last_sync_decode_time, + first_sync_presentation_time, + last_sync_presentation_time, + ) = sync_packet_anchor_summary(&packets); + Ok(DirectIngestPacketReport { + input_path: report.input_path, + detected_kind: report.detected_kind, + supports_flat_mux: report.supports_flat_mux, + note: report.note, + track_count: report.track_count, + packet_count: packets.len(), + sync_packet_count, + starts_with_sync_packet, + total_payload_size, + minimum_packet_size, + maximum_packet_size, + minimum_sync_packet_size, + maximum_sync_packet_size, + average_sync_packet_size, + average_non_sync_packet_size, + minimum_packet_duration, + maximum_packet_duration, + minimum_previous_decode_delta, + maximum_previous_decode_delta, + minimum_composition_time_offset, + maximum_composition_time_offset, + minimum_presentation_time, + maximum_presentation_end_time, + minimum_previous_presentation_delta, + maximum_previous_presentation_delta, + presentation_gap_count, + presentation_overlap_count, + presentation_regression_count, + duration_change_count, + composition_time_offset_change_count, + minimum_sync_packet_distance, + maximum_sync_packet_distance, + average_sync_packet_distance, + minimum_sync_packet_decode_delta, + maximum_sync_packet_decode_delta, + average_sync_packet_decode_delta, + first_sync_packet_track_id, + first_sync_packet_index, + last_sync_packet_track_id, + last_sync_packet_index, + first_sync_decode_time, + last_sync_decode_time, + first_sync_presentation_time, + last_sync_presentation_time, + tracks: report.tracks, + staged_sources: report.staged_sources, + packets, + }) +} + +fn crc32_from_sync_source( + source: &mut SyncMuxSource, + offset: u64, + size: u32, +) -> Result { + source.seek(SeekFrom::Start(offset))?; + let mut remaining = + usize::try_from(size).map_err(|_| MuxError::LayoutOverflow("packet size"))?; + let mut buffer = [0_u8; 8192]; + let mut crc = 0xFFFF_FFFF_u32; + while remaining != 0 { + let to_read = remaining.min(buffer.len()); + source.read_exact(&mut buffer[..to_read])?; + crc = update_crc32(crc, &buffer[..to_read]); + remaining -= to_read; + } + Ok(!crc) +} + +#[cfg(feature = "async")] +async fn crc32_from_async_source( + source: &mut AsyncMuxSource, + offset: u64, + size: u32, +) -> Result { + source.seek(SeekFrom::Start(offset)).await?; + let mut remaining = + usize::try_from(size).map_err(|_| MuxError::LayoutOverflow("packet size"))?; + let mut buffer = [0_u8; 8192]; + let mut crc = 0xFFFF_FFFF_u32; + while remaining != 0 { + let to_read = remaining.min(buffer.len()); + source.read_exact(&mut buffer[..to_read]).await?; + crc = update_crc32(crc, &buffer[..to_read]); + remaining -= to_read; + } + Ok(!crc) +} + +fn update_crc32(mut crc: u32, bytes: &[u8]) -> u32 { + for byte in bytes { + crc ^= u32::from(*byte); + for _ in 0..8 { + crc = if crc & 1 != 0 { + (crc >> 1) ^ 0xEDB8_8320 + } else { + crc >> 1 + }; + } + } + crc +} + +fn import_detected_path_raw_sync( + path: &Path, + spec: &str, + sources: &mut SourceCatalog, +) -> Result { + match detect_path_track_kind_sync(path)? { + DetectedPathTrackKind::Raw(codec) => import_detected_raw_codec_sync(path, codec, spec, sources), + DetectedPathTrackKind::Container(DetectedContainerPathKind::Avi) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected an AVI container on the raw-import path unexpectedly".to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Dash) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected a DASH manifest on the raw-import path unexpectedly" + .to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Ghi) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected a GHI source on the raw-import path unexpectedly".to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Gsf) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected a GSF source on the raw-import path unexpectedly".to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Nhml) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected an NHML sidecar on the raw-import path unexpectedly" + .to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Nhnt) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected an NHNT sidecar on the raw-import path unexpectedly" + .to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::ProgramStream) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "detected an MPEG program stream on the raw-import path unexpectedly" + .to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Saf) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected a SAF source on the raw-import path unexpectedly".to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::TransportStream) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "detected an MPEG transport stream on the raw-import path unexpectedly" + .to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::VobSub) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected a VobSub source on the raw-import path unexpectedly" + .to_string(), + }) + } + DetectedPathTrackKind::Mp4ImportOnly(kind) => Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "path-only mux import for `{kind}` is not supported; import this family from an MP4 source with `#audio` or `#track:ID` instead" + ), + }), + DetectedPathTrackKind::Mp4 => Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected an MP4-style source on the raw-import path unexpectedly".to_string(), + }), + DetectedPathTrackKind::Unknown => Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "path-only mux input is not currently recognized as MP4, VobSub, supported AVI audio or MPEG-4 Part 2 video, supported MPEG-PS MPEG audio, AC-3, or MPEG-4 Part 2/H.264/H.265/VVC video, supported MPEG-TS MPEG audio, AAC LATM, MHAS, AC-3, E-AC-3, AC-4, DTS, TrueHD, MPEG-2 video, AV1, MPEG-4 Part 2, H.264, H.265, VVC, DVB subtitle, or DVB teletext video or subtitle carriage, JPEG still images, PNG still images, BMP still images, JPEG 2000 image or codestream input, self-describing YUV4MPEG raw video, raw ProRes, WAVE/AIFF/AIFC PCM, AAC ADTS, AAC LATM, MP3, AC-3, E-AC-3, AC-4, AMR, AMR-WB, QCP voice audio, DTS core audio, Dolby TrueHD, leading-sync MHAS MPEG-H, FLAC, IAMF, H.263 elementary video, MPEG-2 elementary video, MPEG-4 Part 2 elementary video, H.264 Annex B, H.265 Annex B, IVF-backed AV1/VP8/VP9/VP10, Ogg FLAC, Ogg Opus, Ogg Vorbis, Ogg Speex, Ogg Theora, or CAF ALAC".to_string(), + }), + } +} + +#[cfg(feature = "async")] +async fn import_detected_path_raw_async( + path: &Path, + spec: &str, + sources: &mut SourceCatalog, +) -> Result { + match detect_path_track_kind_async(path).await? { + DetectedPathTrackKind::Raw(codec) => { + import_detected_raw_codec_async(path, codec, spec, sources).await + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Avi) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected an AVI container on the raw-import path unexpectedly".to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Dash) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected a DASH manifest on the raw-import path unexpectedly" + .to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Ghi) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected a GHI source on the raw-import path unexpectedly".to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Gsf) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected a GSF source on the raw-import path unexpectedly".to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Nhml) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected an NHML sidecar on the raw-import path unexpectedly" + .to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Nhnt) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected an NHNT sidecar on the raw-import path unexpectedly" + .to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::ProgramStream) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "detected an MPEG program stream on the raw-import path unexpectedly" + .to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::Saf) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected a SAF source on the raw-import path unexpectedly".to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::TransportStream) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: + "detected an MPEG transport stream on the raw-import path unexpectedly" + .to_string(), + }) + } + DetectedPathTrackKind::Container(DetectedContainerPathKind::VobSub) => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected a VobSub source on the raw-import path unexpectedly" + .to_string(), + }) + } + DetectedPathTrackKind::Mp4ImportOnly(kind) => Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: format!( + "path-only mux import for `{kind}` is not supported; import this family from an MP4 source with `#audio` or `#track:ID` instead" + ), + }), + DetectedPathTrackKind::Mp4 => Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "detected an MP4-style source on the raw-import path unexpectedly".to_string(), + }), + DetectedPathTrackKind::Unknown => Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: "path-only mux input is not currently recognized as MP4, VobSub, supported AVI audio or MPEG-4 Part 2 video, supported MPEG-PS MPEG audio, AC-3, or MPEG-4 Part 2/H.264/H.265/VVC video, supported MPEG-TS MPEG audio, AAC LATM, MHAS, AC-3, E-AC-3, AC-4, DTS, TrueHD, MPEG-2 video, AV1, MPEG-4 Part 2, H.264, H.265, VVC, DVB subtitle, or DVB teletext video or subtitle carriage, JPEG still images, PNG still images, BMP still images, JPEG 2000 image or codestream input, self-describing YUV4MPEG raw video, raw ProRes, WAVE/AIFF/AIFC PCM, AAC ADTS, AAC LATM, MP3, AC-3, E-AC-3, AC-4, AMR, AMR-WB, DTS core audio, Dolby TrueHD, leading-sync MHAS MPEG-H, FLAC, IAMF, H.263 elementary video, MPEG-2 elementary video, MPEG-4 Part 2 elementary video, H.264 Annex B, H.265 Annex B, IVF-backed AV1/VP8/VP9/VP10, Ogg FLAC, Ogg Opus, Ogg Vorbis, Ogg Speex, Ogg Theora, or CAF ALAC".to_string(), + }), + } +} + +fn import_detected_raw_codec_sync( + path: &Path, + codec: MuxRawCodec, + spec: &str, + sources: &mut SourceCatalog, +) -> Result { + import_raw_track_sync(path, codec, spec.to_string(), sources) +} + +#[cfg(feature = "async")] +async fn import_detected_raw_codec_async( + path: &Path, + codec: MuxRawCodec, + spec: &str, + sources: &mut SourceCatalog, +) -> Result { + import_raw_track_async(path, codec, spec.to_string(), sources).await +} + +fn import_raw_track_sync( + path: &Path, + codec: MuxRawCodec, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + match codec { + MuxRawCodec::Mpeg2v => import_raw_mpeg2v_sync(path, spec, sources), + MuxRawCodec::Mp4v => import_raw_mp4v_sync(path, spec, sources), + MuxRawCodec::H263 => import_raw_h263_sync(path, spec, sources), + MuxRawCodec::H264 => import_raw_h264_sync(path, spec, sources), + MuxRawCodec::H265 => import_raw_h265_sync(path, spec, sources), + MuxRawCodec::Vvc => import_raw_vvc_sync(path, spec, sources), + MuxRawCodec::Av1 => import_raw_av1_sync(path, spec, sources), + MuxRawCodec::Vp8 | MuxRawCodec::Vp9 | MuxRawCodec::Vp10 => { + import_ivf_video_sync(path, codec, spec, sources) + } + MuxRawCodec::Aac => import_raw_aac_sync(path, spec, sources), + MuxRawCodec::Latm => import_raw_latm_sync(path, spec, sources), + MuxRawCodec::Mp3 => import_raw_mp3_sync(path, spec, sources), + MuxRawCodec::Ac3 => import_raw_ac3_sync(path, spec, sources), + MuxRawCodec::Eac3 => import_raw_eac3_sync(path, spec, sources), + MuxRawCodec::Ac4 => import_raw_ac4_sync(path, spec, sources), + MuxRawCodec::Amr => import_raw_amr_sync(path, spec, sources), + MuxRawCodec::AmrWb => import_raw_amr_wb_sync(path, spec, sources), + MuxRawCodec::Qcp => import_raw_qcp_sync(path, spec, sources), + MuxRawCodec::Jpeg => import_raw_jpeg_sync(path, spec, sources), + MuxRawCodec::Png => import_raw_png_sync(path, spec, sources), + MuxRawCodec::Bmp => import_raw_bmp_sync(path, spec, sources), + MuxRawCodec::Prores => import_raw_prores_sync(path, spec, sources), + MuxRawCodec::Y4m => import_raw_y4m_sync(path, spec, sources), + MuxRawCodec::J2k => import_raw_j2k_sync(path, spec, sources), + MuxRawCodec::Pcm => import_wave_pcm_sync(path, spec, sources), + MuxRawCodec::Dts => import_raw_dts_sync(path, spec, sources), + MuxRawCodec::Truehd => import_raw_truehd_sync(path, spec, sources), + MuxRawCodec::Alac => import_caf_alac_sync(path, spec, sources), + MuxRawCodec::Flac => import_raw_flac_sync(path, spec, sources), + MuxRawCodec::Iamf => import_raw_iamf_sync(path, spec, sources), + MuxRawCodec::MpegH => import_raw_mhas_sync(path, spec, sources), + MuxRawCodec::Opus => import_ogg_opus_sync(path, spec, sources), + MuxRawCodec::Vorbis => import_ogg_vorbis_sync(path, spec, sources), + MuxRawCodec::Speex => import_ogg_speex_sync(path, spec, sources), + MuxRawCodec::Theora => import_ogg_theora_sync(path, spec, sources), + } +} + +#[cfg(feature = "async")] +async fn import_raw_track_async( + path: &Path, + codec: MuxRawCodec, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + match codec { + MuxRawCodec::Mpeg2v => import_raw_mpeg2v_async(path, spec, sources).await, + MuxRawCodec::Mp4v => import_raw_mp4v_async(path, spec, sources).await, + MuxRawCodec::H263 => import_raw_h263_async(path, spec, sources).await, + MuxRawCodec::H264 => import_raw_h264_async(path, spec, sources).await, + MuxRawCodec::H265 => import_raw_h265_async(path, spec, sources).await, + MuxRawCodec::Vvc => import_raw_vvc_async(path, spec, sources).await, + MuxRawCodec::Av1 => import_raw_av1_async(path, spec, sources).await, + MuxRawCodec::Vp8 | MuxRawCodec::Vp9 | MuxRawCodec::Vp10 => { + import_ivf_video_async(path, codec, spec, sources).await + } + MuxRawCodec::Aac => import_raw_aac_async(path, spec, sources).await, + MuxRawCodec::Latm => import_raw_latm_async(path, spec, sources).await, + MuxRawCodec::Mp3 => import_raw_mp3_async(path, spec, sources).await, + MuxRawCodec::Ac3 => import_raw_ac3_async(path, spec, sources).await, + MuxRawCodec::Eac3 => import_raw_eac3_async(path, spec, sources).await, + MuxRawCodec::Ac4 => import_raw_ac4_async(path, spec, sources).await, + MuxRawCodec::Amr => import_raw_amr_async(path, spec, sources).await, + MuxRawCodec::AmrWb => import_raw_amr_wb_async(path, spec, sources).await, + MuxRawCodec::Qcp => import_raw_qcp_async(path, spec, sources).await, + MuxRawCodec::Jpeg => import_raw_jpeg_async(path, spec, sources).await, + MuxRawCodec::Png => import_raw_png_async(path, spec, sources).await, + MuxRawCodec::Bmp => import_raw_bmp_async(path, spec, sources).await, + MuxRawCodec::Prores => import_raw_prores_async(path, spec, sources).await, + MuxRawCodec::Y4m => import_raw_y4m_async(path, spec, sources).await, + MuxRawCodec::J2k => import_raw_j2k_async(path, spec, sources).await, + MuxRawCodec::Pcm => import_wave_pcm_async(path, spec, sources).await, + MuxRawCodec::Dts => import_raw_dts_async(path, spec, sources).await, + MuxRawCodec::Truehd => import_raw_truehd_async(path, spec, sources).await, + MuxRawCodec::Alac => import_caf_alac_async(path, spec, sources).await, + MuxRawCodec::Flac => import_raw_flac_async(path, spec, sources).await, + MuxRawCodec::Iamf => import_raw_iamf_async(path, spec, sources).await, + MuxRawCodec::MpegH => import_raw_mhas_async(path, spec, sources).await, + MuxRawCodec::Opus => import_ogg_opus_async(path, spec, sources).await, + MuxRawCodec::Vorbis => import_ogg_vorbis_async(path, spec, sources).await, + MuxRawCodec::Speex => import_ogg_speex_async(path, spec, sources).await, + MuxRawCodec::Theora => import_ogg_theora_async(path, spec, sources).await, + } +} + +pub(in crate::mux) fn build_visual_sample_entry_box( + sample_entry_type: FourCc, + width: u16, + height: u16, + child_boxes: &[Vec], +) -> Result, MuxError> { + build_visual_sample_entry_box_with_compressor_name( + sample_entry_type, + width, + height, + &[], + child_boxes, + ) +} + +pub(in crate::mux) fn build_visual_sample_entry_box_with_compressor_name( + sample_entry_type: FourCc, + width: u16, + height: u16, + compressor_name: &[u8], + child_boxes: &[Vec], +) -> Result, MuxError> { + let mut compressorname = [0_u8; 32]; + let visible_len = compressor_name.len().min(31); + compressorname[0] = + u8::try_from(visible_len).map_err(|_| MuxError::LayoutOverflow("compressor name"))?; + compressorname[1..1 + visible_len].copy_from_slice(&compressor_name[..visible_len]); + super::mp4::encode_typed_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: sample_entry_type, + data_reference_index: 1, + }, + width, + height, + horizresolution: 72_u32 << 16, + vertresolution: 72_u32 << 16, + frame_count: 1, + compressorname, + depth: 0x0018, + pre_defined3: -1, + ..VisualSampleEntry::default() + }, + &child_boxes.concat(), + ) +} + +pub(in crate::mux) fn build_generic_audio_sample_entry_box( + sample_entry_type: FourCc, + sample_rate: u32, + channel_count: u16, + sample_size: u16, + child_boxes: &[Vec], +) -> Result, MuxError> { + super::mp4::encode_typed_box( + &AudioSampleEntry { + sample_entry: SampleEntry { + box_type: sample_entry_type, + data_reference_index: 1, + }, + channel_count, + sample_size, + sample_rate: sample_rate << 16, + ..AudioSampleEntry::default() + }, + &child_boxes.concat(), + ) +} + +pub(in crate::mux) fn build_generic_media_sample_entry_box( + sample_entry_type: FourCc, + child_boxes: &[Vec], +) -> Result, MuxError> { + super::mp4::encode_typed_box( + &GenericMediaSampleEntry { + sample_entry: SampleEntry { + box_type: sample_entry_type, + data_reference_index: 1, + }, + }, + &child_boxes.concat(), + ) +} + +pub(in crate::mux) fn build_btrt_from_sample_sizes( + samples: I, + timescale: u32, +) -> Result +where + I: IntoIterator, +{ + build_btrt_from_sample_sizes_with_total_duration(samples, timescale, None) +} + +pub(in crate::mux) fn build_btrt_from_sample_sizes_with_total_duration( + samples: I, + timescale: u32, + total_duration_override: Option, +) -> Result +where + I: IntoIterator, +{ + if timescale == 0 { + return Ok(Btrt::default()); + } + + let mut saw_sample = false; + let mut buffer_size_db = 0_u32; + let mut total_payload_bytes = 0_u64; + let mut total_duration = 0_u64; + let mut max_window_payload_bytes = 0_u64; + let mut current_window_payload_bytes = 0_u64; + let mut window_start_decode_time = 0_u64; + let mut sample_decode_time = 0_u64; + for (data_size, duration) in samples { + saw_sample = true; + buffer_size_db = buffer_size_db.max(data_size); + total_payload_bytes = total_payload_bytes + .checked_add(u64::from(data_size)) + .ok_or(MuxError::LayoutOverflow("audio total payload bytes"))?; + total_duration = total_duration + .checked_add(u64::from(duration)) + .ok_or(MuxError::LayoutOverflow("audio total duration"))?; + current_window_payload_bytes = current_window_payload_bytes + .checked_add(u64::from(data_size)) + .ok_or(MuxError::LayoutOverflow("audio bitrate window payload"))?; + if sample_decode_time > window_start_decode_time.saturating_add(u64::from(timescale)) { + max_window_payload_bytes = max_window_payload_bytes.max(current_window_payload_bytes); + window_start_decode_time = sample_decode_time; + current_window_payload_bytes = 0; + } + sample_decode_time = sample_decode_time + .checked_add(u64::from(duration)) + .ok_or(MuxError::LayoutOverflow("audio decode time"))?; + } + let total_duration = total_duration_override.unwrap_or(total_duration); + if !saw_sample || total_duration == 0 { + return Ok(Btrt::default()); + } + + let avg_bitrate = total_payload_bytes + .checked_mul(8) + .and_then(|bits| bits.checked_mul(u64::from(timescale))) + .ok_or(MuxError::LayoutOverflow("audio average bitrate"))? + / total_duration; + let avg_bitrate = avg_bitrate & !7; + + let max_bitrate = if max_window_payload_bytes == 0 { + avg_bitrate + } else { + max_window_payload_bytes + .checked_mul(8) + .ok_or(MuxError::LayoutOverflow("audio maximum bitrate"))? + }; + + Ok(Btrt { + buffer_size_db, + max_bitrate: u32::try_from(max_bitrate) + .map_err(|_| MuxError::LayoutOverflow("audio maximum bitrate"))?, + avg_bitrate: u32::try_from(avg_bitrate) + .map_err(|_| MuxError::LayoutOverflow("audio average bitrate"))?, + }) +} + +fn import_ivf_video_sync( + path: &Path, + codec: MuxRawCodec, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = match codec { + MuxRawCodec::Vp8 => scan_vp8_file_sync(path, &spec)?, + MuxRawCodec::Vp9 => scan_vp9_file_sync(path, &spec)?, + MuxRawCodec::Vp10 => scan_vp10_file_sync(path, &spec)?, + _ => unreachable!("only IVF-backed codecs use this import helper"), + }; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name(match codec { + MuxRawCodec::Vp8 => "vp8", + MuxRawCodec::Vp9 => "vp9", + MuxRawCodec::Vp10 => "vp10", + _ => unreachable!("only IVF-backed codecs use this import helper"), + }), + mux_policy: direct_ingest_mux_policy( + match codec { + MuxRawCodec::Vp8 => "vp8", + MuxRawCodec::Vp9 => "vp9", + MuxRawCodec::Vp10 => "vp10", + _ => unreachable!("only IVF-backed codecs use this import helper"), + }, + MuxTrackKind::Video, + ), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_ivf_video_async( + path: &Path, + codec: MuxRawCodec, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let source_index = sources.add_file(path)?; + let parsed = match codec { + MuxRawCodec::Vp8 => scan_vp8_file_async(path, &spec).await?, + MuxRawCodec::Vp9 => scan_vp9_file_async(path, &spec).await?, + MuxRawCodec::Vp10 => scan_vp10_file_async(path, &spec).await?, + _ => unreachable!("only IVF-backed codecs use this import helper"), + }; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale: parsed.timescale, + language: *b"und", + handler_name: direct_ingest_handler_name(match codec { + MuxRawCodec::Vp8 => "vp8", + MuxRawCodec::Vp9 => "vp9", + MuxRawCodec::Vp10 => "vp10", + _ => unreachable!("only IVF-backed codecs use this import helper"), + }), + mux_policy: direct_ingest_mux_policy( + match codec { + MuxRawCodec::Vp8 => "vp8", + MuxRawCodec::Vp9 => "vp9", + MuxRawCodec::Vp10 => "vp10", + _ => unreachable!("only IVF-backed codecs use this import helper"), + }, + MuxTrackKind::Video, + ), + width: parsed.width, + height: parsed.height, + sample_entry_box: parsed.sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(parsed.samples, source_index), + }) +} + +fn import_raw_av1_sync( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_av1_file_sync(path, &spec)?; + let ParsedAv1Track { + width, + height, + timescale, + sample_entry_box, + samples, + source, + } = parsed; + let source_index = match source { + ParsedAv1TrackSource::File => sources.add_file(path)?, + ParsedAv1TrackSource::Segmented(source) => sources.add_segmented(source)?, + }; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("av1"), + mux_policy: direct_ingest_mux_policy("av1", MuxTrackKind::Video), + width, + height, + sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(samples, source_index), + }) +} + +#[cfg(feature = "async")] +async fn import_raw_av1_async( + path: &Path, + spec: String, + sources: &mut SourceCatalog, +) -> Result { + let parsed = scan_av1_file_async(path, &spec).await?; + let ParsedAv1Track { + width, + height, + timescale, + sample_entry_box, + samples, + source, + } = parsed; + let source_index = match source { + ParsedAv1TrackSource::File => sources.add_file(path)?, + ParsedAv1TrackSource::Segmented(source) => sources.add_segmented(source)?, + }; + Ok(ImportedTrack { + kind: MuxTrackKind::Video, + timescale, + language: *b"und", + handler_name: direct_ingest_handler_name("av1"), + mux_policy: direct_ingest_mux_policy("av1", MuxTrackKind::Video), + width, + height, + sample_entry_box, + source_edit_media_time: None, + sample_roll_distance: None, + samples: imported_samples_from_staged(samples, source_index), + }) +} + +#[derive(Clone, Copy)] +pub(in crate::mux) struct SourceFileSpan { + pub(in crate::mux) source_offset: u64, + pub(in crate::mux) size: u32, +} + +pub(in crate::mux) fn read_exact_at_sync( + file: &mut File, + offset: u64, + buf: &mut [u8], + spec: &str, + truncated_message: &'static str, +) -> Result<(), MuxError> { + file.seek(SeekFrom::Start(offset))?; + match file.read_exact(buf) { + Ok(_) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: truncated_message.to_string(), + }) + } + Err(error) => Err(MuxError::Io(error)), + } +} + +pub(in crate::mux) fn read_spans_sync( + file: &mut File, + spans: &[SourceFileSpan], + total_size: u32, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + let mut bytes = Vec::with_capacity( + usize::try_from(total_size) + .map_err(|_| MuxError::LayoutOverflow("packet byte capacity"))?, + ); + for span in spans { + let mut chunk = vec![0_u8; usize::try_from(span.size).unwrap()]; + read_exact_at_sync( + file, + span.source_offset, + &mut chunk, + spec, + truncated_message, + )?; + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +fn absolute_path(path: &Path) -> Result { + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + Ok(std::env::current_dir()?.join(path)) +} + +fn extract_required_single_as_sync( + reader: &mut R, + parent: &HeaderInfo, + path: BoxPath, + name: &'static str, +) -> Result +where + R: Read + Seek, + T: CodecBox + Clone + 'static, +{ + let boxes = extract_box_as::<_, T>(reader, Some(parent), path)?; + let [value] = boxes.as_slice() else { + return Err(MuxError::UnsupportedTrackImport { + spec: name.to_string(), + message: format!("expected exactly one {name} box but found {}", boxes.len()), + }); + }; + Ok(value.clone()) +} + +fn extract_optional_single_as_sync( + reader: &mut R, + parent: &HeaderInfo, + path: BoxPath, +) -> Result, MuxError> +where + R: Read + Seek, + T: CodecBox + Clone + 'static, +{ + let boxes = extract_box_as::<_, T>(reader, Some(parent), path)?; + match boxes.len() { + 0 => Ok(None), + 1 => Ok(Some(boxes[0].clone())), + _ => Err(MuxError::UnsupportedTrackImport { + spec: "track".to_string(), + message: "expected at most one optional box".to_string(), + }), + } +} + +#[cfg(feature = "async")] +async fn extract_required_single_as_async( + reader: &mut R, + parent: &HeaderInfo, + path: BoxPath, + name: &'static str, +) -> Result +where + R: AsyncReadSeek, + T: CodecBox + Clone + 'static, +{ + let boxes = extract_box_as_async::<_, T>(reader, Some(parent), path).await?; + let [value] = boxes.as_slice() else { + return Err(MuxError::UnsupportedTrackImport { + spec: name.to_string(), + message: format!("expected exactly one {name} box but found {}", boxes.len()), + }); + }; + Ok(value.clone()) +} + +#[cfg(feature = "async")] +async fn extract_optional_single_as_async( + reader: &mut R, + parent: &HeaderInfo, + path: BoxPath, +) -> Result, MuxError> +where + R: AsyncReadSeek, + T: CodecBox + Clone + 'static, +{ + let boxes = extract_box_as_async::<_, T>(reader, Some(parent), path).await?; + match boxes.len() { + 0 => Ok(None), + 1 => Ok(Some(boxes[0].clone())), + _ => Err(MuxError::UnsupportedTrackImport { + spec: "track".to_string(), + message: "expected at most one optional box".to_string(), + }), + } +} + +fn extract_required_single_info_sync( + reader: &mut R, + parent: &HeaderInfo, + path: BoxPath, + name: &'static str, +) -> Result +where + R: Read + Seek, +{ + let infos = extract_box(reader, Some(parent), path)?; + let [info] = infos.as_slice() else { + return Err(MuxError::UnsupportedTrackImport { + spec: name.to_string(), + message: format!("expected exactly one {name} box but found {}", infos.len()), + }); + }; + Ok(*info) +} + +#[cfg(feature = "async")] +async fn extract_required_single_info_async( + reader: &mut R, + parent: &HeaderInfo, + path: BoxPath, + name: &'static str, +) -> Result +where + R: AsyncReadSeek, +{ + let infos = extract_box_async(reader, Some(parent), path).await?; + let [info] = infos.as_slice() else { + return Err(MuxError::UnsupportedTrackImport { + spec: name.to_string(), + message: format!("expected exactly one {name} box but found {}", infos.len()), + }); + }; + Ok(*info) +} + +fn extract_preserved_flat_stbl_boxes_sync( + reader: &mut R, + stbl_info: &HeaderInfo, +) -> Result>, MuxError> +where + R: Read + Seek, +{ + let mut preserved = Vec::new(); + preserved.extend(extract_box_bytes( + reader, + Some(stbl_info), + BoxPath::from([CSLG]), + )?); + preserved.extend(extract_box_bytes( + reader, + Some(stbl_info), + BoxPath::from([SDTP]), + )?); + preserved.extend(extract_box_bytes( + reader, + Some(stbl_info), + BoxPath::from([STPS]), + )?); + preserved.extend(extract_box_bytes( + reader, + Some(stbl_info), + BoxPath::from([STDP]), + )?); + preserved.extend(extract_box_bytes( + reader, + Some(stbl_info), + BoxPath::from([SUBS]), + )?); + preserved.extend(extract_box_bytes( + reader, + Some(stbl_info), + BoxPath::from([SGPD]), + )?); + preserved.extend(extract_box_bytes( + reader, + Some(stbl_info), + BoxPath::from([SBGP]), + )?); + Ok(preserved) +} + +#[cfg(feature = "async")] +async fn extract_preserved_flat_stbl_boxes_async( + reader: &mut R, + stbl_info: &HeaderInfo, +) -> Result>, MuxError> +where + R: AsyncReadSeek, +{ + let mut preserved = Vec::new(); + preserved + .extend(extract_box_bytes_async(reader, Some(stbl_info), BoxPath::from([CSLG])).await?); + preserved + .extend(extract_box_bytes_async(reader, Some(stbl_info), BoxPath::from([SDTP])).await?); + preserved + .extend(extract_box_bytes_async(reader, Some(stbl_info), BoxPath::from([STPS])).await?); + preserved + .extend(extract_box_bytes_async(reader, Some(stbl_info), BoxPath::from([STDP])).await?); + preserved + .extend(extract_box_bytes_async(reader, Some(stbl_info), BoxPath::from([SUBS])).await?); + preserved + .extend(extract_box_bytes_async(reader, Some(stbl_info), BoxPath::from([SGPD])).await?); + preserved + .extend(extract_box_bytes_async(reader, Some(stbl_info), BoxPath::from([SBGP])).await?); + Ok(preserved) +} + +fn expand_sample_sizes(stsz: &Stsz, path: &Path, track_id: u32) -> Result, MuxError> { + if stsz.sample_size != 0 { + return Ok(vec![stsz.sample_size; stsz.sample_count as usize]); + } + if stsz.entry_size.len() != stsz.sample_count as usize { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} has stsz sample_count {} but {} explicit entry sizes", + stsz.sample_count, + stsz.entry_size.len() + ), + }); + } + stsz.entry_size + .iter() + .map(|size| { + u32::try_from(*size).map_err(|_| MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} has a sample size that does not fit in u32"), + }) + }) + .collect() +} + +fn parse_compact_sample_sizes( + stz2_bytes: &[u8], + path: &Path, + track_id: u32, +) -> Result, MuxError> { + let payload = compact_sample_size_payload(stz2_bytes, path, track_id)?; + if payload.len() < 12 { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} has a truncated stz2 sample size table"), + }); + } + if payload[0] != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} uses unsupported stz2 version {}", + payload[0] + ), + }); + } + let field_size = payload[7]; + let sample_count = usize::try_from(u32::from_be_bytes([ + payload[8], + payload[9], + payload[10], + payload[11], + ])) + .map_err(|_| MuxError::LayoutOverflow("compact sample size count"))?; + let data = &payload[12..]; + let expected_data_len = compact_sample_size_data_len(field_size, sample_count, path, track_id)?; + if data.len() != expected_data_len { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} has stz2 sample_count {sample_count} with {} size bytes", + data.len() + ), + }); + } + + let mut sample_sizes = Vec::with_capacity(sample_count); + match field_size { + 4 => { + for (index, byte) in data.iter().copied().enumerate() { + if sample_sizes.len() == sample_count { + break; + } + sample_sizes.push(u32::from(byte >> 4)); + if sample_sizes.len() != sample_count { + sample_sizes.push(u32::from(byte & 0x0F)); + } else if byte & 0x0F != 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} has a non-zero unused stz2 nibble at byte {index}" + ), + }); + } + } + } + 8 => sample_sizes.extend(data.iter().map(|size| u32::from(*size))), + 16 => { + for bytes in data.chunks_exact(2) { + sample_sizes.push(u32::from(u16::from_be_bytes([bytes[0], bytes[1]]))); + } + } + _ => unreachable!(), + } + Ok(sample_sizes) +} + +fn compact_sample_size_payload<'a>( + stz2_bytes: &'a [u8], + path: &Path, + track_id: u32, +) -> Result<&'a [u8], MuxError> { + if stz2_bytes.len() < 8 { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} has a truncated stz2 box header"), + }); + } + let box_size = u32::from_be_bytes([stz2_bytes[0], stz2_bytes[1], stz2_bytes[2], stz2_bytes[3]]); + if stz2_bytes[4..8] != *b"stz2" { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} has a malformed stz2 sample size table"), + }); + } + let (declared_size, payload_offset) = if box_size == 1 { + if stz2_bytes.len() < 16 { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} has a truncated largesize stz2 header"), + }); + } + ( + u64::from_be_bytes([ + stz2_bytes[8], + stz2_bytes[9], + stz2_bytes[10], + stz2_bytes[11], + stz2_bytes[12], + stz2_bytes[13], + stz2_bytes[14], + stz2_bytes[15], + ]), + 16, + ) + } else { + (u64::from(box_size), 8) + }; + if declared_size != u64::try_from(stz2_bytes.len()).unwrap_or(u64::MAX) { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} has an inconsistent stz2 box size"), + }); + } + if declared_size < u64::try_from(payload_offset).unwrap_or(u64::MAX) { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} has an invalid stz2 box size"), + }); + } + Ok(&stz2_bytes[payload_offset..]) +} + +fn compact_sample_size_data_len( + field_size: u8, + sample_count: usize, + path: &Path, + track_id: u32, +) -> Result { + match field_size { + 4 => Ok(sample_count.div_ceil(2)), + 8 => Ok(sample_count), + 16 => sample_count + .checked_mul(2) + .ok_or(MuxError::LayoutOverflow("compact sample size payload")), + _ => Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} uses unsupported stz2 field size {field_size}"), + }), + } +} + +fn expand_sample_durations( + stts: &Stts, + sample_count: usize, + path: &Path, + track_id: u32, +) -> Result, MuxError> { + let mut durations = Vec::with_capacity(sample_count); + for entry in &stts.entries { + for _ in 0..entry.sample_count { + durations.push(entry.sample_delta); + } + } + if durations.len() != sample_count { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} resolves {} durations from stts but has {sample_count} samples", + durations.len() + ), + }); + } + Ok(durations) +} + +fn expand_composition_offsets( + ctts: Option<&Ctts>, + sample_count: usize, + path: &Path, + track_id: u32, +) -> Result, MuxError> { + let Some(ctts) = ctts else { + return Ok(vec![0; sample_count]); + }; + let mut offsets = Vec::with_capacity(sample_count); + for (entry_index, entry) in ctts.entries.iter().enumerate() { + for _ in 0..entry.sample_count { + offsets.push(i32::try_from(ctts.sample_offset(entry_index)).map_err(|_| { + MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} uses a composition offset outside i32"), + } + })?); + } + } + if offsets.len() != sample_count { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} resolves {} composition offsets but has {sample_count} samples", + offsets.len() + ), + }); + } + Ok(offsets) +} + +fn select_chunk_offsets( + stco: Option<&Stco>, + co64: Option<&Co64>, + path: &Path, + track_id: u32, +) -> Result, MuxError> { + match (stco, co64) { + (Some(_), Some(_)) => Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} carries both stco and co64"), + }), + (Some(stco), None) => Ok(stco.chunk_offset.clone()), + (None, Some(co64)) => Ok(co64.chunk_offset.clone()), + (None, None) => Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} is missing stco/co64 chunk offsets"), + }), + } +} + +fn expand_sample_offsets_and_description_indices( + stsc: &Stsc, + sample_sizes: &[u32], + chunk_offsets: &[u64], + path: &Path, + track_id: u32, +) -> Result<(Vec, Vec), MuxError> { + if stsc.entries.is_empty() { + if sample_sizes.is_empty() && chunk_offsets.is_empty() { + return Ok((Vec::new(), Vec::new())); + } + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} has no stsc entries"), + }); + } + + let mut mappings = Vec::with_capacity(chunk_offsets.len()); + for (index, entry) in stsc.entries.iter().enumerate() { + if entry.first_chunk == 0 || entry.sample_description_index == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} uses unsupported stsc entry first_chunk={} sample_description_index={}", + entry.first_chunk, entry.sample_description_index + ), + }); + } + let next_first_chunk = stsc + .entries + .get(index + 1) + .map(|next| next.first_chunk) + .unwrap_or( + u32::try_from(chunk_offsets.len()) + .map_err(|_| MuxError::LayoutOverflow("chunk count"))? + .saturating_add(1), + ); + if next_first_chunk <= entry.first_chunk { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} has descending stsc first_chunk values"), + }); + } + for _ in entry.first_chunk..next_first_chunk { + mappings.push((entry.samples_per_chunk, entry.sample_description_index)); + } + } + if mappings.len() != chunk_offsets.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} resolved {} chunk mappings for {} chunk offsets", + mappings.len(), + chunk_offsets.len() + ), + }); + } + + let mut sample_offsets = Vec::with_capacity(sample_sizes.len()); + let mut sample_description_indices = Vec::with_capacity(sample_sizes.len()); + let mut sample_index = 0_usize; + for (chunk_offset, (samples_per_chunk, sample_description_index)) in + chunk_offsets.iter().zip(mappings) + { + let mut running_offset = *chunk_offset; + for _ in 0..samples_per_chunk { + let Some(sample_size) = sample_sizes.get(sample_index).copied() else { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} resolved more chunk samples than stsz entries" + ), + }); + }; + sample_offsets.push(running_offset); + sample_description_indices.push(sample_description_index); + running_offset = running_offset + .checked_add(u64::from(sample_size)) + .ok_or(MuxError::LayoutOverflow("sample offset"))?; + sample_index += 1; + } + } + if sample_index != sample_sizes.len() { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} resolved {sample_index} sample offsets for {} sample sizes", + sample_sizes.len() + ), + }); + } + Ok((sample_offsets, sample_description_indices)) +} + +fn expand_chunk_sample_counts( + stsc: &Stsc, + chunk_count: usize, + path: &Path, + track_id: u32, +) -> Result, MuxError> { + if stsc.entries.is_empty() { + if chunk_count == 0 { + return Ok(Vec::new()); + } + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} has no stsc entries"), + }); + } + + let mut chunk_sample_counts = Vec::with_capacity(chunk_count); + for (index, entry) in stsc.entries.iter().enumerate() { + if entry.first_chunk == 0 || entry.sample_description_index == 0 { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} uses unsupported stsc entry first_chunk={} sample_description_index={}", + entry.first_chunk, entry.sample_description_index + ), + }); + } + let next_first_chunk = stsc + .entries + .get(index + 1) + .map(|next| next.first_chunk) + .unwrap_or( + u32::try_from(chunk_count) + .map_err(|_| MuxError::LayoutOverflow("chunk count"))? + .saturating_add(1), + ); + if next_first_chunk <= entry.first_chunk { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!("track {track_id} has descending stsc first_chunk values"), + }); + } + for _ in entry.first_chunk..next_first_chunk { + chunk_sample_counts.push(entry.samples_per_chunk); + } + } + if chunk_sample_counts.len() != chunk_count { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} resolved {} chunk sample counts for {chunk_count} chunk offsets", + chunk_sample_counts.len(), + ), + }); + } + Ok(chunk_sample_counts) +} + +fn expand_sync_samples( + stss: Option<&Stss>, + sample_entry_type: FourCc, + sample_count: usize, + path: &Path, + track_id: u32, +) -> Result, MuxError> { + let Some(stss) = stss else { + return Ok(vec![true; sample_count]); + }; + if stss.entry_count == 0 + && matches!( + sample_entry_type, + value if value == FourCc::from_bytes(*b"vp08") + || value == FourCc::from_bytes(*b"vp09") + ) + { + return Ok(vec![true; sample_count]); + } + if stss.entry_count == 1 + && stss.sample_number.as_slice() == [1] + && sample_count > 2 + && matches!( + sample_entry_type, + value + if value == FourCc::from_bytes(*b"mha1") + || value == FourCc::from_bytes(*b"mha2") + || value == FourCc::from_bytes(*b"mhm1") + || value == FourCc::from_bytes(*b"mhm2") + ) + { + return Ok(vec![true; sample_count]); + } + let mut sync = vec![false; sample_count]; + for sample_number in &stss.sample_number { + let index = usize::try_from(sample_number.saturating_sub(1)).map_err(|_| { + MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} exposes an stss entry that does not fit in usize" + ), + } + })?; + let Some(entry) = sync.get_mut(index) else { + return Err(MuxError::UnsupportedTrackImport { + spec: path.display().to_string(), + message: format!( + "track {track_id} exposes an stss sample number outside its sample count" + ), + }); + }; + *entry = true; + } + Ok(sync) +} + +fn imported_mp4_avc_length_size(sample_entry_box: &[u8]) -> Result, MuxError> { + if !matches!( + sample_entry_box_type(sample_entry_box), + Some(box_type) + if box_type == FourCc::from_bytes(*b"avc1") + || box_type == FourCc::from_bytes(*b"avc3") + ) { + return Ok(None); + } + let child_boxes = super::mp4::visual_sample_entry_immediate_children(sample_entry_box)?; + let Some(avcc_box) = child_boxes + .into_iter() + .find(|child_box| sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"avcC"))) + else { + return Ok(None); + }; + let avcc = super::mp4::decode_typed_box::(&avcc_box)?; + Ok(Some(usize::from(avcc.length_size_minus_one) + 1)) +} + +fn imported_mp4_hevc_length_size(sample_entry_box: &[u8]) -> Result, MuxError> { + if !matches!( + sample_entry_box_type(sample_entry_box), + Some(box_type) + if box_type == FourCc::from_bytes(*b"hvc1") + || box_type == FourCc::from_bytes(*b"hev1") + || box_type == FourCc::from_bytes(*b"dvh1") + || box_type == FourCc::from_bytes(*b"dvhe") + ) { + return Ok(None); + } + let child_boxes = super::mp4::visual_sample_entry_immediate_children(sample_entry_box)?; + let Some(hvcc_box) = child_boxes + .into_iter() + .find(|child_box| sample_entry_box_type(child_box) == Some(FourCc::from_bytes(*b"hvcC"))) + else { + return Ok(None); + }; + let hvcc = super::mp4::decode_typed_box::(&hvcc_box)?; + Ok(Some(usize::from(hvcc.length_size_minus_one) + 1)) +} + +fn supplement_imported_mp4_avc_sync_samples_sync( + reader: &mut R, + sample_entry_type: FourCc, + sample_entry_box: &[u8], + _source_stss: Option<&Stss>, + sample_offsets: &[u64], + sample_sizes: &[u32], + sync_samples: &mut [bool], +) -> Result<(), MuxError> +where + R: Read + Seek, +{ + if sample_entry_type != FourCc::from_bytes(*b"avc1") + && sample_entry_type != FourCc::from_bytes(*b"avc3") + { + return Ok(()); + } + let Some(length_size) = imported_mp4_avc_length_size(sample_entry_box)? else { + return Ok(()); + }; + if length_size == 0 || length_size > 4 { + return Ok(()); + } + for ((sample_offset, sample_size), is_sync_sample) in sample_offsets + .iter() + .copied() + .zip(sample_sizes.iter().copied()) + .zip(sync_samples.iter_mut()) + { + if *is_sync_sample || sample_size == 0 { + continue; + } + let sample_size = usize::try_from(sample_size) + .map_err(|_| MuxError::LayoutOverflow("AVC sample size inspection"))?; + let mut sample_bytes = vec![0_u8; sample_size]; + reader + .seek(SeekFrom::Start(sample_offset)) + .map_err(MuxError::Io)?; + match reader.read_exact(&mut sample_bytes) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break, + Err(error) => return Err(MuxError::Io(error)), + } + if imported_mp4_avc_sample_contains_sync_nal(&sample_bytes, length_size) { + *is_sync_sample = true; + } + } + + Ok(()) +} + +fn supplement_imported_mp4_hevc_sync_samples_sync( + reader: &mut R, + sample_entry_type: FourCc, + sample_entry_box: &[u8], + sample_offsets: &[u64], + sample_sizes: &[u32], + sync_samples: &mut [bool], +) -> Result<(), MuxError> +where + R: Read + Seek, +{ + if sample_entry_type != FourCc::from_bytes(*b"hvc1") + && sample_entry_type != FourCc::from_bytes(*b"hev1") + && sample_entry_type != FourCc::from_bytes(*b"dvh1") + && sample_entry_type != FourCc::from_bytes(*b"dvhe") + { + return Ok(()); + } + let Some(length_size) = imported_mp4_hevc_length_size(sample_entry_box)? else { + return Ok(()); + }; + if length_size == 0 || length_size > 4 { + return Ok(()); + } + + for ((sample_offset, sample_size), is_sync_sample) in sample_offsets + .iter() + .copied() + .zip(sample_sizes.iter().copied()) + .zip(sync_samples.iter_mut()) + { + if *is_sync_sample || sample_size == 0 { + continue; + } + let sample_size = usize::try_from(sample_size) + .map_err(|_| MuxError::LayoutOverflow("HEVC sample size inspection"))?; + let mut sample_bytes = vec![0_u8; sample_size]; + reader + .seek(SeekFrom::Start(sample_offset)) + .map_err(MuxError::Io)?; + match reader.read_exact(&mut sample_bytes) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break, + Err(error) => return Err(MuxError::Io(error)), + } + if imported_mp4_hevc_sample_contains_sync_nal(&sample_bytes, length_size) { + *is_sync_sample = true; + } + } + + Ok(()) +} + +#[cfg(feature = "async")] +async fn supplement_imported_mp4_avc_sync_samples_async( + reader: &mut R, + sample_entry_type: FourCc, + sample_entry_box: &[u8], + _source_stss: Option<&Stss>, + sample_offsets: &[u64], + sample_sizes: &[u32], + sync_samples: &mut [bool], +) -> Result<(), MuxError> +where + R: AsyncReadSeek, +{ + if sample_entry_type != FourCc::from_bytes(*b"avc1") + && sample_entry_type != FourCc::from_bytes(*b"avc3") + { + return Ok(()); + } + let Some(length_size) = imported_mp4_avc_length_size(sample_entry_box)? else { + return Ok(()); + }; + if length_size == 0 || length_size > 4 { + return Ok(()); + } + for ((sample_offset, sample_size), is_sync_sample) in sample_offsets + .iter() + .copied() + .zip(sample_sizes.iter().copied()) + .zip(sync_samples.iter_mut()) + { + if *is_sync_sample || sample_size == 0 { + continue; + } + let sample_size = usize::try_from(sample_size) + .map_err(|_| MuxError::LayoutOverflow("AVC sample size inspection"))?; + let mut sample_bytes = vec![0_u8; sample_size]; + reader + .seek(SeekFrom::Start(sample_offset)) + .await + .map_err(MuxError::Io)?; + match reader.read_exact(&mut sample_bytes).await { + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break, + Err(error) => return Err(MuxError::Io(error)), + } + if imported_mp4_avc_sample_contains_sync_nal(&sample_bytes, length_size) { + *is_sync_sample = true; + } + } + + Ok(()) +} + +#[cfg(feature = "async")] +async fn supplement_imported_mp4_hevc_sync_samples_async( + reader: &mut R, + sample_entry_type: FourCc, + sample_entry_box: &[u8], + sample_offsets: &[u64], + sample_sizes: &[u32], + sync_samples: &mut [bool], +) -> Result<(), MuxError> +where + R: AsyncReadSeek, +{ + if sample_entry_type != FourCc::from_bytes(*b"hvc1") + && sample_entry_type != FourCc::from_bytes(*b"hev1") + && sample_entry_type != FourCc::from_bytes(*b"dvh1") + && sample_entry_type != FourCc::from_bytes(*b"dvhe") + { + return Ok(()); + } + let Some(length_size) = imported_mp4_hevc_length_size(sample_entry_box)? else { + return Ok(()); + }; + if length_size == 0 || length_size > 4 { + return Ok(()); + } + + for ((sample_offset, sample_size), is_sync_sample) in sample_offsets + .iter() + .copied() + .zip(sample_sizes.iter().copied()) + .zip(sync_samples.iter_mut()) + { + if *is_sync_sample || sample_size == 0 { + continue; + } + let sample_size = usize::try_from(sample_size) + .map_err(|_| MuxError::LayoutOverflow("HEVC sample size inspection"))?; + let mut sample_bytes = vec![0_u8; sample_size]; + reader + .seek(SeekFrom::Start(sample_offset)) + .await + .map_err(MuxError::Io)?; + match reader.read_exact(&mut sample_bytes).await { + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break, + Err(error) => return Err(MuxError::Io(error)), + } + if imported_mp4_hevc_sample_contains_sync_nal(&sample_bytes, length_size) { + *is_sync_sample = true; + } + } + + Ok(()) +} + +fn imported_mp4_hevc_sample_contains_sync_nal(sample_bytes: &[u8], length_size: usize) -> bool { + let mut offset = 0_usize; + while sample_bytes.len().saturating_sub(offset) >= length_size { + let nal_size = match length_size { + 1 => usize::from(sample_bytes[offset]), + 2 => usize::from(u16::from_be_bytes( + sample_bytes[offset..offset + 2].try_into().unwrap(), + )), + 3 => { + (usize::from(sample_bytes[offset]) << 16) + | (usize::from(sample_bytes[offset + 1]) << 8) + | usize::from(sample_bytes[offset + 2]) + } + 4 => usize::try_from(u32::from_be_bytes( + sample_bytes[offset..offset + 4].try_into().unwrap(), + )) + .unwrap(), + _ => return false, + }; + offset += length_size; + let Some(end) = offset.checked_add(nal_size) else { + return false; + }; + if end > sample_bytes.len() { + return false; + } + let nal = &sample_bytes[offset..end]; + if imported_mp4_hevc_nal_is_sync(nal) { + return true; + } + offset = end; + } + false +} + +fn imported_mp4_hevc_nal_is_sync(nal: &[u8]) -> bool { + if nal.is_empty() { + return false; + } + matches!((nal[0] >> 1) & 0x3F, 16..=21) +} + +fn imported_mp4_avc_nal_is_intra_slice(nal: &[u8]) -> bool { + if nal.len() < 2 { + return false; + } + let nal_type = nal[0] & 0x1F; + if !matches!(nal_type, 1..=5) { + return false; + } + let rbsp = nal_to_rbsp(&nal[1..]); + let mut reader = BitReader::new(Cursor::new(rbsp)); + if read_ue_labeled(&mut reader, "h264", "H.264 slice first_mb_in_slice").is_err() { + return false; + } + let Ok(slice_type) = read_ue_labeled(&mut reader, "h264", "H.264 slice type") else { + return false; + }; + matches!(slice_type % 5, 2 | 4) +} + +fn decode_mdhd_language(encoded: [u8; 3]) -> [u8; 3] { + let mut decoded = [b'u', b'n', b'd']; + for (index, value) in encoded.into_iter().enumerate() { + decoded[index] = if (1..=26).contains(&value) { + value + b'`' + } else { + b"und"[index] + }; + } + decoded +} + +fn scale_track_time_to_movie( + track_id: u32, + value: i64, + track_timescale: u32, + movie_timescale: u32, + allow_inexact: bool, +) -> Result { + if track_timescale == 0 || movie_timescale == 0 { + return Err(MuxError::InvalidTrackTimescale { track_id }); + } + let sign = value.signum(); + let magnitude = value.unsigned_abs(); + let scaled = magnitude + .checked_mul(u64::from(movie_timescale)) + .ok_or(MuxError::LayoutOverflow("track time normalization"))?; + if scaled % u64::from(track_timescale) != 0 && !allow_inexact { + return Err(MuxError::IncompatibleTrackTiming { + track_id, + track_timescale, + movie_timescale, + value, + }); + } + let normalized = scaled / u64::from(track_timescale); + i64::try_from(normalized) + .map_err(|_| MuxError::LayoutOverflow("track time normalization")) + .map(|normalized| normalized * sign) +} + +fn track_times_fit_movie_timescale(track: &ImportedTrack, movie_timescale: u32) -> bool { + if track.timescale == 0 || movie_timescale == 0 { + return false; + } + track.samples.iter().all(|sample| { + can_scale_track_time_to_movie(i64::from(sample.duration), track.timescale, movie_timescale) + && can_scale_track_time_to_movie( + i64::from(sample.composition_time_offset), + track.timescale, + movie_timescale, + ) + }) +} + +fn can_scale_track_time_to_movie(value: i64, track_timescale: u32, movie_timescale: u32) -> bool { + let magnitude = value.unsigned_abs(); + magnitude + .checked_mul(u64::from(movie_timescale)) + .is_some_and(|scaled| scaled % u64::from(track_timescale) == 0) +} + +fn lcm_u32(left: u32, right: u32) -> Option { + let gcd = gcd_u32(left, right); + left.checked_div(gcd)?.checked_mul(right) +} + +const fn gcd_u32(mut left: u32, mut right: u32) -> u32 { + while right != 0 { + let next = left % right; + left = right; + right = next; + } + left +} + +fn probe_file_config_sync(reader: &mut R) -> Result +where + R: Read + Seek, +{ + use crate::probe::probe_with_options; + let summary = probe_with_options(reader, crate::probe::ProbeOptions::lightweight())?; + let config = MuxFileConfig::new(summary.timescale.max(1)) + .with_major_brand(summary.major_brand) + .with_minor_version(summary.minor_version) + .with_compatible_brands(summary.compatible_brands) + .with_flat_source_movie_creation_time(extract_preserved_flat_movie_creation_time_sync( + reader, + )?) + .with_flat_source_movie_modification_time( + extract_preserved_flat_movie_modification_time_sync(reader)?, + ) + .with_preserved_flat_prefix_bytes(extract_preserved_flat_prefix_bytes_sync(reader)?) + .with_preserved_flat_iods_bytes(extract_preserved_flat_iods_bytes_sync(reader)?) + .with_preserved_flat_udta_bytes(extract_preserved_flat_udta_bytes_sync(reader)?); + Ok(config) +} + +#[cfg(feature = "async")] +async fn probe_file_config_async(reader: &mut R) -> Result +where + R: AsyncReadSeek, +{ + use crate::probe::probe_with_options_async; + let summary = + probe_with_options_async(reader, crate::probe::ProbeOptions::lightweight()).await?; + let config = MuxFileConfig::new(summary.timescale.max(1)) + .with_major_brand(summary.major_brand) + .with_minor_version(summary.minor_version) + .with_compatible_brands(summary.compatible_brands) + .with_flat_source_movie_creation_time( + extract_preserved_flat_movie_creation_time_async(reader).await?, + ) + .with_flat_source_movie_modification_time( + extract_preserved_flat_movie_modification_time_async(reader).await?, + ) + .with_preserved_flat_prefix_bytes(extract_preserved_flat_prefix_bytes_async(reader).await?) + .with_preserved_flat_iods_bytes(extract_preserved_flat_iods_bytes_async(reader).await?) + .with_preserved_flat_udta_bytes(extract_preserved_flat_udta_bytes_async(reader).await?); + Ok(config) +} + +fn extract_preserved_flat_movie_creation_time_sync( + reader: &mut R, +) -> Result, MuxError> +where + R: Read + Seek, +{ + Ok( + extract_box_as::<_, Mvhd>(reader, None, BoxPath::from([MOOV, MVHD]))? + .into_iter() + .next() + .map(|mvhd| mvhd.creation_time()), + ) +} + +#[cfg(feature = "async")] +async fn extract_preserved_flat_movie_creation_time_async( + reader: &mut R, +) -> Result, MuxError> +where + R: AsyncReadSeek, +{ + Ok( + extract_box_as_async::<_, Mvhd>(reader, None, BoxPath::from([MOOV, MVHD])) + .await? + .into_iter() + .next() + .map(|mvhd| mvhd.creation_time()), + ) +} + +fn extract_preserved_flat_movie_modification_time_sync( + reader: &mut R, +) -> Result, MuxError> +where + R: Read + Seek, +{ + Ok( + extract_box_as::<_, Mvhd>(reader, None, BoxPath::from([MOOV, MVHD]))? + .into_iter() + .next() + .map(|mvhd| mvhd.modification_time()), + ) +} + +#[cfg(feature = "async")] +async fn extract_preserved_flat_movie_modification_time_async( + reader: &mut R, +) -> Result, MuxError> +where + R: AsyncReadSeek, +{ + Ok( + extract_box_as_async::<_, Mvhd>(reader, None, BoxPath::from([MOOV, MVHD])) + .await? + .into_iter() + .next() + .map(|mvhd| mvhd.modification_time()), + ) +} + +fn extract_preserved_flat_prefix_bytes_sync(reader: &mut R) -> Result, MuxError> +where + R: Read + Seek, +{ + let file_size = reader.seek(SeekFrom::End(0))?; + reader.seek(SeekFrom::Start(0))?; + let mut saw_ftyp = false; + let mut preserved = Vec::new(); + loop { + let offset = reader.stream_position()?; + if offset >= file_size { + break; + } + let info = match crate::BoxInfo::read(reader) { + Ok(info) => info, + Err(crate::HeaderError::Io(error)) if error.kind() == io::ErrorKind::UnexpectedEof => { + break; + } + Err(error) => { + return Err(MuxError::InvalidOutputLayout { + layout: "flat", + message: format!( + "failed to parse root box header while probing preserved flat prefix boxes: {error}" + ), + }); + } + }; + let box_end = info + .offset() + .checked_add(info.size()) + .ok_or(MuxError::LayoutOverflow("preserved flat prefix box range"))?; + if box_end > file_size { + break; + } + let box_type = info.box_type(); + if box_type == FTYP { + saw_ftyp = true; + } else if saw_ftyp && box_type == FREE { + reader.seek(SeekFrom::Start(info.offset()))?; + let mut box_bytes = vec![ + 0_u8; + usize::try_from(info.size()).map_err(|_| { + MuxError::LayoutOverflow("preserved flat prefix box size") + })? + ]; + reader.read_exact(&mut box_bytes)?; + preserved.extend_from_slice(&box_bytes); + } else if saw_ftyp { + break; + } + reader.seek(SeekFrom::Start(box_end))?; + } + Ok(preserved) +} + +#[cfg(feature = "async")] +async fn extract_preserved_flat_prefix_bytes_async(reader: &mut R) -> Result, MuxError> +where + R: AsyncReadSeek, +{ + let file_size = reader.seek(SeekFrom::End(0)).await?; + reader.seek(SeekFrom::Start(0)).await?; + let mut saw_ftyp = false; + let mut preserved = Vec::new(); + loop { + let offset = reader.stream_position().await?; + if offset >= file_size { + break; + } + let info = match HeaderInfo::read_async(reader).await { + Ok(info) => info, + Err(crate::HeaderError::Io(error)) if error.kind() == io::ErrorKind::UnexpectedEof => { + break; + } + Err(error) => { + return Err(MuxError::InvalidOutputLayout { + layout: "flat", + message: format!( + "failed to parse root box header while probing preserved flat prefix boxes: {error}" + ), + }); + } + }; + let box_end = info + .offset() + .checked_add(info.size()) + .ok_or(MuxError::LayoutOverflow("preserved flat prefix box range"))?; + if box_end > file_size { + break; + } + let box_type = info.box_type(); + if box_type == FTYP { + saw_ftyp = true; + } else if saw_ftyp && box_type == FREE { + reader.seek(SeekFrom::Start(info.offset())).await?; + let mut box_bytes = vec![ + 0_u8; + usize::try_from(info.size()).map_err(|_| { + MuxError::LayoutOverflow("preserved flat prefix box size") + })? + ]; + reader.read_exact(&mut box_bytes).await?; + preserved.extend_from_slice(&box_bytes); + } else if saw_ftyp { + break; + } + reader.seek(SeekFrom::Start(box_end)).await?; + } + Ok(preserved) +} + +fn extract_preserved_flat_udta_bytes_sync(reader: &mut R) -> Result>, MuxError> +where + R: Read + Seek, +{ + Ok( + extract_box_bytes(reader, None, BoxPath::from([MOOV, UDTA]))? + .into_iter() + .next(), + ) +} + +#[cfg(feature = "async")] +async fn extract_preserved_flat_udta_bytes_async( + reader: &mut R, +) -> Result>, MuxError> +where + R: AsyncReadSeek, +{ + Ok( + extract_box_bytes_async(reader, None, BoxPath::from([MOOV, UDTA])) + .await? + .into_iter() + .next(), + ) +} + +fn extract_preserved_flat_iods_bytes_sync(reader: &mut R) -> Result>, MuxError> +where + R: Read + Seek, +{ + Ok( + extract_box_bytes(reader, None, BoxPath::from([MOOV, IODS]))? + .into_iter() + .next(), + ) +} + +#[cfg(feature = "async")] +async fn extract_preserved_flat_iods_bytes_async( + reader: &mut R, +) -> Result>, MuxError> +where + R: AsyncReadSeek, +{ + Ok( + extract_box_bytes_async(reader, None, BoxPath::from([MOOV, IODS])) + .await? + .into_iter() + .next(), + ) +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn read_exact_at_async( + file: &mut TokioFile, + offset: u64, + buf: &mut [u8], + spec: &str, + truncated_message: &'static str, +) -> Result<(), MuxError> { + file.seek(SeekFrom::Start(offset)).await?; + match file.read_exact(buf).await { + Ok(_) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => { + Err(MuxError::UnsupportedTrackImport { + spec: spec.to_string(), + message: truncated_message.to_string(), + }) + } + Err(error) => Err(MuxError::Io(error)), + } +} + +#[cfg(feature = "async")] +pub(in crate::mux) async fn read_spans_async( + file: &mut TokioFile, + spans: &[SourceFileSpan], + total_size: u32, + spec: &str, + truncated_message: &'static str, +) -> Result, MuxError> { + let mut bytes = Vec::with_capacity( + usize::try_from(total_size) + .map_err(|_| MuxError::LayoutOverflow("packet byte capacity"))?, + ); + for span in spans { + let mut chunk = vec![0_u8; usize::try_from(span.size).unwrap()]; + read_exact_at_async( + file, + span.source_offset, + &mut chunk, + spec, + truncated_message, + ) + .await?; + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} +use crate::probe::detect_aac_profile; diff --git a/src/mux/inspect.rs b/src/mux/inspect.rs new file mode 100644 index 0000000..7d6a718 --- /dev/null +++ b/src/mux/inspect.rs @@ -0,0 +1,3164 @@ +//! Public direct-ingest inspection and export helpers built on the mux parser path. +//! +//! This additive surface lets callers inspect one path-first direct-ingest input without writing +//! an MP4 first. Reports intentionally reuse the same native detection and staging path that the +//! real mux task uses, so successful reports describe the same staged sources, track metadata, and +//! sample timing that the retained flat mux surface would consume. + +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use super::MuxError; + +/// Structured output formats supported by the direct-ingest report writers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DirectIngestReportFormat { + /// Pretty-printed JSON output with stable field ordering. + Json, + /// Stable YAML output with one explicit field order. + Yaml, + /// Stable NHML-like XML sidecar output for the track-oriented direct-ingest view. + Nhml, + /// Stable NHNT-like XML sidecar output for the packet-oriented direct-ingest view. + Nhnt, +} + +/// Top-level detection result for one path-first direct-ingest input. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DirectIngestDetectedKind { + /// The input path was recognized as one MP4-family source. + Mp4, + /// The input path was recognized as one supported carried container family. + Container { + /// Stable lowercase container-family label. + container: String, + }, + /// The input path was recognized as one supported raw direct-ingest family. + Raw { + /// Stable lowercase codec-family label. + codec: String, + }, + /// The input path was recognized, but it is currently import-only on the native direct path. + ImportOnly { + /// Stable human-readable family label explaining the import-only state. + family: String, + }, + /// The input path was not recognized by the current native direct-ingest detector. + Unknown, +} + +/// One staged source referenced by the inspected direct-ingest path. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DirectIngestStagedSourceReport { + /// Stable staged source index referenced by the per-sample reports. + pub source_index: usize, + /// Backing filesystem path used by the staged source. + pub path: PathBuf, + /// Whether this staged source is segmented rather than one direct file range. + pub segmented: bool, + /// Total logical payload size exposed by this staged source. + pub total_size: u64, + /// Number of logical segments when this staged source is segmented. + pub segment_count: Option, + /// Logical segment detail when this staged source is segmented. + pub segments: Option>, +} + +/// One logical segment inside a segmented staged source. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DirectIngestSourceSegmentReport { + /// Stable lowercase segment-kind label. + pub kind: String, + /// Logical staged byte offset where this segment begins. + pub logical_offset: u64, + /// Logical staged payload size exposed by this segment. + pub logical_size: u64, + /// Backing file byte offset when the segment reads directly from the source file. + pub source_offset: Option, + /// Backing filesystem path when the segment reads directly from one external file. + pub source_path: Option, + /// Inline payload bytes as one lowercase hexadecimal string when the segment is inline. + pub data_hex: Option, +} + +/// One staged sample entry in the direct-ingest report. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DirectIngestSampleReport { + /// Staged source index that supplies this sample's bytes. + pub source_index: usize, + /// Logical staged byte offset used by the mux path for this sample. + pub data_offset: u64, + /// Number of staged payload bytes used by this sample. + pub data_size: u32, + /// Decode time assigned by the native direct-ingest path before movie-timescale normalization. + pub decode_time: u64, + /// Decode-time delta from the previous sample in this track, when one exists. + pub previous_decode_delta: Option, + /// Composition-time offset carried by this sample. + pub composition_time_offset: i32, + /// Presentation timestamp derived from decode time and composition offset. + pub presentation_time: i64, + /// Presentation end timestamp derived from presentation time and duration. + pub presentation_end_time: i64, + /// Presentation-time delta from the previous sample in this track, when one exists. + pub previous_presentation_delta: Option, + /// Decode duration carried by this sample. + pub duration: u32, + /// Whether this sample is marked as a sync sample. + pub is_sync_sample: bool, +} + +/// One staged track entry in the direct-ingest report. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DirectIngestTrackReport { + /// Track identifier that the direct-ingest path would assign or preserve. + pub track_id: u32, + /// Stable lowercase track-kind label used by the landed mux surface. + pub kind: String, + /// Media timescale carried by this track. + pub timescale: u32, + /// Three-letter ISO-639-2 language code. + pub language: String, + /// Authored handler-name string used by the current native path. + pub handler_name: String, + /// Sample-entry box type authored or preserved for this track. + pub sample_entry_type: String, + /// Full sample-entry box bytes as one lowercase hexadecimal string. + pub sample_entry_box_hex: String, + /// Visual width when the track is visual. + pub width: Option, + /// Visual height when the track is visual. + pub height: Option, + /// Source edit media time when the direct-ingest path preserves one. + pub source_edit_media_time: Option, + /// Sample roll distance when the current native path preserves one. + pub sample_roll_distance: Option, + /// Number of staged samples carried by this track. + pub sample_count: usize, + /// Number of sync samples carried by this track. + pub sync_sample_count: usize, + /// Whether the first staged sample in this track is marked as a sync sample. + pub starts_with_sync_sample: bool, + /// Sum of staged sample durations on the native media timescale. + pub total_duration: u64, + /// Sum of staged payload sizes in bytes. + pub total_payload_size: u64, + /// Average staged sample payload size in bytes. + pub average_sample_size: Option, + /// Smallest staged sample payload size in bytes. + pub minimum_sample_size: Option, + /// Largest staged sample payload size in bytes. + pub maximum_sample_size: Option, + /// Smallest staged sample duration on the native media timescale. + pub minimum_sample_duration: Option, + /// Largest staged sample duration on the native media timescale. + pub maximum_sample_duration: Option, + /// Average authored track bitrate on the native media timescale. + pub average_bitrate_bits_per_second: Option, + /// Smallest sync-sample payload size in bytes. + pub minimum_sync_sample_size: Option, + /// Largest sync-sample payload size in bytes. + pub maximum_sync_sample_size: Option, + /// Average sync-sample payload size in bytes. + pub average_sync_sample_size: Option, + /// Average non-sync-sample payload size in bytes. + pub average_non_sync_sample_size: Option, + /// Smallest composition-time offset observed across the staged samples. + pub minimum_composition_time_offset: Option, + /// Largest composition-time offset observed across the staged samples. + pub maximum_composition_time_offset: Option, + /// Smallest presentation timestamp observed across the staged samples. + pub minimum_presentation_time: Option, + /// Largest presentation end timestamp observed across the staged samples. + pub maximum_presentation_end_time: Option, + /// Smallest presentation-time delta observed between consecutive samples. + pub minimum_previous_presentation_delta: Option, + /// Largest presentation-time delta observed between consecutive samples. + pub maximum_previous_presentation_delta: Option, + /// Smallest decode-time delta observed between consecutive samples. + pub minimum_previous_decode_delta: Option, + /// Largest decode-time delta observed between consecutive samples. + pub maximum_previous_decode_delta: Option, + /// Number of times one sample started after the previous presentation end time. + pub presentation_gap_count: usize, + /// Number of times one sample started before the previous presentation end time. + pub presentation_overlap_count: usize, + /// Number of times one sample presentation time moved backward relative to the previous one. + pub presentation_regression_count: usize, + /// Number of times adjacent samples changed decode duration. + pub duration_change_count: usize, + /// Number of times adjacent samples changed composition-time offset. + pub composition_time_offset_change_count: usize, + /// Smallest distance in samples between consecutive sync samples. + pub minimum_sync_sample_distance: Option, + /// Largest distance in samples between consecutive sync samples. + pub maximum_sync_sample_distance: Option, + /// Average distance in samples between consecutive sync samples. + pub average_sync_sample_distance: Option, + /// Smallest decode-time delta between consecutive sync samples. + pub minimum_sync_sample_decode_delta: Option, + /// Largest decode-time delta between consecutive sync samples. + pub maximum_sync_sample_decode_delta: Option, + /// Average decode-time delta between consecutive sync samples. + pub average_sync_sample_decode_delta: Option, + /// Zero-based index of the first sync sample when one exists. + pub first_sync_sample_index: Option, + /// Zero-based index of the last sync sample when one exists. + pub last_sync_sample_index: Option, + /// Decode time of the first sync sample when one exists. + pub first_sync_decode_time: Option, + /// Decode time of the last sync sample when one exists. + pub last_sync_decode_time: Option, + /// Presentation time of the first sync sample when one exists. + pub first_sync_presentation_time: Option, + /// Presentation time of the last sync sample when one exists. + pub last_sync_presentation_time: Option, + /// Decode time of the first staged sample in this track. + pub first_decode_time: u64, + /// Decode end time of the last staged sample in this track. + pub end_decode_time: u64, + /// Per-sample staged metadata in native decode order. + pub samples: Vec, +} + +/// Top-level direct-ingest report for one input path. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DirectIngestReport { + /// Input path inspected by the direct-ingest detector. + pub input_path: PathBuf, + /// High-level detection result for this input path. + pub detected_kind: DirectIngestDetectedKind, + /// Whether the current native direct-ingest path supports this input directly. + pub supports_flat_mux: bool, + /// Optional explanatory note for import-only or unknown inputs. + pub note: Option, + /// Number of staged tracks surfaced by the native direct-ingest path. + pub track_count: usize, + /// Total number of staged samples across every reported track. + pub total_sample_count: usize, + /// Total number of sync samples across every reported track. + pub total_sync_sample_count: usize, + /// Sum of staged payload sizes across every reported track. + pub total_payload_size: u64, + /// Staged sources referenced by the reported samples. + pub staged_sources: Vec, + /// Track reports surfaced by the native direct-ingest path. + pub tracks: Vec, +} + +/// One flattened packet entry in the additive packet-focused direct-ingest view. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DirectIngestPacketEntry { + /// Track identifier that the direct-ingest path would assign or preserve. + pub track_id: u32, + /// Zero-based packet index in native decode order within the track. + pub packet_index: usize, + /// Stable lowercase track-kind label used by the landed mux surface. + pub track_kind: String, + /// Media timescale carried by this packet's track. + pub timescale: u32, + /// Sample-entry box type authored or preserved for this packet's track. + pub sample_entry_type: String, + /// Staged source index that supplies this packet's bytes. + pub source_index: usize, + /// Logical staged byte offset used by the mux path for this packet. + pub data_offset: u64, + /// Number of staged payload bytes used by this packet. + pub data_size: u32, + /// Decode time assigned by the native direct-ingest path before movie-timescale normalization. + pub decode_time: u64, + /// Composition-time offset carried by this packet. + pub composition_time_offset: i32, + /// Presentation timestamp derived from decode time and composition offset. + pub presentation_time: i64, + /// Presentation end timestamp derived from presentation time and duration. + pub presentation_end_time: i64, + /// Presentation-time delta from the previous packet in this track, when one exists. + pub previous_presentation_delta: Option, + /// Decode duration carried by this packet. + pub duration: u32, + /// Decode-time delta from the previous packet in this track, when one exists. + pub previous_decode_delta: Option, + /// CRC-32 of the staged packet payload bytes. + pub payload_crc32: u32, + /// Whether this packet is marked as a sync sample. + pub is_sync_sample: bool, +} + +/// Top-level packet-focused direct-ingest report for one input path. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DirectIngestPacketReport { + /// Input path inspected by the direct-ingest detector. + pub input_path: PathBuf, + /// High-level detection result for this input path. + pub detected_kind: DirectIngestDetectedKind, + /// Whether the current native direct-ingest path supports this input directly. + pub supports_flat_mux: bool, + /// Optional explanatory note for import-only or unknown inputs. + pub note: Option, + /// Number of staged tracks surfaced by the native direct-ingest path. + pub track_count: usize, + /// Number of flattened packets emitted by this report. + pub packet_count: usize, + /// Number of sync packets emitted by this report. + pub sync_packet_count: usize, + /// Whether the first flattened packet is marked as a sync packet. + pub starts_with_sync_packet: bool, + /// Sum of staged payload sizes across every flattened packet. + pub total_payload_size: u64, + /// Smallest flattened packet payload size in bytes. + pub minimum_packet_size: Option, + /// Largest flattened packet payload size in bytes. + pub maximum_packet_size: Option, + /// Smallest sync-packet payload size in bytes. + pub minimum_sync_packet_size: Option, + /// Largest sync-packet payload size in bytes. + pub maximum_sync_packet_size: Option, + /// Average sync-packet payload size in bytes. + pub average_sync_packet_size: Option, + /// Average non-sync-packet payload size in bytes. + pub average_non_sync_packet_size: Option, + /// Smallest flattened packet duration on the native media timescale. + pub minimum_packet_duration: Option, + /// Largest flattened packet duration on the native media timescale. + pub maximum_packet_duration: Option, + /// Smallest previous decode delta surfaced by the flattened packet view. + pub minimum_previous_decode_delta: Option, + /// Largest previous decode delta surfaced by the flattened packet view. + pub maximum_previous_decode_delta: Option, + /// Smallest composition-time offset surfaced by the flattened packet view. + pub minimum_composition_time_offset: Option, + /// Largest composition-time offset surfaced by the flattened packet view. + pub maximum_composition_time_offset: Option, + /// Smallest presentation timestamp surfaced by the flattened packet view. + pub minimum_presentation_time: Option, + /// Largest presentation end timestamp surfaced by the flattened packet view. + pub maximum_presentation_end_time: Option, + /// Smallest presentation-time delta surfaced by the flattened packet view. + pub minimum_previous_presentation_delta: Option, + /// Largest presentation-time delta surfaced by the flattened packet view. + pub maximum_previous_presentation_delta: Option, + /// Number of times one packet started after the previous presentation end time within a track. + pub presentation_gap_count: usize, + /// Number of times one packet started before the previous presentation end time within a track. + pub presentation_overlap_count: usize, + /// Number of times one packet presentation time moved backward within a track. + pub presentation_regression_count: usize, + /// Number of times adjacent packets changed decode duration within a track. + pub duration_change_count: usize, + /// Number of times adjacent packets changed composition-time offset within a track. + pub composition_time_offset_change_count: usize, + /// Smallest per-track packet distance between consecutive sync packets. + pub minimum_sync_packet_distance: Option, + /// Largest per-track packet distance between consecutive sync packets. + pub maximum_sync_packet_distance: Option, + /// Average per-track packet distance between consecutive sync packets. + pub average_sync_packet_distance: Option, + /// Smallest per-track decode-time delta between consecutive sync packets. + pub minimum_sync_packet_decode_delta: Option, + /// Largest per-track decode-time delta between consecutive sync packets. + pub maximum_sync_packet_decode_delta: Option, + /// Average per-track decode-time delta between consecutive sync packets. + pub average_sync_packet_decode_delta: Option, + /// Track identifier of the first sync packet when one exists. + pub first_sync_packet_track_id: Option, + /// Zero-based packet index of the first sync packet when one exists. + pub first_sync_packet_index: Option, + /// Track identifier of the last sync packet when one exists. + pub last_sync_packet_track_id: Option, + /// Zero-based packet index of the last sync packet when one exists. + pub last_sync_packet_index: Option, + /// Decode time of the first sync packet when one exists. + pub first_sync_decode_time: Option, + /// Decode time of the last sync packet when one exists. + pub last_sync_decode_time: Option, + /// Presentation time of the first sync packet when one exists. + pub first_sync_presentation_time: Option, + /// Presentation time of the last sync packet when one exists. + pub last_sync_presentation_time: Option, + /// Track reports surfaced by the native direct-ingest path before packet flattening. + pub tracks: Vec, + /// Staged sources referenced by the reported packets. + pub staged_sources: Vec, + /// Flattened packet entries in track and decode order. + pub packets: Vec, +} + +/// Inspects one path-first direct-ingest input with the synchronous mux parser path. +pub fn inspect_direct_ingest_path(path: impl AsRef) -> Result { + super::import::inspect_direct_ingest_path_sync(path.as_ref()) +} + +/// Inspects one path-first direct-ingest input with the additive async mux parser path. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn inspect_direct_ingest_path_async( + path: impl AsRef, +) -> Result { + super::import::inspect_direct_ingest_path_async(path.as_ref()).await +} + +/// Inspects one path-first direct-ingest input and flattens the staged track view into packets. +pub fn inspect_direct_ingest_packets( + path: impl AsRef, +) -> Result { + super::import::inspect_direct_ingest_packets_sync(path.as_ref()) +} + +/// Async companion to [`inspect_direct_ingest_packets`]. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn inspect_direct_ingest_packets_async( + path: impl AsRef, +) -> Result { + super::import::inspect_direct_ingest_packets_async(path.as_ref()).await +} + +/// Collects warning-grade diagnostics from one track-focused direct-ingest report. +pub fn collect_track_report_warnings(report: &DirectIngestReport) -> Vec { + let mut warnings = Vec::new(); + for track in &report.tracks { + if !track.starts_with_sync_sample { + warnings.push(format!( + "track {} ({}) does not start with a sync sample", + track.track_id, track.kind + )); + } + if track.sync_sample_count == 0 { + warnings.push(format!( + "track {} ({}) has no sync samples", + track.track_id, track.kind + )); + } + if track.presentation_gap_count != 0 { + warnings.push(format!( + "track {} ({}) has {} presentation gap(s)", + track.track_id, track.kind, track.presentation_gap_count + )); + } + if track.presentation_overlap_count != 0 { + warnings.push(format!( + "track {} ({}) has {} presentation overlap(s)", + track.track_id, track.kind, track.presentation_overlap_count + )); + } + if track.presentation_regression_count != 0 { + warnings.push(format!( + "track {} ({}) has {} presentation regression(s)", + track.track_id, track.kind, track.presentation_regression_count + )); + } + if track.duration_change_count != 0 + && track.minimum_sample_duration != track.maximum_sample_duration + { + warnings.push(format!( + "track {} ({}) changes decode duration {} time(s)", + track.track_id, track.kind, track.duration_change_count + )); + } + if track.composition_time_offset_change_count != 0 + && track.minimum_composition_time_offset != track.maximum_composition_time_offset + { + warnings.push(format!( + "track {} ({}) changes composition offset {} time(s)", + track.track_id, track.kind, track.composition_time_offset_change_count + )); + } + } + warnings +} + +/// Collects warning-grade diagnostics from one packet-focused direct-ingest report. +pub fn collect_packet_report_warnings(report: &DirectIngestPacketReport) -> Vec { + let mut warnings = Vec::new(); + if report.packet_count != 0 && !report.starts_with_sync_packet { + warnings.push("packet view does not start with a sync packet".to_string()); + } + if report.packet_count != 0 && report.sync_packet_count == 0 { + warnings.push("packet view has no sync packets".to_string()); + } + if report.presentation_gap_count != 0 { + warnings.push(format!( + "packet view has {} presentation gap(s)", + report.presentation_gap_count + )); + } + if report.presentation_overlap_count != 0 { + warnings.push(format!( + "packet view has {} presentation overlap(s)", + report.presentation_overlap_count + )); + } + if report.presentation_regression_count != 0 { + warnings.push(format!( + "packet view has {} presentation regression(s)", + report.presentation_regression_count + )); + } + if report.duration_change_count != 0 + && report.minimum_packet_duration != report.maximum_packet_duration + { + warnings.push(format!( + "packet view changes decode duration {} time(s)", + report.duration_change_count + )); + } + if report.composition_time_offset_change_count != 0 + && report.minimum_composition_time_offset != report.maximum_composition_time_offset + { + warnings.push(format!( + "packet view changes composition offset {} time(s)", + report.composition_time_offset_change_count + )); + } + warnings +} + +/// Writes one direct-ingest report in the requested stable structured format. +pub fn write_report( + writer: &mut W, + report: &DirectIngestReport, + format: DirectIngestReportFormat, +) -> io::Result<()> +where + W: Write, +{ + match format { + DirectIngestReportFormat::Json => write_json_report(writer, report), + DirectIngestReportFormat::Yaml => write_yaml_report(writer, report), + DirectIngestReportFormat::Nhml => write_nhml_report(writer, report), + DirectIngestReportFormat::Nhnt => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "NHNT output requires the packet inspection view", + )), + } +} + +/// Writes one packet-focused direct-ingest report in the requested stable structured format. +pub fn write_packet_report( + writer: &mut W, + report: &DirectIngestPacketReport, + format: DirectIngestReportFormat, +) -> io::Result<()> +where + W: Write, +{ + match format { + DirectIngestReportFormat::Json => write_json_packet_report(writer, report), + DirectIngestReportFormat::Yaml => write_yaml_packet_report(writer, report), + DirectIngestReportFormat::Nhml => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "NHML output requires the track inspection view", + )), + DirectIngestReportFormat::Nhnt => write_nhnt_report(writer, report), + } +} + +fn detected_kind_name(kind: &DirectIngestDetectedKind) -> &'static str { + match kind { + DirectIngestDetectedKind::Mp4 => "mp4", + DirectIngestDetectedKind::Container { .. } => "container", + DirectIngestDetectedKind::Raw { .. } => "raw", + DirectIngestDetectedKind::ImportOnly { .. } => "import_only", + DirectIngestDetectedKind::Unknown => "unknown", + } +} + +fn write_json_report(writer: &mut W, report: &DirectIngestReport) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "{{")?; + write_json_field( + writer, + 1, + "InputPath", + &json_string(&report.input_path.display().to_string()), + true, + )?; + write_json_detected_kind(writer, &report.detected_kind)?; + write_json_field( + writer, + 1, + "SupportsFlatMux", + if report.supports_flat_mux { + "true" + } else { + "false" + }, + true, + )?; + if let Some(note) = &report.note { + write_json_field(writer, 1, "Note", &json_string(note), true)?; + } + write_json_field( + writer, + 1, + "TrackCount", + &report.track_count.to_string(), + true, + )?; + write_json_field( + writer, + 1, + "TotalSampleCount", + &report.total_sample_count.to_string(), + true, + )?; + write_json_field( + writer, + 1, + "TotalSyncSampleCount", + &report.total_sync_sample_count.to_string(), + true, + )?; + write_json_field( + writer, + 1, + "TotalPayloadSize", + &report.total_payload_size.to_string(), + true, + )?; + writeln!(writer, " \"StagedSources\": [")?; + for (index, source) in report.staged_sources.iter().enumerate() { + write_json_source(writer, source, index + 1 != report.staged_sources.len())?; + } + writeln!(writer, " ],")?; + writeln!(writer, " \"Tracks\": [")?; + for (index, track) in report.tracks.iter().enumerate() { + write_json_track(writer, track, index + 1 != report.tracks.len())?; + } + writeln!(writer, " ]")?; + writeln!(writer, "}}") +} + +fn write_json_packet_report(writer: &mut W, report: &DirectIngestPacketReport) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "{{")?; + write_json_field( + writer, + 1, + "InputPath", + &json_string(&report.input_path.display().to_string()), + true, + )?; + write_json_detected_kind(writer, &report.detected_kind)?; + write_json_field( + writer, + 1, + "SupportsFlatMux", + if report.supports_flat_mux { + "true" + } else { + "false" + }, + true, + )?; + if let Some(note) = &report.note { + write_json_field(writer, 1, "Note", &json_string(note), true)?; + } + write_json_field( + writer, + 1, + "TrackCount", + &report.track_count.to_string(), + true, + )?; + write_json_field( + writer, + 1, + "PacketCount", + &report.packet_count.to_string(), + true, + )?; + write_json_field( + writer, + 1, + "SyncPacketCount", + &report.sync_packet_count.to_string(), + true, + )?; + write_json_field( + writer, + 1, + "StartsWithSyncPacket", + if report.starts_with_sync_packet { + "true" + } else { + "false" + }, + true, + )?; + write_json_field( + writer, + 1, + "TotalPayloadSize", + &report.total_payload_size.to_string(), + true, + )?; + if let Some(minimum_packet_size) = report.minimum_packet_size { + write_json_field( + writer, + 1, + "MinimumPacketSize", + &minimum_packet_size.to_string(), + true, + )?; + } + if let Some(maximum_packet_size) = report.maximum_packet_size { + write_json_field( + writer, + 1, + "MaximumPacketSize", + &maximum_packet_size.to_string(), + true, + )?; + } + if let Some(minimum_sync_packet_size) = report.minimum_sync_packet_size { + write_json_field( + writer, + 1, + "MinimumSyncPacketSize", + &minimum_sync_packet_size.to_string(), + true, + )?; + } + if let Some(maximum_sync_packet_size) = report.maximum_sync_packet_size { + write_json_field( + writer, + 1, + "MaximumSyncPacketSize", + &maximum_sync_packet_size.to_string(), + true, + )?; + } + if let Some(average_sync_packet_size) = report.average_sync_packet_size { + write_json_field( + writer, + 1, + "AverageSyncPacketSize", + &average_sync_packet_size.to_string(), + true, + )?; + } + if let Some(average_non_sync_packet_size) = report.average_non_sync_packet_size { + write_json_field( + writer, + 1, + "AverageNonSyncPacketSize", + &average_non_sync_packet_size.to_string(), + true, + )?; + } + if let Some(minimum_packet_duration) = report.minimum_packet_duration { + write_json_field( + writer, + 1, + "MinimumPacketDuration", + &minimum_packet_duration.to_string(), + true, + )?; + } + if let Some(maximum_packet_duration) = report.maximum_packet_duration { + write_json_field( + writer, + 1, + "MaximumPacketDuration", + &maximum_packet_duration.to_string(), + true, + )?; + } + if let Some(minimum_previous_decode_delta) = report.minimum_previous_decode_delta { + write_json_field( + writer, + 1, + "MinimumPreviousDecodeDelta", + &minimum_previous_decode_delta.to_string(), + true, + )?; + } + if let Some(maximum_previous_decode_delta) = report.maximum_previous_decode_delta { + write_json_field( + writer, + 1, + "MaximumPreviousDecodeDelta", + &maximum_previous_decode_delta.to_string(), + true, + )?; + } + if let Some(minimum_composition_time_offset) = report.minimum_composition_time_offset { + write_json_field( + writer, + 1, + "MinimumCompositionTimeOffset", + &minimum_composition_time_offset.to_string(), + true, + )?; + } + if let Some(maximum_composition_time_offset) = report.maximum_composition_time_offset { + write_json_field( + writer, + 1, + "MaximumCompositionTimeOffset", + &maximum_composition_time_offset.to_string(), + true, + )?; + } + if let Some(minimum_presentation_time) = report.minimum_presentation_time { + write_json_field( + writer, + 1, + "MinimumPresentationTime", + &minimum_presentation_time.to_string(), + true, + )?; + } + if let Some(maximum_presentation_end_time) = report.maximum_presentation_end_time { + write_json_field( + writer, + 1, + "MaximumPresentationEndTime", + &maximum_presentation_end_time.to_string(), + true, + )?; + } + if let Some(minimum_previous_presentation_delta) = report.minimum_previous_presentation_delta { + write_json_field( + writer, + 1, + "MinimumPreviousPresentationDelta", + &minimum_previous_presentation_delta.to_string(), + true, + )?; + } + if let Some(maximum_previous_presentation_delta) = report.maximum_previous_presentation_delta { + write_json_field( + writer, + 1, + "MaximumPreviousPresentationDelta", + &maximum_previous_presentation_delta.to_string(), + true, + )?; + } + write_json_field( + writer, + 1, + "PresentationGapCount", + &report.presentation_gap_count.to_string(), + true, + )?; + write_json_field( + writer, + 1, + "PresentationOverlapCount", + &report.presentation_overlap_count.to_string(), + true, + )?; + write_json_field( + writer, + 1, + "PresentationRegressionCount", + &report.presentation_regression_count.to_string(), + true, + )?; + write_json_field( + writer, + 1, + "DurationChangeCount", + &report.duration_change_count.to_string(), + true, + )?; + write_json_field( + writer, + 1, + "CompositionTimeOffsetChangeCount", + &report.composition_time_offset_change_count.to_string(), + true, + )?; + if let Some(minimum_sync_packet_distance) = report.minimum_sync_packet_distance { + write_json_field( + writer, + 1, + "MinimumSyncPacketDistance", + &minimum_sync_packet_distance.to_string(), + true, + )?; + } + if let Some(maximum_sync_packet_distance) = report.maximum_sync_packet_distance { + write_json_field( + writer, + 1, + "MaximumSyncPacketDistance", + &maximum_sync_packet_distance.to_string(), + true, + )?; + } + if let Some(average_sync_packet_distance) = report.average_sync_packet_distance { + write_json_field( + writer, + 1, + "AverageSyncPacketDistance", + &average_sync_packet_distance.to_string(), + true, + )?; + } + if let Some(minimum_sync_packet_decode_delta) = report.minimum_sync_packet_decode_delta { + write_json_field( + writer, + 1, + "MinimumSyncPacketDecodeDelta", + &minimum_sync_packet_decode_delta.to_string(), + true, + )?; + } + if let Some(maximum_sync_packet_decode_delta) = report.maximum_sync_packet_decode_delta { + write_json_field( + writer, + 1, + "MaximumSyncPacketDecodeDelta", + &maximum_sync_packet_decode_delta.to_string(), + true, + )?; + } + if let Some(average_sync_packet_decode_delta) = report.average_sync_packet_decode_delta { + write_json_field( + writer, + 1, + "AverageSyncPacketDecodeDelta", + &average_sync_packet_decode_delta.to_string(), + true, + )?; + } + write_json_field( + writer, + 1, + "FirstSyncPacketTrackID", + &report + .first_sync_packet_track_id + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + true, + )?; + write_json_field( + writer, + 1, + "FirstSyncPacketIndex", + &report + .first_sync_packet_index + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + true, + )?; + write_json_field( + writer, + 1, + "LastSyncPacketTrackID", + &report + .last_sync_packet_track_id + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + true, + )?; + write_json_field( + writer, + 1, + "LastSyncPacketIndex", + &report + .last_sync_packet_index + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + true, + )?; + write_json_field( + writer, + 1, + "FirstSyncDecodeTime", + &report + .first_sync_decode_time + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + true, + )?; + write_json_field( + writer, + 1, + "LastSyncDecodeTime", + &report + .last_sync_decode_time + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + true, + )?; + write_json_field( + writer, + 1, + "FirstSyncPresentationTime", + &report + .first_sync_presentation_time + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + true, + )?; + write_json_field( + writer, + 1, + "LastSyncPresentationTime", + &report + .last_sync_presentation_time + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + true, + )?; + writeln!(writer, " \"StagedSources\": [")?; + for (index, source) in report.staged_sources.iter().enumerate() { + write_json_source(writer, source, index + 1 != report.staged_sources.len())?; + } + writeln!(writer, " ],")?; + writeln!(writer, " \"Packets\": [")?; + for (index, packet) in report.packets.iter().enumerate() { + write_json_packet(writer, packet, index + 1 != report.packets.len())?; + } + writeln!(writer, " ]")?; + writeln!(writer, "}}") +} + +fn write_json_detected_kind(writer: &mut W, kind: &DirectIngestDetectedKind) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, " \"DetectedKind\": {{")?; + write_json_field( + writer, + 2, + "Kind", + &json_string(detected_kind_name(kind)), + true, + )?; + match kind { + DirectIngestDetectedKind::Container { container } => { + write_json_field(writer, 2, "Container", &json_string(container), false)?; + } + DirectIngestDetectedKind::Raw { codec } => { + write_json_field(writer, 2, "Codec", &json_string(codec), false)?; + } + DirectIngestDetectedKind::ImportOnly { family } => { + write_json_field(writer, 2, "Family", &json_string(family), false)?; + } + DirectIngestDetectedKind::Mp4 | DirectIngestDetectedKind::Unknown => { + writeln!(writer, " \"Value\": null")?; + } + } + writeln!(writer, " }},") +} + +fn write_json_source( + writer: &mut W, + source: &DirectIngestStagedSourceReport, + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, " {{")?; + let mut fields = vec![ + ("SourceIndex", source.source_index.to_string()), + ("Path", json_string(&source.path.display().to_string())), + ( + "Segmented", + if source.segmented { "true" } else { "false" }.to_string(), + ), + ("TotalSize", source.total_size.to_string()), + ]; + if let Some(segment_count) = source.segment_count { + fields.push(("SegmentCount", segment_count.to_string())); + } + let has_segments = source + .segments + .as_ref() + .map(|segments| !segments.is_empty()) + .unwrap_or(false); + for (index, (name, value)) in fields.iter().enumerate() { + let trailing = index + 1 != fields.len() || has_segments; + write_json_field(writer, 3, name, value, trailing)?; + } + if let Some(segments) = &source.segments + && !segments.is_empty() + { + writeln!(writer, " \"Segments\": [")?; + for (index, segment) in segments.iter().enumerate() { + write_json_source_segment(writer, segment, index + 1 != segments.len())?; + } + writeln!(writer, " ]")?; + } + writeln!(writer, " }}{}", if trailing_comma { "," } else { "" }) +} + +fn write_json_source_segment( + writer: &mut W, + segment: &DirectIngestSourceSegmentReport, + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, " {{")?; + let mut fields = vec![ + ("Kind", json_string(&segment.kind)), + ("LogicalOffset", segment.logical_offset.to_string()), + ("LogicalSize", segment.logical_size.to_string()), + ]; + if let Some(source_offset) = segment.source_offset { + fields.push(("SourceOffset", source_offset.to_string())); + } + if let Some(data_hex) = &segment.data_hex { + fields.push(("DataHex", json_string(data_hex))); + } + for (index, (name, value)) in fields.iter().enumerate() { + write_json_field(writer, 5, name, value, index + 1 != fields.len())?; + } + writeln!( + writer, + " }}{}", + if trailing_comma { "," } else { "" } + ) +} + +fn write_json_track( + writer: &mut W, + track: &DirectIngestTrackReport, + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, " {{")?; + let mut fields = vec![ + ("TrackID", track.track_id.to_string()), + ("Kind", json_string(&track.kind)), + ("Timescale", track.timescale.to_string()), + ("Language", json_string(&track.language)), + ("HandlerName", json_string(&track.handler_name)), + ("SampleEntryType", json_string(&track.sample_entry_type)), + ( + "SampleEntryBoxHex", + json_string(&track.sample_entry_box_hex), + ), + ("SampleCount", track.sample_count.to_string()), + ("SyncSampleCount", track.sync_sample_count.to_string()), + ( + "StartsWithSyncSample", + if track.starts_with_sync_sample { + "true" + } else { + "false" + } + .to_string(), + ), + ("TotalDuration", track.total_duration.to_string()), + ("TotalPayloadSize", track.total_payload_size.to_string()), + ( + "AverageSampleSize", + track + .average_sample_size + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MinimumSampleSize", + track + .minimum_sample_size + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MaximumSampleSize", + track + .maximum_sample_size + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MinimumSampleDuration", + track + .minimum_sample_duration + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MaximumSampleDuration", + track + .maximum_sample_duration + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "AverageBitrateBitsPerSecond", + track + .average_bitrate_bits_per_second + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MinimumSyncSampleSize", + track + .minimum_sync_sample_size + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MaximumSyncSampleSize", + track + .maximum_sync_sample_size + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "AverageSyncSampleSize", + track + .average_sync_sample_size + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "AverageNonSyncSampleSize", + track + .average_non_sync_sample_size + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MinimumCompositionTimeOffset", + track + .minimum_composition_time_offset + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MaximumCompositionTimeOffset", + track + .maximum_composition_time_offset + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MinimumPresentationTime", + track + .minimum_presentation_time + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MaximumPresentationEndTime", + track + .maximum_presentation_end_time + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MinimumPreviousDecodeDelta", + track + .minimum_previous_decode_delta + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MaximumPreviousDecodeDelta", + track + .maximum_previous_decode_delta + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MinimumPreviousPresentationDelta", + track + .minimum_previous_presentation_delta + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MaximumPreviousPresentationDelta", + track + .maximum_previous_presentation_delta + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "PresentationGapCount", + track.presentation_gap_count.to_string(), + ), + ( + "PresentationOverlapCount", + track.presentation_overlap_count.to_string(), + ), + ( + "PresentationRegressionCount", + track.presentation_regression_count.to_string(), + ), + ( + "DurationChangeCount", + track.duration_change_count.to_string(), + ), + ( + "CompositionTimeOffsetChangeCount", + track.composition_time_offset_change_count.to_string(), + ), + ( + "MinimumSyncSampleDistance", + track + .minimum_sync_sample_distance + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MaximumSyncSampleDistance", + track + .maximum_sync_sample_distance + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "AverageSyncSampleDistance", + track + .average_sync_sample_distance + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MinimumSyncSampleDecodeDelta", + track + .minimum_sync_sample_decode_delta + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "MaximumSyncSampleDecodeDelta", + track + .maximum_sync_sample_decode_delta + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "AverageSyncSampleDecodeDelta", + track + .average_sync_sample_decode_delta + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "FirstSyncSampleIndex", + track + .first_sync_sample_index + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "LastSyncSampleIndex", + track + .last_sync_sample_index + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "FirstSyncDecodeTime", + track + .first_sync_decode_time + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "LastSyncDecodeTime", + track + .last_sync_decode_time + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "FirstSyncPresentationTime", + track + .first_sync_presentation_time + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "LastSyncPresentationTime", + track + .last_sync_presentation_time + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ("FirstDecodeTime", track.first_decode_time.to_string()), + ("EndDecodeTime", track.end_decode_time.to_string()), + ]; + if let Some(width) = track.width { + fields.push(("Width", width.to_string())); + } + if let Some(height) = track.height { + fields.push(("Height", height.to_string())); + } + if let Some(edit_media_time) = track.source_edit_media_time { + fields.push(("SourceEditMediaTime", edit_media_time.to_string())); + } + if let Some(sample_roll_distance) = track.sample_roll_distance { + fields.push(("SampleRollDistance", sample_roll_distance.to_string())); + } + for (index, (name, value)) in fields.iter().enumerate() { + write_json_field(writer, 3, name, value, true)?; + if index + 1 == fields.len() { + break; + } + } + writeln!(writer, " \"Samples\": [")?; + for (index, sample) in track.samples.iter().enumerate() { + write_json_sample(writer, sample, index + 1 != track.samples.len())?; + } + writeln!(writer, " ]")?; + writeln!(writer, " }}{}", if trailing_comma { "," } else { "" }) +} + +fn write_json_sample( + writer: &mut W, + sample: &DirectIngestSampleReport, + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, " {{")?; + let fields = [ + ("SourceIndex", sample.source_index.to_string()), + ("DataOffset", sample.data_offset.to_string()), + ("DataSize", sample.data_size.to_string()), + ("DecodeTime", sample.decode_time.to_string()), + ( + "PreviousDecodeDelta", + sample + .previous_decode_delta + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ( + "CompositionTimeOffset", + sample.composition_time_offset.to_string(), + ), + ("PresentationTime", sample.presentation_time.to_string()), + ( + "PresentationEndTime", + sample.presentation_end_time.to_string(), + ), + ( + "PreviousPresentationDelta", + sample + .previous_presentation_delta + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ("Duration", sample.duration.to_string()), + ( + "IsSyncSample", + if sample.is_sync_sample { + "true" + } else { + "false" + } + .to_string(), + ), + ]; + for (index, (name, value)) in fields.iter().enumerate() { + write_json_field(writer, 5, name, value, index + 1 != fields.len())?; + } + writeln!( + writer, + " }}{}", + if trailing_comma { "," } else { "" } + ) +} + +fn write_json_packet( + writer: &mut W, + packet: &DirectIngestPacketEntry, + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, " {{")?; + let fields = [ + ("TrackID", packet.track_id.to_string()), + ("PacketIndex", packet.packet_index.to_string()), + ("TrackKind", json_string(&packet.track_kind)), + ("Timescale", packet.timescale.to_string()), + ("SampleEntryType", json_string(&packet.sample_entry_type)), + ("SourceIndex", packet.source_index.to_string()), + ("DataOffset", packet.data_offset.to_string()), + ("DataSize", packet.data_size.to_string()), + ("DecodeTime", packet.decode_time.to_string()), + ( + "CompositionTimeOffset", + packet.composition_time_offset.to_string(), + ), + ("PresentationTime", packet.presentation_time.to_string()), + ( + "PresentationEndTime", + packet.presentation_end_time.to_string(), + ), + ( + "PreviousPresentationDelta", + packet + .previous_presentation_delta + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ("Duration", packet.duration.to_string()), + ( + "PreviousDecodeDelta", + packet + .previous_decode_delta + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".to_string()), + ), + ("PayloadCrc32", packet.payload_crc32.to_string()), + ( + "IsSyncSample", + if packet.is_sync_sample { + "true" + } else { + "false" + } + .to_string(), + ), + ]; + for (index, (name, value)) in fields.iter().enumerate() { + write_json_field(writer, 3, name, value, index + 1 != fields.len())?; + } + writeln!(writer, " }}{}", if trailing_comma { "," } else { "" }) +} + +fn write_json_field( + writer: &mut W, + indent_level: usize, + name: &str, + value: &str, + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + let indent = " ".repeat(indent_level); + writeln!( + writer, + "{indent}\"{name}\": {value}{}", + if trailing_comma { "," } else { "" } + ) +} + +fn write_yaml_report(writer: &mut W, report: &DirectIngestReport) -> io::Result<()> +where + W: Write, +{ + writeln!( + writer, + "input_path: {}", + yaml_string(&report.input_path.display().to_string()) + )?; + writeln!(writer, "detected_kind:")?; + writeln!( + writer, + " kind: {}", + yaml_string(detected_kind_name(&report.detected_kind)) + )?; + match &report.detected_kind { + DirectIngestDetectedKind::Container { container } => { + writeln!(writer, " container: {}", yaml_string(container))?; + } + DirectIngestDetectedKind::Raw { codec } => { + writeln!(writer, " codec: {}", yaml_string(codec))?; + } + DirectIngestDetectedKind::ImportOnly { family } => { + writeln!(writer, " family: {}", yaml_string(family))?; + } + DirectIngestDetectedKind::Mp4 | DirectIngestDetectedKind::Unknown => {} + } + writeln!( + writer, + "supports_flat_mux: {}", + if report.supports_flat_mux { + "true" + } else { + "false" + } + )?; + if let Some(note) = &report.note { + writeln!(writer, "note: {}", yaml_string(note))?; + } + writeln!(writer, "track_count: {}", report.track_count)?; + writeln!(writer, "total_sample_count: {}", report.total_sample_count)?; + writeln!( + writer, + "total_sync_sample_count: {}", + report.total_sync_sample_count + )?; + writeln!(writer, "total_payload_size: {}", report.total_payload_size)?; + writeln!(writer, "staged_sources:")?; + for source in &report.staged_sources { + writeln!(writer, "- source_index: {}", source.source_index)?; + writeln!( + writer, + " path: {}", + yaml_string(&source.path.display().to_string()) + )?; + writeln!( + writer, + " segmented: {}", + if source.segmented { "true" } else { "false" } + )?; + writeln!(writer, " total_size: {}", source.total_size)?; + if let Some(segment_count) = source.segment_count { + writeln!(writer, " segment_count: {}", segment_count)?; + } + if let Some(segments) = &source.segments + && !segments.is_empty() + { + writeln!(writer, " segments:")?; + for segment in segments { + writeln!(writer, " - kind: {}", yaml_string(&segment.kind))?; + writeln!(writer, " logical_offset: {}", segment.logical_offset)?; + writeln!(writer, " logical_size: {}", segment.logical_size)?; + match segment.source_offset { + Some(source_offset) => { + writeln!(writer, " source_offset: {}", source_offset)? + } + None => writeln!(writer, " source_offset: null")?, + } + match &segment.data_hex { + Some(data_hex) => writeln!(writer, " data_hex: {}", yaml_string(data_hex))?, + None => writeln!(writer, " data_hex: null")?, + } + } + } + } + writeln!(writer, "tracks:")?; + for track in &report.tracks { + writeln!(writer, "- track_id: {}", track.track_id)?; + writeln!(writer, " kind: {}", yaml_string(&track.kind))?; + writeln!(writer, " timescale: {}", track.timescale)?; + writeln!(writer, " language: {}", yaml_string(&track.language))?; + writeln!( + writer, + " handler_name: {}", + yaml_string(&track.handler_name) + )?; + writeln!( + writer, + " sample_entry_type: {}", + yaml_string(&track.sample_entry_type) + )?; + writeln!( + writer, + " sample_entry_box_hex: {}", + yaml_string(&track.sample_entry_box_hex) + )?; + if let Some(width) = track.width { + writeln!(writer, " width: {}", width)?; + } + if let Some(height) = track.height { + writeln!(writer, " height: {}", height)?; + } + if let Some(edit_media_time) = track.source_edit_media_time { + writeln!(writer, " source_edit_media_time: {}", edit_media_time)?; + } + if let Some(sample_roll_distance) = track.sample_roll_distance { + writeln!(writer, " sample_roll_distance: {}", sample_roll_distance)?; + } + writeln!(writer, " sample_count: {}", track.sample_count)?; + writeln!(writer, " sync_sample_count: {}", track.sync_sample_count)?; + writeln!( + writer, + " starts_with_sync_sample: {}", + if track.starts_with_sync_sample { + "true" + } else { + "false" + } + )?; + writeln!(writer, " total_duration: {}", track.total_duration)?; + writeln!(writer, " total_payload_size: {}", track.total_payload_size)?; + match track.average_sample_size { + Some(average_sample_size) => { + writeln!(writer, " average_sample_size: {}", average_sample_size)? + } + None => writeln!(writer, " average_sample_size: null")?, + } + match track.minimum_sample_size { + Some(minimum_sample_size) => { + writeln!(writer, " minimum_sample_size: {}", minimum_sample_size)? + } + None => writeln!(writer, " minimum_sample_size: null")?, + } + match track.maximum_sample_size { + Some(maximum_sample_size) => { + writeln!(writer, " maximum_sample_size: {}", maximum_sample_size)? + } + None => writeln!(writer, " maximum_sample_size: null")?, + } + match track.minimum_sample_duration { + Some(minimum_sample_duration) => writeln!( + writer, + " minimum_sample_duration: {}", + minimum_sample_duration + )?, + None => writeln!(writer, " minimum_sample_duration: null")?, + } + match track.maximum_sample_duration { + Some(maximum_sample_duration) => writeln!( + writer, + " maximum_sample_duration: {}", + maximum_sample_duration + )?, + None => writeln!(writer, " maximum_sample_duration: null")?, + } + match track.average_bitrate_bits_per_second { + Some(average_bitrate_bits_per_second) => writeln!( + writer, + " average_bitrate_bits_per_second: {}", + average_bitrate_bits_per_second + )?, + None => writeln!(writer, " average_bitrate_bits_per_second: null")?, + } + match track.minimum_sync_sample_size { + Some(minimum_sync_sample_size) => writeln!( + writer, + " minimum_sync_sample_size: {}", + minimum_sync_sample_size + )?, + None => writeln!(writer, " minimum_sync_sample_size: null")?, + } + match track.maximum_sync_sample_size { + Some(maximum_sync_sample_size) => writeln!( + writer, + " maximum_sync_sample_size: {}", + maximum_sync_sample_size + )?, + None => writeln!(writer, " maximum_sync_sample_size: null")?, + } + match track.average_sync_sample_size { + Some(average_sync_sample_size) => writeln!( + writer, + " average_sync_sample_size: {}", + average_sync_sample_size + )?, + None => writeln!(writer, " average_sync_sample_size: null")?, + } + match track.average_non_sync_sample_size { + Some(average_non_sync_sample_size) => writeln!( + writer, + " average_non_sync_sample_size: {}", + average_non_sync_sample_size + )?, + None => writeln!(writer, " average_non_sync_sample_size: null")?, + } + match track.minimum_composition_time_offset { + Some(minimum_composition_time_offset) => writeln!( + writer, + " minimum_composition_time_offset: {}", + minimum_composition_time_offset + )?, + None => writeln!(writer, " minimum_composition_time_offset: null")?, + } + match track.maximum_composition_time_offset { + Some(maximum_composition_time_offset) => writeln!( + writer, + " maximum_composition_time_offset: {}", + maximum_composition_time_offset + )?, + None => writeln!(writer, " maximum_composition_time_offset: null")?, + } + match track.minimum_presentation_time { + Some(minimum_presentation_time) => writeln!( + writer, + " minimum_presentation_time: {}", + minimum_presentation_time + )?, + None => writeln!(writer, " minimum_presentation_time: null")?, + } + match track.maximum_presentation_end_time { + Some(maximum_presentation_end_time) => writeln!( + writer, + " maximum_presentation_end_time: {}", + maximum_presentation_end_time + )?, + None => writeln!(writer, " maximum_presentation_end_time: null")?, + } + match track.minimum_previous_decode_delta { + Some(minimum_previous_decode_delta) => writeln!( + writer, + " minimum_previous_decode_delta: {}", + minimum_previous_decode_delta + )?, + None => writeln!(writer, " minimum_previous_decode_delta: null")?, + } + match track.maximum_previous_decode_delta { + Some(maximum_previous_decode_delta) => writeln!( + writer, + " maximum_previous_decode_delta: {}", + maximum_previous_decode_delta + )?, + None => writeln!(writer, " maximum_previous_decode_delta: null")?, + } + match track.minimum_previous_presentation_delta { + Some(minimum_previous_presentation_delta) => writeln!( + writer, + " minimum_previous_presentation_delta: {}", + minimum_previous_presentation_delta + )?, + None => writeln!(writer, " minimum_previous_presentation_delta: null")?, + } + match track.maximum_previous_presentation_delta { + Some(maximum_previous_presentation_delta) => writeln!( + writer, + " maximum_previous_presentation_delta: {}", + maximum_previous_presentation_delta + )?, + None => writeln!(writer, " maximum_previous_presentation_delta: null")?, + } + writeln!( + writer, + " presentation_gap_count: {}", + track.presentation_gap_count + )?; + writeln!( + writer, + " presentation_overlap_count: {}", + track.presentation_overlap_count + )?; + writeln!( + writer, + " presentation_regression_count: {}", + track.presentation_regression_count + )?; + writeln!( + writer, + " duration_change_count: {}", + track.duration_change_count + )?; + writeln!( + writer, + " composition_time_offset_change_count: {}", + track.composition_time_offset_change_count + )?; + match track.minimum_sync_sample_distance { + Some(minimum_sync_sample_distance) => writeln!( + writer, + " minimum_sync_sample_distance: {}", + minimum_sync_sample_distance + )?, + None => writeln!(writer, " minimum_sync_sample_distance: null")?, + } + match track.maximum_sync_sample_distance { + Some(maximum_sync_sample_distance) => writeln!( + writer, + " maximum_sync_sample_distance: {}", + maximum_sync_sample_distance + )?, + None => writeln!(writer, " maximum_sync_sample_distance: null")?, + } + match track.average_sync_sample_distance { + Some(average_sync_sample_distance) => writeln!( + writer, + " average_sync_sample_distance: {}", + average_sync_sample_distance + )?, + None => writeln!(writer, " average_sync_sample_distance: null")?, + } + match track.minimum_sync_sample_decode_delta { + Some(minimum_sync_sample_decode_delta) => writeln!( + writer, + " minimum_sync_sample_decode_delta: {}", + minimum_sync_sample_decode_delta + )?, + None => writeln!(writer, " minimum_sync_sample_decode_delta: null")?, + } + match track.maximum_sync_sample_decode_delta { + Some(maximum_sync_sample_decode_delta) => writeln!( + writer, + " maximum_sync_sample_decode_delta: {}", + maximum_sync_sample_decode_delta + )?, + None => writeln!(writer, " maximum_sync_sample_decode_delta: null")?, + } + match track.average_sync_sample_decode_delta { + Some(average_sync_sample_decode_delta) => writeln!( + writer, + " average_sync_sample_decode_delta: {}", + average_sync_sample_decode_delta + )?, + None => writeln!(writer, " average_sync_sample_decode_delta: null")?, + } + match track.first_sync_sample_index { + Some(first_sync_sample_index) => writeln!( + writer, + " first_sync_sample_index: {}", + first_sync_sample_index + )?, + None => writeln!(writer, " first_sync_sample_index: null")?, + } + match track.last_sync_sample_index { + Some(last_sync_sample_index) => writeln!( + writer, + " last_sync_sample_index: {}", + last_sync_sample_index + )?, + None => writeln!(writer, " last_sync_sample_index: null")?, + } + match track.first_sync_decode_time { + Some(first_sync_decode_time) => writeln!( + writer, + " first_sync_decode_time: {}", + first_sync_decode_time + )?, + None => writeln!(writer, " first_sync_decode_time: null")?, + } + match track.last_sync_decode_time { + Some(last_sync_decode_time) => { + writeln!(writer, " last_sync_decode_time: {}", last_sync_decode_time)? + } + None => writeln!(writer, " last_sync_decode_time: null")?, + } + match track.first_sync_presentation_time { + Some(first_sync_presentation_time) => writeln!( + writer, + " first_sync_presentation_time: {}", + first_sync_presentation_time + )?, + None => writeln!(writer, " first_sync_presentation_time: null")?, + } + match track.last_sync_presentation_time { + Some(last_sync_presentation_time) => writeln!( + writer, + " last_sync_presentation_time: {}", + last_sync_presentation_time + )?, + None => writeln!(writer, " last_sync_presentation_time: null")?, + } + writeln!(writer, " first_decode_time: {}", track.first_decode_time)?; + writeln!(writer, " end_decode_time: {}", track.end_decode_time)?; + writeln!(writer, " samples:")?; + for sample in &track.samples { + writeln!(writer, " - source_index: {}", sample.source_index)?; + writeln!(writer, " data_offset: {}", sample.data_offset)?; + writeln!(writer, " data_size: {}", sample.data_size)?; + writeln!(writer, " decode_time: {}", sample.decode_time)?; + match sample.previous_decode_delta { + Some(previous_decode_delta) => writeln!( + writer, + " previous_decode_delta: {}", + previous_decode_delta + )?, + None => writeln!(writer, " previous_decode_delta: null")?, + } + writeln!( + writer, + " composition_time_offset: {}", + sample.composition_time_offset + )?; + writeln!( + writer, + " presentation_time: {}", + sample.presentation_time + )?; + writeln!( + writer, + " presentation_end_time: {}", + sample.presentation_end_time + )?; + match sample.previous_presentation_delta { + Some(previous_presentation_delta) => writeln!( + writer, + " previous_presentation_delta: {}", + previous_presentation_delta + )?, + None => writeln!(writer, " previous_presentation_delta: null")?, + } + writeln!(writer, " duration: {}", sample.duration)?; + writeln!( + writer, + " is_sync_sample: {}", + if sample.is_sync_sample { + "true" + } else { + "false" + } + )?; + } + } + Ok(()) +} + +fn write_yaml_packet_report(writer: &mut W, report: &DirectIngestPacketReport) -> io::Result<()> +where + W: Write, +{ + writeln!( + writer, + "input_path: {}", + yaml_string(&report.input_path.display().to_string()) + )?; + writeln!(writer, "detected_kind:")?; + writeln!( + writer, + " kind: {}", + yaml_string(detected_kind_name(&report.detected_kind)) + )?; + match &report.detected_kind { + DirectIngestDetectedKind::Container { container } => { + writeln!(writer, " container: {}", yaml_string(container))?; + } + DirectIngestDetectedKind::Raw { codec } => { + writeln!(writer, " codec: {}", yaml_string(codec))?; + } + DirectIngestDetectedKind::ImportOnly { family } => { + writeln!(writer, " family: {}", yaml_string(family))?; + } + DirectIngestDetectedKind::Mp4 | DirectIngestDetectedKind::Unknown => {} + } + writeln!( + writer, + "supports_flat_mux: {}", + if report.supports_flat_mux { + "true" + } else { + "false" + } + )?; + if let Some(note) = &report.note { + writeln!(writer, "note: {}", yaml_string(note))?; + } + writeln!(writer, "track_count: {}", report.track_count)?; + writeln!(writer, "packet_count: {}", report.packet_count)?; + writeln!(writer, "sync_packet_count: {}", report.sync_packet_count)?; + writeln!( + writer, + "starts_with_sync_packet: {}", + if report.starts_with_sync_packet { + "true" + } else { + "false" + } + )?; + writeln!(writer, "total_payload_size: {}", report.total_payload_size)?; + if let Some(minimum_packet_size) = report.minimum_packet_size { + writeln!(writer, "minimum_packet_size: {}", minimum_packet_size)?; + } + if let Some(maximum_packet_size) = report.maximum_packet_size { + writeln!(writer, "maximum_packet_size: {}", maximum_packet_size)?; + } + if let Some(minimum_sync_packet_size) = report.minimum_sync_packet_size { + writeln!( + writer, + "minimum_sync_packet_size: {}", + minimum_sync_packet_size + )?; + } + if let Some(maximum_sync_packet_size) = report.maximum_sync_packet_size { + writeln!( + writer, + "maximum_sync_packet_size: {}", + maximum_sync_packet_size + )?; + } + if let Some(average_sync_packet_size) = report.average_sync_packet_size { + writeln!( + writer, + "average_sync_packet_size: {}", + average_sync_packet_size + )?; + } + if let Some(average_non_sync_packet_size) = report.average_non_sync_packet_size { + writeln!( + writer, + "average_non_sync_packet_size: {}", + average_non_sync_packet_size + )?; + } + if let Some(minimum_packet_duration) = report.minimum_packet_duration { + writeln!( + writer, + "minimum_packet_duration: {}", + minimum_packet_duration + )?; + } + if let Some(maximum_packet_duration) = report.maximum_packet_duration { + writeln!( + writer, + "maximum_packet_duration: {}", + maximum_packet_duration + )?; + } + if let Some(minimum_previous_decode_delta) = report.minimum_previous_decode_delta { + writeln!( + writer, + "minimum_previous_decode_delta: {}", + minimum_previous_decode_delta + )?; + } + if let Some(maximum_previous_decode_delta) = report.maximum_previous_decode_delta { + writeln!( + writer, + "maximum_previous_decode_delta: {}", + maximum_previous_decode_delta + )?; + } + if let Some(minimum_composition_time_offset) = report.minimum_composition_time_offset { + writeln!( + writer, + "minimum_composition_time_offset: {}", + minimum_composition_time_offset + )?; + } + if let Some(maximum_composition_time_offset) = report.maximum_composition_time_offset { + writeln!( + writer, + "maximum_composition_time_offset: {}", + maximum_composition_time_offset + )?; + } + if let Some(minimum_presentation_time) = report.minimum_presentation_time { + writeln!( + writer, + "minimum_presentation_time: {}", + minimum_presentation_time + )?; + } + if let Some(maximum_presentation_end_time) = report.maximum_presentation_end_time { + writeln!( + writer, + "maximum_presentation_end_time: {}", + maximum_presentation_end_time + )?; + } + if let Some(minimum_previous_presentation_delta) = report.minimum_previous_presentation_delta { + writeln!( + writer, + "minimum_previous_presentation_delta: {}", + minimum_previous_presentation_delta + )?; + } + if let Some(maximum_previous_presentation_delta) = report.maximum_previous_presentation_delta { + writeln!( + writer, + "maximum_previous_presentation_delta: {}", + maximum_previous_presentation_delta + )?; + } + writeln!( + writer, + "presentation_gap_count: {}", + report.presentation_gap_count + )?; + writeln!( + writer, + "presentation_overlap_count: {}", + report.presentation_overlap_count + )?; + writeln!( + writer, + "presentation_regression_count: {}", + report.presentation_regression_count + )?; + writeln!( + writer, + "duration_change_count: {}", + report.duration_change_count + )?; + writeln!( + writer, + "composition_time_offset_change_count: {}", + report.composition_time_offset_change_count + )?; + if let Some(minimum_sync_packet_distance) = report.minimum_sync_packet_distance { + writeln!( + writer, + "minimum_sync_packet_distance: {}", + minimum_sync_packet_distance + )?; + } + if let Some(maximum_sync_packet_distance) = report.maximum_sync_packet_distance { + writeln!( + writer, + "maximum_sync_packet_distance: {}", + maximum_sync_packet_distance + )?; + } + if let Some(average_sync_packet_distance) = report.average_sync_packet_distance { + writeln!( + writer, + "average_sync_packet_distance: {}", + average_sync_packet_distance + )?; + } + if let Some(minimum_sync_packet_decode_delta) = report.minimum_sync_packet_decode_delta { + writeln!( + writer, + "minimum_sync_packet_decode_delta: {}", + minimum_sync_packet_decode_delta + )?; + } + if let Some(maximum_sync_packet_decode_delta) = report.maximum_sync_packet_decode_delta { + writeln!( + writer, + "maximum_sync_packet_decode_delta: {}", + maximum_sync_packet_decode_delta + )?; + } + if let Some(average_sync_packet_decode_delta) = report.average_sync_packet_decode_delta { + writeln!( + writer, + "average_sync_packet_decode_delta: {}", + average_sync_packet_decode_delta + )?; + } + match report.first_sync_packet_track_id { + Some(first_sync_packet_track_id) => writeln!( + writer, + "first_sync_packet_track_id: {}", + first_sync_packet_track_id + )?, + None => writeln!(writer, "first_sync_packet_track_id: null")?, + } + match report.first_sync_packet_index { + Some(first_sync_packet_index) => writeln!( + writer, + "first_sync_packet_index: {}", + first_sync_packet_index + )?, + None => writeln!(writer, "first_sync_packet_index: null")?, + } + match report.last_sync_packet_track_id { + Some(last_sync_packet_track_id) => writeln!( + writer, + "last_sync_packet_track_id: {}", + last_sync_packet_track_id + )?, + None => writeln!(writer, "last_sync_packet_track_id: null")?, + } + match report.last_sync_packet_index { + Some(last_sync_packet_index) => { + writeln!(writer, "last_sync_packet_index: {}", last_sync_packet_index)? + } + None => writeln!(writer, "last_sync_packet_index: null")?, + } + match report.first_sync_decode_time { + Some(first_sync_decode_time) => { + writeln!(writer, "first_sync_decode_time: {}", first_sync_decode_time)? + } + None => writeln!(writer, "first_sync_decode_time: null")?, + } + match report.last_sync_decode_time { + Some(last_sync_decode_time) => { + writeln!(writer, "last_sync_decode_time: {}", last_sync_decode_time)? + } + None => writeln!(writer, "last_sync_decode_time: null")?, + } + match report.first_sync_presentation_time { + Some(first_sync_presentation_time) => writeln!( + writer, + "first_sync_presentation_time: {}", + first_sync_presentation_time + )?, + None => writeln!(writer, "first_sync_presentation_time: null")?, + } + match report.last_sync_presentation_time { + Some(last_sync_presentation_time) => writeln!( + writer, + "last_sync_presentation_time: {}", + last_sync_presentation_time + )?, + None => writeln!(writer, "last_sync_presentation_time: null")?, + } + writeln!(writer, "staged_sources:")?; + for source in &report.staged_sources { + writeln!(writer, "- source_index: {}", source.source_index)?; + writeln!( + writer, + " path: {}", + yaml_string(&source.path.display().to_string()) + )?; + writeln!( + writer, + " segmented: {}", + if source.segmented { "true" } else { "false" } + )?; + writeln!(writer, " total_size: {}", source.total_size)?; + if let Some(segment_count) = source.segment_count { + writeln!(writer, " segment_count: {}", segment_count)?; + } + if let Some(segments) = &source.segments + && !segments.is_empty() + { + writeln!(writer, " segments:")?; + for segment in segments { + writeln!(writer, " - kind: {}", yaml_string(&segment.kind))?; + writeln!(writer, " logical_offset: {}", segment.logical_offset)?; + writeln!(writer, " logical_size: {}", segment.logical_size)?; + match segment.source_offset { + Some(source_offset) => { + writeln!(writer, " source_offset: {}", source_offset)? + } + None => writeln!(writer, " source_offset: null")?, + } + match &segment.data_hex { + Some(data_hex) => writeln!(writer, " data_hex: {}", yaml_string(data_hex))?, + None => writeln!(writer, " data_hex: null")?, + } + } + } + } + writeln!(writer, "packets:")?; + for packet in &report.packets { + writeln!(writer, "- track_id: {}", packet.track_id)?; + writeln!(writer, " packet_index: {}", packet.packet_index)?; + writeln!(writer, " track_kind: {}", yaml_string(&packet.track_kind))?; + writeln!(writer, " timescale: {}", packet.timescale)?; + writeln!( + writer, + " sample_entry_type: {}", + yaml_string(&packet.sample_entry_type) + )?; + writeln!(writer, " source_index: {}", packet.source_index)?; + writeln!(writer, " data_offset: {}", packet.data_offset)?; + writeln!(writer, " data_size: {}", packet.data_size)?; + writeln!(writer, " decode_time: {}", packet.decode_time)?; + writeln!( + writer, + " composition_time_offset: {}", + packet.composition_time_offset + )?; + writeln!(writer, " presentation_time: {}", packet.presentation_time)?; + writeln!( + writer, + " presentation_end_time: {}", + packet.presentation_end_time + )?; + match packet.previous_presentation_delta { + Some(previous_presentation_delta) => writeln!( + writer, + " previous_presentation_delta: {}", + previous_presentation_delta + )?, + None => writeln!(writer, " previous_presentation_delta: null")?, + } + writeln!(writer, " duration: {}", packet.duration)?; + match packet.previous_decode_delta { + Some(previous_decode_delta) => { + writeln!(writer, " previous_decode_delta: {}", previous_decode_delta)?; + } + None => { + writeln!(writer, " previous_decode_delta: null")?; + } + } + writeln!(writer, " payload_crc32: {}", packet.payload_crc32)?; + writeln!( + writer, + " is_sync_sample: {}", + if packet.is_sync_sample { + "true" + } else { + "false" + } + )?; + } + Ok(()) +} + +fn write_nhml_report(writer: &mut W, report: &DirectIngestReport) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "")?; + write!( + writer, + " { + write!(writer, " container=\"{}\"", xml_escape_attr(container))?; + } + DirectIngestDetectedKind::Raw { codec } => { + write!(writer, " codec=\"{}\"", xml_escape_attr(codec))?; + } + DirectIngestDetectedKind::ImportOnly { family } => { + write!(writer, " family=\"{}\"", xml_escape_attr(family))?; + } + DirectIngestDetectedKind::Mp4 | DirectIngestDetectedKind::Unknown => {} + } + if let Some(note) = &report.note { + write!(writer, " note=\"{}\"", xml_escape_attr(note))?; + } + writeln!(writer, ">")?; + for source in &report.staged_sources { + write!( + writer, + " ")?; + for segment in segments { + write!( + writer, + " ")?; + } + writeln!(writer, " ")?; + } else { + writeln!(writer, " />")?; + } + } + for track in &report.tracks { + write!( + writer, + " ")?; + for sample in &track.samples { + writeln!( + writer, + " ", + sample.source_index, + sample.data_offset, + sample.data_size, + sample.decode_time, + sample + .previous_decode_delta + .map(|value| format!(" previousDecodeDelta=\"{value}\"")) + .unwrap_or_default(), + sample.composition_time_offset, + sample.presentation_time, + sample.presentation_end_time, + sample + .previous_presentation_delta + .map(|value| format!(" previousPresentationDelta=\"{value}\"")) + .unwrap_or_default(), + sample.duration, + if sample.is_sync_sample { + "true" + } else { + "false" + } + )?; + } + writeln!(writer, " ")?; + } + writeln!(writer, "") +} + +fn write_nhnt_report(writer: &mut W, report: &DirectIngestPacketReport) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "")?; + write!( + writer, + " { + write!(writer, " container=\"{}\"", xml_escape_attr(container))?; + } + DirectIngestDetectedKind::Raw { codec } => { + write!(writer, " codec=\"{}\"", xml_escape_attr(codec))?; + } + DirectIngestDetectedKind::ImportOnly { family } => { + write!(writer, " family=\"{}\"", xml_escape_attr(family))?; + } + DirectIngestDetectedKind::Mp4 | DirectIngestDetectedKind::Unknown => {} + } + if let Some(note) = &report.note { + write!(writer, " note=\"{}\"", xml_escape_attr(note))?; + } + if let Some(minimum_packet_size) = report.minimum_packet_size { + write!(writer, " minimumPacketSize=\"{}\"", minimum_packet_size)?; + } + if let Some(maximum_packet_size) = report.maximum_packet_size { + write!(writer, " maximumPacketSize=\"{}\"", maximum_packet_size)?; + } + if let Some(minimum_sync_packet_size) = report.minimum_sync_packet_size { + write!( + writer, + " minimumSyncPacketSize=\"{}\"", + minimum_sync_packet_size + )?; + } + if let Some(maximum_sync_packet_size) = report.maximum_sync_packet_size { + write!( + writer, + " maximumSyncPacketSize=\"{}\"", + maximum_sync_packet_size + )?; + } + if let Some(average_sync_packet_size) = report.average_sync_packet_size { + write!( + writer, + " averageSyncPacketSize=\"{}\"", + average_sync_packet_size + )?; + } + if let Some(average_non_sync_packet_size) = report.average_non_sync_packet_size { + write!( + writer, + " averageNonSyncPacketSize=\"{}\"", + average_non_sync_packet_size + )?; + } + if let Some(minimum_packet_duration) = report.minimum_packet_duration { + write!( + writer, + " minimumPacketDuration=\"{}\"", + minimum_packet_duration + )?; + } + if let Some(maximum_packet_duration) = report.maximum_packet_duration { + write!( + writer, + " maximumPacketDuration=\"{}\"", + maximum_packet_duration + )?; + } + if let Some(minimum_previous_decode_delta) = report.minimum_previous_decode_delta { + write!( + writer, + " minimumPreviousDecodeDelta=\"{}\"", + minimum_previous_decode_delta + )?; + } + if let Some(maximum_previous_decode_delta) = report.maximum_previous_decode_delta { + write!( + writer, + " maximumPreviousDecodeDelta=\"{}\"", + maximum_previous_decode_delta + )?; + } + if let Some(minimum_composition_time_offset) = report.minimum_composition_time_offset { + write!( + writer, + " minimumCompositionTimeOffset=\"{}\"", + minimum_composition_time_offset + )?; + } + if let Some(maximum_composition_time_offset) = report.maximum_composition_time_offset { + write!( + writer, + " maximumCompositionTimeOffset=\"{}\"", + maximum_composition_time_offset + )?; + } + if let Some(minimum_presentation_time) = report.minimum_presentation_time { + write!( + writer, + " minimumPresentationTime=\"{}\"", + minimum_presentation_time + )?; + } + if let Some(maximum_presentation_end_time) = report.maximum_presentation_end_time { + write!( + writer, + " maximumPresentationEndTime=\"{}\"", + maximum_presentation_end_time + )?; + } + if let Some(minimum_previous_presentation_delta) = report.minimum_previous_presentation_delta { + write!( + writer, + " minimumPreviousPresentationDelta=\"{}\"", + minimum_previous_presentation_delta + )?; + } + if let Some(maximum_previous_presentation_delta) = report.maximum_previous_presentation_delta { + write!( + writer, + " maximumPreviousPresentationDelta=\"{}\"", + maximum_previous_presentation_delta + )?; + } + write!( + writer, + " presentationGapCount=\"{}\" presentationOverlapCount=\"{}\" presentationRegressionCount=\"{}\" durationChangeCount=\"{}\" compositionTimeOffsetChangeCount=\"{}\"", + report.presentation_gap_count, + report.presentation_overlap_count, + report.presentation_regression_count, + report.duration_change_count, + report.composition_time_offset_change_count + )?; + if let Some(minimum_sync_packet_distance) = report.minimum_sync_packet_distance { + write!( + writer, + " minimumSyncPacketDistance=\"{}\"", + minimum_sync_packet_distance + )?; + } + if let Some(maximum_sync_packet_distance) = report.maximum_sync_packet_distance { + write!( + writer, + " maximumSyncPacketDistance=\"{}\"", + maximum_sync_packet_distance + )?; + } + if let Some(average_sync_packet_distance) = report.average_sync_packet_distance { + write!( + writer, + " averageSyncPacketDistance=\"{}\"", + average_sync_packet_distance + )?; + } + if let Some(minimum_sync_packet_decode_delta) = report.minimum_sync_packet_decode_delta { + write!( + writer, + " minimumSyncPacketDecodeDelta=\"{}\"", + minimum_sync_packet_decode_delta + )?; + } + if let Some(maximum_sync_packet_decode_delta) = report.maximum_sync_packet_decode_delta { + write!( + writer, + " maximumSyncPacketDecodeDelta=\"{}\"", + maximum_sync_packet_decode_delta + )?; + } + if let Some(average_sync_packet_decode_delta) = report.average_sync_packet_decode_delta { + write!( + writer, + " averageSyncPacketDecodeDelta=\"{}\"", + average_sync_packet_decode_delta + )?; + } + if let Some(first_sync_packet_track_id) = report.first_sync_packet_track_id { + write!( + writer, + " firstSyncPacketTrackID=\"{}\"", + first_sync_packet_track_id + )?; + } + if let Some(first_sync_packet_index) = report.first_sync_packet_index { + write!( + writer, + " firstSyncPacketIndex=\"{}\"", + first_sync_packet_index + )?; + } + if let Some(last_sync_packet_track_id) = report.last_sync_packet_track_id { + write!( + writer, + " lastSyncPacketTrackID=\"{}\"", + last_sync_packet_track_id + )?; + } + if let Some(last_sync_packet_index) = report.last_sync_packet_index { + write!( + writer, + " lastSyncPacketIndex=\"{}\"", + last_sync_packet_index + )?; + } + if let Some(first_sync_decode_time) = report.first_sync_decode_time { + write!( + writer, + " firstSyncDecodeTime=\"{}\"", + first_sync_decode_time + )?; + } + if let Some(last_sync_decode_time) = report.last_sync_decode_time { + write!(writer, " lastSyncDecodeTime=\"{}\"", last_sync_decode_time)?; + } + if let Some(first_sync_presentation_time) = report.first_sync_presentation_time { + write!( + writer, + " firstSyncPresentationTime=\"{}\"", + first_sync_presentation_time + )?; + } + if let Some(last_sync_presentation_time) = report.last_sync_presentation_time { + write!( + writer, + " lastSyncPresentationTime=\"{}\"", + last_sync_presentation_time + )?; + } + writeln!(writer, ">")?; + for source in &report.staged_sources { + write!( + writer, + " ")?; + for segment in segments { + write!( + writer, + " ")?; + } + writeln!(writer, " ")?; + } else { + writeln!(writer, " />")?; + } + } + for track in &report.tracks { + write!( + writer, + " ", + track.sample_count, + track.sync_sample_count, + track.total_duration, + track.total_payload_size + )?; + } + for packet in &report.packets { + writeln!( + writer, + " ", + packet.track_id, + packet.packet_index, + xml_escape_attr(&packet.track_kind), + packet.timescale, + xml_escape_attr(&packet.sample_entry_type), + packet.source_index, + packet.data_offset, + packet.data_size, + packet.decode_time, + packet.composition_time_offset, + packet.presentation_time, + packet.presentation_end_time, + packet + .previous_presentation_delta + .map(|value| format!(" previousPresentationDelta=\"{value}\"")) + .unwrap_or_default(), + packet.duration, + packet + .previous_decode_delta + .map(|value| format!(" previousDecodeDelta=\"{value}\"")) + .unwrap_or_default(), + packet.payload_crc32, + if packet.is_sync_sample { + "true" + } else { + "false" + } + )?; + } + writeln!(writer, "") +} + +fn json_string(value: &str) -> String { + let mut escaped = String::with_capacity(value.len() + 2); + escaped.push('"'); + for ch in value.chars() { + match ch { + '"' => escaped.push_str("\\\""), + '\\' => escaped.push_str("\\\\"), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + ch if ch.is_control() => { + use std::fmt::Write as _; + let _ = write!(escaped, "\\u{:04X}", ch as u32); + } + ch => escaped.push(ch), + } + } + escaped.push('"'); + escaped +} + +fn yaml_string(value: &str) -> String { + if value.is_empty() + || value.starts_with(|ch: char| { + ch.is_whitespace() || matches!(ch, '-' | '?' | ':' | '[' | ']' | '{' | '}' | ',') + }) + || value.ends_with(char::is_whitespace) + || value.contains(|ch: char| { + ch.is_control() || matches!(ch, ':' | '#' | '"' | '\'' | '\n' | '\r' | '\t') + }) + { + let mut escaped = String::with_capacity(value.len() + 2); + escaped.push('"'); + for ch in value.chars() { + match ch { + '"' => escaped.push_str("\\\""), + '\\' => escaped.push_str("\\\\"), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + ch => escaped.push(ch), + } + } + escaped.push('"'); + escaped + } else { + value.to_string() + } +} + +fn xml_escape_attr(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + ch if ch.is_control() && !matches!(ch, '\n' | '\r' | '\t') => {} + ch => escaped.push(ch), + } + } + escaped +} diff --git a/src/mux/mod.rs b/src/mux/mod.rs new file mode 100644 index 0000000..29788dc --- /dev/null +++ b/src/mux/mod.rs @@ -0,0 +1,3704 @@ +//! Feature-gated mux planning, real MP4 container assembly, and sample-reader helpers. +//! +//! The additive `mux` feature exposes two layers: +//! - low-level staged media-item planning plus payload-copy helpers +//! - higher-level real MP4 mux helpers that assemble `ftyp`, `moov`, and `mdat` +//! +//! Internally, both layers build on one mux event graph that carries stream descriptions, ordered +//! sample events, and boundary events. The task-level sample-reader helpers live under +//! [`crate::mux::sample_reader`], the public direct-ingest inspection and export helpers plus the +//! additive packet-focused report surface live under [`crate::mux::inspect`], the public +//! elementary sample rewrite helpers and elementary export helpers live under +//! [`crate::mux::rewrite`], and the real file-backed mux surface builds actual MP4 container +//! output on top of the same internal event flow. + +use std::collections::BTreeMap; +use std::error::Error; +use std::fmt; +use std::fs::File; +use std::io::{self, BufWriter, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; + +use crate::FourCc; +#[cfg(feature = "async")] +use crate::async_io::{AsyncReadForward, AsyncReadSeek, AsyncWrite, AsyncWriteForward}; +use crate::codec::CodecError; +use crate::header::HeaderError; +use crate::queue::{OrderedWorkQueue, QueueWorkItem}; +use crate::writer::WriterError; + +mod coordination; +mod demux; +pub(crate) mod event; +mod import; +/// Feature-gated direct-ingest inspection and export helpers built on native mux parsing. +#[cfg_attr(docsrs, doc(cfg(feature = "mux")))] +pub mod inspect; +mod mp4; +/// Feature-gated elementary sample rewrite helpers built on landed mux codec logic. +#[cfg_attr(docsrs, doc(cfg(feature = "mux")))] +pub mod rewrite; +/// Feature-gated planned sample-reader helpers built on mux plans. +#[cfg_attr(docsrs, doc(cfg(feature = "mux")))] +pub mod sample_reader; + +use coordination::MuxCoordinationPlan; +pub(crate) use coordination::{ + MuxDurationBoundaryKind, TrackCoordinationDirective, build_capped_duration_chunk_sample_counts, + build_duration_chunk_sample_counts, build_duration_chunk_sample_counts_with_start_time, + build_fragmented_duration_chunk_sample_counts_with_start_time, + build_sync_aligned_fragmented_duration_chunk_sample_counts, + build_sync_aligned_segment_chunk_sample_counts, + rebalance_small_multi_audio_chunk_sample_counts, +}; +pub(crate) use event::{MuxEventCursor, MuxEventGraph, MuxSampleEvent}; +pub use import::mux_fragmented_to_paths; +#[cfg(feature = "async")] +pub use import::mux_fragmented_to_paths_async; +pub use import::mux_into_path; +#[cfg(feature = "async")] +pub use import::mux_into_path_async; +pub use import::mux_to_path; +#[cfg(feature = "async")] +pub use import::mux_to_path_async; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum MuxRawCodec { + /// AV1 elementary input. + Av1, + /// MPEG-2 elementary video input. + Mpeg2v, + /// MPEG-4 Part 2 elementary input. + Mp4v, + /// H.263 elementary input. + H263, + /// H.264 or AVC elementary input. + H264, + /// H.265 or HEVC elementary input. + H265, + /// H.266 or VVC elementary input. + Vvc, + /// VP8 elementary input. + Vp8, + /// VP9 elementary input. + Vp9, + /// VP10 elementary input. + Vp10, + /// AAC input. + Aac, + /// AAC LATM input. + Latm, + /// MP3 input. + Mp3, + /// AC-3 input. + Ac3, + /// E-AC-3 input. + Eac3, + /// AC-4 input. + Ac4, + /// AMR narrowband input. + Amr, + /// AMR wideband input. + AmrWb, + /// QCP-wrapped voice input carrying QCELP, EVRC, or SMV frames. + Qcp, + /// JPEG still-image input. + Jpeg, + /// PNG still-image input. + Png, + /// BMP still-image input. + Bmp, + /// Raw ProRes input. + Prores, + /// Self-describing YUV4MPEG input. + Y4m, + /// JPEG 2000 image or codestream input. + J2k, + /// WAVE or PCM input. + Pcm, + /// DTS core input. + Dts, + /// Dolby TrueHD input. + Truehd, + /// ALAC input. + Alac, + /// FLAC input. + Flac, + /// IAMF elementary input. + Iamf, + /// MPEG-H AudioMux input. + MpegH, + /// Opus input. + Opus, + /// Vorbis input. + Vorbis, + /// Speex input. + Speex, + /// Theora input. + Theora, +} + +impl MuxRawCodec { + pub const fn prefix(&self) -> &'static str { + match self { + Self::Av1 => "av1", + Self::Mpeg2v => "mpeg2v", + Self::Mp4v => "mp4v", + Self::H263 => "h263", + Self::H264 => "h264", + Self::H265 => "h265", + Self::Vvc => "vvc", + Self::Vp8 => "vp8", + Self::Vp9 => "vp9", + Self::Vp10 => "vp10", + Self::Aac => "aac", + Self::Latm => "latm", + Self::Mp3 => "mp3", + Self::Ac3 => "ac3", + Self::Eac3 => "ec3", + Self::Ac4 => "ac4", + Self::Amr => "amr", + Self::AmrWb => "amr-wb", + Self::Qcp => "qcp", + Self::Jpeg => "jpeg", + Self::Png => "png", + Self::Bmp => "bmp", + Self::Prores => "prores", + Self::Y4m => "y4m", + Self::J2k => "j2k", + Self::Pcm => "pcm", + Self::Dts => "dts", + Self::Truehd => "truehd", + Self::Alac => "alac", + Self::Flac => "flac", + Self::Iamf => "iamf", + Self::MpegH => "mhas", + Self::Opus => "opus", + Self::Vorbis => "vorbis", + Self::Speex => "speex", + Self::Theora => "theora", + } + } +} + +/// One MP4-side track selector accepted by widened `mux` track specs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MuxMp4TrackSelector { + /// Select the first video track from one MP4 source. + Video, + /// Select one audio track occurrence from one MP4 source. + /// + /// The occurrence index is one-based in the public surface, so `1` means the first audio + /// track in file order and `2` means the second. + Audio { occurrence: u32 }, + /// Select one text-track occurrence from one MP4 source. + /// + /// The occurrence index is one-based in the public surface. + Text { occurrence: u32 }, + /// Select one specific track identifier from one MP4 source. + TrackId { track_id: u32 }, +} + +/// One validated public track specification for the mux task surface. +/// +/// The current path-first `mux` grammar uses one repeated track-spec model for both CLI and +/// library callers: +/// - path-only imports: `PATH` +/// - path plus selector: `PATH#video`, `PATH#audio`, `PATH#audio:N`, `PATH#text`, +/// `PATH#text:N`, `PATH#track:ID` +/// - explicit bare raw-video imports: `PATH#rawvideo:size=WIDTHxHEIGHT,spfmt=PIXFMT,fps=NUM/DEN` +/// +/// The raw-video form is intentionally explicit. Unlike self-describing YUV4MPEG streams, bare +/// raw video needs out-of-band geometry, pixel-format, and frame-rate metadata before `mp4forge` +/// can author a truthful `uncv` sample entry. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MuxRawVideoPixelFormat { + /// Planar 8-bit YUV 4:2:0. + Yuv420p8, + /// Planar 8-bit YVU 4:2:0. + Yvu420p8, + /// Planar 10-bit YUV 4:2:0 stored in 16-bit words. + Yuv420p10, + /// Planar 8-bit YUV 4:2:2. + Yuv422p8, + /// Planar 10-bit YUV 4:2:2 stored in 16-bit words. + Yuv422p10, + /// Planar 8-bit YUV 4:4:4. + Yuv444p8, + /// Planar 10-bit YUV 4:4:4 stored in 16-bit words. + Yuv444p10, + /// Planar 8-bit YUV 4:2:0 with alpha. + Yuva420p8, + /// Planar 8-bit YUV 4:2:0 with depth. + Yuvd420p8, + /// Planar 8-bit YUV 4:4:4 with alpha. + Yuva444p8, + /// Semi-planar 8-bit NV12. + Nv12p8, + /// Semi-planar 8-bit NV21. + Nv21p8, + /// Semi-planar 10-bit NV12 stored in 16-bit words. + Nv12p10, + /// Semi-planar 10-bit NV21 stored in 16-bit words. + Nv21p10, + /// Packed 8-bit UYVY 4:2:2. + Uyvy422p8, + /// Packed 8-bit VYUY 4:2:2. + Vyuy422p8, + /// Packed 8-bit YUYV 4:2:2. + Yuyv422p8, + /// Packed 8-bit YVYU 4:2:2. + Yvyu422p8, + /// Packed 10-bit UYVY 4:2:2 stored in 16-bit words. + Uyvy422p10, + /// Packed 10-bit VYUY 4:2:2 stored in 16-bit words. + Vyuy422p10, + /// Packed 10-bit YUYV 4:2:2 stored in 16-bit words. + Yuyv422p10, + /// Packed 10-bit YVYU 4:2:2 stored in 16-bit words. + Yvyu422p10, + /// Packed 8-bit YUV 4:4:4. + Yuv444Packed8, + /// Packed 8-bit VYU 4:4:4. + Vyu444Packed8, + /// Packed 8-bit YUV 4:4:4 with alpha. + Yuva444Packed8, + /// Packed 8-bit UYV 4:4:4 with alpha. + Uyva444Packed8, + /// Packed 10-bit UYV 4:4:4 little-endian. + Yuv444Packed10, + /// Packed 10-bit v210 4:2:2 little-endian. + V210, + /// 8-bit greyscale. + Grey8, + /// 8-bit alpha followed by 8-bit greyscale. + AlphaGrey8, + /// 8-bit greyscale followed by 8-bit alpha. + GreyAlpha8, + /// Packed RGB 3:3:2. + Rgb332, + /// Packed RGB 4:4:4 stored in 16 bits. + Rgb444, + /// Packed RGB 5:5:5 stored in 16 bits. + Rgb555, + /// Packed RGB 5:6:5 stored in 16 bits. + Rgb565, + /// Packed 24-bit RGB in byte order `R-G-B`. + Rgb24, + /// Packed 24-bit RGB in byte order `B-G-R`. + Bgr24, + /// Packed 32-bit RGB in byte order `R-G-B-X`. + Rgbx32, + /// Packed 32-bit RGB in byte order `B-G-R-X`. + Bgrx32, + /// Packed 32-bit RGB in byte order `X-R-G-B`. + Xrgb32, + /// Packed 32-bit RGB in byte order `X-B-G-R`. + Xbgr32, + /// Packed 32-bit RGBA in byte order `A-R-G-B`. + Argb32, + /// Packed 32-bit RGBA in byte order `R-G-B-A`. + Rgba32, + /// Packed 32-bit RGBA in byte order `B-G-R-A`. + Bgra32, + /// Packed 32-bit RGBA in byte order `A-B-G-R`. + Abgr32, + /// Packed 32-bit RGB with depth. + Rgbd32, + /// Packed 32-bit RGB with depth and bit-shape. + Rgbds32, +} + +impl MuxRawVideoPixelFormat { + /// Returns the canonical raw-video pixel-format label. + pub const fn canonical_name(self) -> &'static str { + match self { + Self::Yuv420p8 => "yuv420", + Self::Yvu420p8 => "yvu420", + Self::Yuv420p10 => "yuv420_10", + Self::Yuv422p8 => "yuv422", + Self::Yuv422p10 => "yuv422_10", + Self::Yuv444p8 => "yuv444", + Self::Yuv444p10 => "yuv444_10", + Self::Yuva420p8 => "yuva", + Self::Yuvd420p8 => "yuvd", + Self::Yuva444p8 => "yuv444a", + Self::Nv12p8 => "nv12", + Self::Nv21p8 => "nv21", + Self::Nv12p10 => "nv12_10", + Self::Nv21p10 => "nv21_10", + Self::Uyvy422p8 => "uyvy", + Self::Vyuy422p8 => "vyuy", + Self::Yuyv422p8 => "yuyv", + Self::Yvyu422p8 => "yvyu", + Self::Uyvy422p10 => "uyvl", + Self::Vyuy422p10 => "vyul", + Self::Yuyv422p10 => "yuyl", + Self::Yvyu422p10 => "yvyl", + Self::Yuv444Packed8 => "yuv444p", + Self::Vyu444Packed8 => "v308", + Self::Yuva444Packed8 => "yuv444ap", + Self::Uyva444Packed8 => "v408", + Self::Yuv444Packed10 => "v410", + Self::V210 => "v210", + Self::Grey8 => "grey", + Self::AlphaGrey8 => "algr", + Self::GreyAlpha8 => "gral", + Self::Rgb332 => "rgb8", + Self::Rgb444 => "rgb4", + Self::Rgb555 => "rgb5", + Self::Rgb565 => "rgb6", + Self::Rgb24 => "rgb", + Self::Bgr24 => "bgr", + Self::Rgbx32 => "rgbx", + Self::Bgrx32 => "bgrx", + Self::Xrgb32 => "xrgb", + Self::Xbgr32 => "xbgr", + Self::Argb32 => "argb", + Self::Rgba32 => "rgba", + Self::Bgra32 => "bgra", + Self::Abgr32 => "abgr", + Self::Rgbd32 => "rgbd", + Self::Rgbds32 => "rgbds", + } + } + + fn parse(spec: &str, value: &str) -> Result { + match value { + "yuv420" | "yuv" => Ok(Self::Yuv420p8), + "yvu420" | "yvu" => Ok(Self::Yvu420p8), + "yuv420_10" | "yuvl" => Ok(Self::Yuv420p10), + "yuv422" | "yuv2" => Ok(Self::Yuv422p8), + "yuv422_10" | "yp2l" => Ok(Self::Yuv422p10), + "yuv444" | "yuv4" => Ok(Self::Yuv444p8), + "yuv444_10" | "yp4l" => Ok(Self::Yuv444p10), + "yuva" => Ok(Self::Yuva420p8), + "yuvd" => Ok(Self::Yuvd420p8), + "yuv444a" | "yp4a" => Ok(Self::Yuva444p8), + "nv12" => Ok(Self::Nv12p8), + "nv21" => Ok(Self::Nv21p8), + "nv12_10" | "nv1l" => Ok(Self::Nv12p10), + "nv21_10" | "nv2l" => Ok(Self::Nv21p10), + "uyvy" => Ok(Self::Uyvy422p8), + "vyuy" => Ok(Self::Vyuy422p8), + "yuyv" => Ok(Self::Yuyv422p8), + "yvyu" => Ok(Self::Yvyu422p8), + "uyvl" => Ok(Self::Uyvy422p10), + "vyul" => Ok(Self::Vyuy422p10), + "yuyl" => Ok(Self::Yuyv422p10), + "yvyl" => Ok(Self::Yvyu422p10), + "yuv444p" | "yv4p" => Ok(Self::Yuv444Packed8), + "v308" => Ok(Self::Vyu444Packed8), + "yuv444ap" | "y4ap" => Ok(Self::Yuva444Packed8), + "v408" => Ok(Self::Uyva444Packed8), + "v410" => Ok(Self::Yuv444Packed10), + "v210" => Ok(Self::V210), + "grey" => Ok(Self::Grey8), + "algr" => Ok(Self::AlphaGrey8), + "gral" => Ok(Self::GreyAlpha8), + "rgb8" => Ok(Self::Rgb332), + "rgb4" => Ok(Self::Rgb444), + "rgb5" => Ok(Self::Rgb555), + "rgb6" => Ok(Self::Rgb565), + "rgb" => Ok(Self::Rgb24), + "bgr" => Ok(Self::Bgr24), + "rgbx" => Ok(Self::Rgbx32), + "bgrx" => Ok(Self::Bgrx32), + "xrgb" => Ok(Self::Xrgb32), + "xbgr" => Ok(Self::Xbgr32), + "argb" => Ok(Self::Argb32), + "rgba" => Ok(Self::Rgba32), + "bgra" => Ok(Self::Bgra32), + "abgr" => Ok(Self::Abgr32), + "rgbd" => Ok(Self::Rgbd32), + "rgbds" => Ok(Self::Rgbds32), + _ => Err(MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: format!( + "unsupported rawvideo `spfmt={value}`; expected one of the rawvideo pixel formats supported by mp4forge" + ), + }), + } + } +} + +/// One explicit bare raw-video import description for the mux surface. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MuxRawVideoParams { + width: u32, + height: u32, + pixel_format: MuxRawVideoPixelFormat, + fps_num: u32, + fps_den: u32, +} + +impl MuxRawVideoParams { + /// Validates one explicit raw-video import description. + pub fn new( + width: u32, + height: u32, + pixel_format: MuxRawVideoPixelFormat, + fps_num: u32, + fps_den: u32, + ) -> Result { + if width == 0 || height == 0 { + return Err(MuxError::InvalidTrackSpec { + spec: "rawvideo".to_string(), + message: "rawvideo `size` must declare non-zero width and height".to_string(), + }); + } + if fps_num == 0 || fps_den == 0 { + return Err(MuxError::InvalidTrackSpec { + spec: "rawvideo".to_string(), + message: "rawvideo `fps` must declare non-zero numerator and denominator" + .to_string(), + }); + } + Ok(Self { + width, + height, + pixel_format, + fps_num, + fps_den, + }) + } + + /// Returns the declared frame width in pixels. + pub const fn width(&self) -> u32 { + self.width + } + + /// Returns the declared frame height in pixels. + pub const fn height(&self) -> u32 { + self.height + } + + /// Returns the declared pixel format. + pub const fn pixel_format(&self) -> MuxRawVideoPixelFormat { + self.pixel_format + } + + /// Returns the declared frame-rate numerator. + pub const fn fps_num(&self) -> u32 { + self.fps_num + } + + /// Returns the declared frame-rate denominator. + pub const fn fps_den(&self) -> u32 { + self.fps_den + } + + fn format_suffix(&self) -> String { + format!( + "rawvideo:size={}x{},spfmt={},fps={}/{}", + self.width, + self.height, + self.pixel_format.canonical_name(), + self.fps_num, + self.fps_den + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum MuxTrackSpec { + /// Import one input path, optionally selecting one track when the source is containerized. + Path { + /// The filesystem path to import. + path: PathBuf, + /// The optional public selector to resolve inside that source. + selector: Option, + }, + /// Import one bare raw-video input using explicit out-of-band geometry and frame-rate data. + RawVideo { + /// The filesystem path to import. + path: PathBuf, + /// The explicit raw-video parameters. + params: MuxRawVideoParams, + }, +} + +impl MuxTrackSpec { + /// Creates one path-first track specification from `path`. + pub fn path(path: impl Into) -> Self { + Self::Path { + path: path.into(), + selector: None, + } + } + + /// Creates one path-first track specification from `path` and `selector`. + pub fn selected(path: impl Into, selector: MuxMp4TrackSelector) -> Self { + Self::Path { + path: path.into(), + selector: Some(selector), + } + } + + /// Creates one compatibility selected track specification from `path` and `selector`. + pub fn mp4(path: impl Into, selector: MuxMp4TrackSelector) -> Self { + Self::selected(path, selector) + } + + /// Creates one explicit bare raw-video track specification from `path` and `params`. + pub fn raw_video(path: impl Into, params: MuxRawVideoParams) -> Self { + Self::RawVideo { + path: path.into(), + params, + } + } + + /// Returns the filesystem path referenced by this track specification. + pub fn input_path(&self) -> &Path { + match self { + Self::Path { path, .. } => path.as_path(), + Self::RawVideo { path, .. } => path.as_path(), + } + } +} + +impl FromStr for MuxTrackSpec { + type Err = MuxError; + + fn from_str(value: &str) -> Result { + if value.is_empty() { + return Err(MuxError::InvalidTrackSpec { + spec: value.to_string(), + message: "missing input path".to_string(), + }); + } + + if let Some((path, selector_text)) = value.rsplit_once('#') { + if path.is_empty() { + return Err(MuxError::InvalidTrackSpec { + spec: value.to_string(), + message: "missing input path before `#`".to_string(), + }); + } + if let Some(rawvideo_text) = selector_text.strip_prefix("rawvideo:") { + let params = parse_raw_video_params(value, rawvideo_text)?; + return Ok(Self::RawVideo { + path: PathBuf::from(path), + params, + }); + } + let selector = parse_mp4_track_selector(value, selector_text)?; + return Ok(Self::Path { + path: PathBuf::from(path), + selector: Some(selector), + }); + } + + Ok(Self::path(value)) + } +} + +fn parse_mp4_track_selector(spec: &str, selector: &str) -> Result { + if selector.is_empty() { + return Err(MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: + "expected one selector after `#`, such as `video`, `audio`, `text`, or `track:ID`" + .to_string(), + }); + } + if selector.contains('=') || selector.contains(',') { + return Err(MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: "public mux track specs only allow selector suffixes such as `#video`, `#audio`, `#text`, or `#track:ID`; raw `#name=value` parameters are no longer accepted".to_string(), + }); + } + if selector == "video" { + return Ok(MuxMp4TrackSelector::Video); + } + if selector == "audio" { + return Ok(MuxMp4TrackSelector::Audio { occurrence: 1 }); + } + if selector == "text" { + return Ok(MuxMp4TrackSelector::Text { occurrence: 1 }); + } + if let Some(index) = selector.strip_prefix("audio:") { + let occurrence = index + .parse::() + .map_err(|_| MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: format!("invalid audio occurrence `{index}`"), + })?; + if occurrence == 0 { + return Err(MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: "audio occurrences are one-based; `audio:0` is invalid".to_string(), + }); + } + return Ok(MuxMp4TrackSelector::Audio { occurrence }); + } + if let Some(index) = selector.strip_prefix("text:") { + let occurrence = index + .parse::() + .map_err(|_| MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: format!("invalid text occurrence `{index}`"), + })?; + if occurrence == 0 { + return Err(MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: "text occurrences are one-based; `text:0` is invalid".to_string(), + }); + } + return Ok(MuxMp4TrackSelector::Text { occurrence }); + } + if let Some(track_id) = selector.strip_prefix("track:") { + let track_id = track_id + .parse::() + .map_err(|_| MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: format!("invalid track id `{track_id}`"), + })?; + if track_id == 0 { + return Err(MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: "track ids are one-based; `track:0` is invalid".to_string(), + }); + } + return Ok(MuxMp4TrackSelector::TrackId { track_id }); + } + + Err(MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: format!( + "unsupported MP4 track selector `{selector}`; expected `video`, `audio`, `audio:N`, `text`, `text:N`, or `track:ID`" + ), + }) +} + +fn parse_raw_video_params(spec: &str, rawvideo_text: &str) -> Result { + if rawvideo_text.is_empty() { + return Err(MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: + "expected rawvideo parameters after `#rawvideo:`, such as `size=1920x1080,spfmt=yuv420,fps=25/1`" + .to_string(), + }); + } + + let mut width = None::; + let mut height = None::; + let mut pixel_format = None::; + let mut fps_num = None::; + let mut fps_den = None::; + + for token in rawvideo_text.split(',') { + let (name, value) = token.split_once('=').ok_or_else(|| MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: format!( + "invalid rawvideo parameter `{token}`; expected `name=value` pairs separated by commas" + ), + })?; + match name { + "size" => { + let (parsed_width, parsed_height) = + value + .split_once('x') + .ok_or_else(|| MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: "rawvideo `size` must use `WIDTHxHEIGHT`".to_string(), + })?; + width = + Some( + parsed_width + .parse::() + .map_err(|_| MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: format!("invalid rawvideo width `{parsed_width}`"), + })?, + ); + height = + Some( + parsed_height + .parse::() + .map_err(|_| MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: format!("invalid rawvideo height `{parsed_height}`"), + })?, + ); + } + "spfmt" => { + pixel_format = Some(MuxRawVideoPixelFormat::parse(spec, value)?); + } + "fps" => { + let (parsed_num, parsed_den) = + value + .split_once('/') + .ok_or_else(|| MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: "rawvideo `fps` must use `NUM/DEN`".to_string(), + })?; + fps_num = + Some( + parsed_num + .parse::() + .map_err(|_| MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: format!( + "invalid rawvideo frame-rate numerator `{parsed_num}`" + ), + })?, + ); + fps_den = + Some( + parsed_den + .parse::() + .map_err(|_| MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: format!( + "invalid rawvideo frame-rate denominator `{parsed_den}`" + ), + })?, + ); + } + _ => { + return Err(MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: format!( + "unsupported rawvideo parameter `{name}`; expected `size`, `spfmt`, or `fps`" + ), + }); + } + } + } + + let width = width.ok_or_else(|| MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: "rawvideo track specs must declare `size=WIDTHxHEIGHT`".to_string(), + })?; + let height = height.ok_or_else(|| MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: "rawvideo track specs must declare `size=WIDTHxHEIGHT`".to_string(), + })?; + let pixel_format = pixel_format.ok_or_else(|| MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: "rawvideo track specs must declare `spfmt=PIXFMT`".to_string(), + })?; + let fps_num = fps_num.ok_or_else(|| MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: "rawvideo track specs must declare `fps=NUM/DEN`".to_string(), + })?; + let fps_den = fps_den.ok_or_else(|| MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message: "rawvideo track specs must declare `fps=NUM/DEN`".to_string(), + })?; + MuxRawVideoParams::new(width, height, pixel_format, fps_num, fps_den).map_err(|error| { + match error { + MuxError::InvalidTrackSpec { message, .. } => MuxError::InvalidTrackSpec { + spec: spec.to_string(), + message, + }, + other => other, + } + }) +} + +/// Duration-boundary mode for the narrowed public mux surface. +/// +/// The current `mp4forge` mux follow-on keeps the public duration surface intentionally narrow: +/// callers may request exactly one boundary mode for fragmented output when the current one-file +/// MP4 output can model it correctly. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum MuxDurationMode { + /// Coordinate track chunks around one target segment duration in seconds. + Segment { seconds: f64 }, + /// Coordinate track chunks around one target fragment duration in seconds. + Fragment { seconds: f64 }, +} + +impl MuxDurationMode { + /// Returns the public mode label used by diagnostics and CLI help. + pub const fn label(&self) -> &'static str { + match self { + Self::Segment { .. } => "segment_duration", + Self::Fragment { .. } => "fragment_duration", + } + } + + /// Returns the requested duration in seconds. + pub const fn seconds(&self) -> f64 { + match self { + Self::Segment { seconds } | Self::Fragment { seconds } => *seconds, + } + } +} + +/// Container layout used by the public mux request surface. +/// +/// The default `mp4forge` mux behavior remains one flat `ftyp + moov + mdat` file. Fragmented +/// output is additive and explicit so callers do not accidentally change container structure just +/// by supplying one duration mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum MuxOutputLayout { + /// Write one flat self-contained MP4 with `ftyp`, `moov`, and `mdat`. + #[default] + Flat, + /// Write one fragmented MP4 with `sidx` plus one or more `moof`/`mdat` pairs. + Fragmented, +} + +impl MuxOutputLayout { + /// Returns the public layout label used by CLI parsing and diagnostics. + pub const fn label(&self) -> &'static str { + match self { + Self::Flat => "flat", + Self::Fragmented => "fragmented", + } + } +} + +/// Destination mode used by the public mux request surface. +/// +/// The force-new mode writes one newly created output file to a caller-supplied path. The +/// destination-path mode follows an update-or-create model: if the destination already exists as +/// an MP4, its tracks are preserved and additional tracks are imported into it; otherwise the same +/// path is treated as the newly created output file. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum MuxDestinationMode { + /// Write one newly created output file supplied separately to the file-backed helpers. + #[default] + CreateNew, + /// Preserve one destination MP4 when it already exists, or create it at the same path. + UpdateOrCreateDestination, +} + +impl MuxDestinationMode { + /// Returns the public destination-mode label used by CLI parsing and diagnostics. + pub const fn label(&self) -> &'static str { + match self { + Self::CreateNew => "create-new", + Self::UpdateOrCreateDestination => "update-or-create-destination", + } + } +} + +/// Event-message metadata to emit before one fragmented media fragment. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MuxFragmentEventMessage { + fragment_index: u32, + version: u8, + scheme_id_uri: String, + value: String, + timescale: u32, + presentation_time_delta: u32, + presentation_time: u64, + event_duration: u32, + id: u32, + message_data: Vec, +} + +impl MuxFragmentEventMessage { + /// Creates one version-0 event message for the zero-based fragment index. + #[allow(clippy::too_many_arguments)] + pub fn new_v0( + fragment_index: u32, + scheme_id_uri: S, + value: V, + timescale: u32, + presentation_time_delta: u32, + event_duration: u32, + id: u32, + message_data: M, + ) -> Self + where + S: Into, + V: Into, + M: Into>, + { + Self { + fragment_index, + version: 0, + scheme_id_uri: scheme_id_uri.into(), + value: value.into(), + timescale, + presentation_time_delta, + presentation_time: 0, + event_duration, + id, + message_data: message_data.into(), + } + } + + /// Creates one version-1 event message for the zero-based fragment index. + #[allow(clippy::too_many_arguments)] + pub fn new_v1( + fragment_index: u32, + scheme_id_uri: S, + value: V, + timescale: u32, + presentation_time: u64, + event_duration: u32, + id: u32, + message_data: M, + ) -> Self + where + S: Into, + V: Into, + M: Into>, + { + Self { + fragment_index, + version: 1, + scheme_id_uri: scheme_id_uri.into(), + value: value.into(), + timescale, + presentation_time_delta: 0, + presentation_time, + event_duration, + id, + message_data: message_data.into(), + } + } + + /// Returns the zero-based output fragment index this message belongs to. + pub const fn fragment_index(&self) -> u32 { + self.fragment_index + } + + /// Returns the encoded `emsg` version. + pub const fn version(&self) -> u8 { + self.version + } + + /// Returns the event scheme identifier. + pub fn scheme_id_uri(&self) -> &str { + &self.scheme_id_uri + } + + /// Returns the event value string. + pub fn value(&self) -> &str { + &self.value + } + + /// Returns the event timescale. + pub const fn timescale(&self) -> u32 { + self.timescale + } + + /// Returns the version-0 presentation time delta. + pub const fn presentation_time_delta(&self) -> u32 { + self.presentation_time_delta + } + + /// Returns the version-1 presentation time. + pub const fn presentation_time(&self) -> u64 { + self.presentation_time + } + + /// Returns the event duration. + pub const fn event_duration(&self) -> u32 { + self.event_duration + } + + /// Returns the event identifier. + pub const fn id(&self) -> u32 { + self.id + } + + /// Returns the event payload bytes. + pub fn message_data(&self) -> &[u8] { + &self.message_data + } +} + +/// Producer-reference-time metadata to emit before one fragmented media fragment. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MuxProducerReferenceTime { + fragment_index: u32, + version: u8, + flags: u32, + reference_track_id: u32, + ntp_timestamp: u64, + media_time: u64, +} + +impl MuxProducerReferenceTime { + /// Creates one version-1 producer-reference-time entry for the zero-based fragment index. + pub const fn new( + fragment_index: u32, + reference_track_id: u32, + ntp_timestamp: u64, + media_time: u64, + ) -> Self { + Self { + fragment_index, + version: 1, + flags: 0, + reference_track_id, + ntp_timestamp, + media_time, + } + } + + /// Returns a copy of this entry with an explicit encoded `prft` version. + pub const fn with_version(mut self, version: u8) -> Self { + self.version = version; + self + } + + /// Returns a copy of this entry with explicit `prft` flags. + pub const fn with_flags(mut self, flags: u32) -> Self { + self.flags = flags; + self + } + + /// Returns the zero-based output fragment index this entry belongs to. + pub const fn fragment_index(&self) -> u32 { + self.fragment_index + } + + /// Returns the encoded `prft` version. + pub const fn version(&self) -> u8 { + self.version + } + + /// Returns the encoded `prft` flags. + pub const fn flags(&self) -> u32 { + self.flags + } + + /// Returns the referenced track identifier. + pub const fn reference_track_id(&self) -> u32 { + self.reference_track_id + } + + /// Returns the NTP timestamp payload. + pub const fn ntp_timestamp(&self) -> u64 { + self.ntp_timestamp + } + + /// Returns the media-time payload before version-specific narrowing. + pub const fn media_time(&self) -> u64 { + self.media_time + } +} + +/// One high-level mux request aligned with the public CLI surface. +/// +/// The narrowed public `mux` surface now centers on repeated [`MuxTrackSpec`] values, one +/// caller-supplied destination path, one explicit output layout, and at most one +/// duration-boundary mode. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MuxRequest { + tracks: Vec, + output_layout: MuxOutputLayout, + destination_mode: MuxDestinationMode, + duration_mode: Option, + preserve_flat_authority_layout: bool, + fragment_event_messages: Vec, + producer_reference_times: Vec, +} + +impl MuxRequest { + /// Creates one mux request from repeated public track specs. + pub fn new(tracks: Vec) -> Self { + Self { + tracks, + output_layout: MuxOutputLayout::Flat, + destination_mode: MuxDestinationMode::CreateNew, + duration_mode: None, + preserve_flat_authority_layout: false, + fragment_event_messages: Vec::new(), + producer_reference_times: Vec::new(), + } + } + + /// Returns the public track specs carried by this request. + pub fn tracks(&self) -> &[MuxTrackSpec] { + &self.tracks + } + + /// Returns the explicit container layout requested by the caller. + pub const fn output_layout(&self) -> MuxOutputLayout { + self.output_layout + } + + /// Returns the destination mode requested by the caller. + pub const fn destination_mode(&self) -> MuxDestinationMode { + self.destination_mode + } + + /// Returns the configured public duration-boundary mode, if any. + pub const fn duration_mode(&self) -> Option { + self.duration_mode + } + + pub(crate) const fn preserve_flat_authority_layout(&self) -> bool { + self.preserve_flat_authority_layout + } + + /// Returns configured event messages for fragmented output. + pub fn fragment_event_messages(&self) -> &[MuxFragmentEventMessage] { + &self.fragment_event_messages + } + + /// Returns configured producer-reference-time entries for fragmented output. + pub fn producer_reference_times(&self) -> &[MuxProducerReferenceTime] { + &self.producer_reference_times + } + + /// Returns a copy of this request with one explicit container layout configured. + pub const fn with_output_layout(mut self, output_layout: MuxOutputLayout) -> Self { + self.output_layout = output_layout; + self + } + + /// Returns a copy of this request with one explicit destination mode configured. + pub const fn with_destination_mode(mut self, destination_mode: MuxDestinationMode) -> Self { + self.destination_mode = destination_mode; + self + } + + /// Returns a copy of this request with one public duration-boundary mode configured. + pub const fn with_duration_mode(mut self, duration_mode: MuxDurationMode) -> Self { + self.duration_mode = Some(duration_mode); + self + } + + pub(crate) const fn with_preserve_flat_authority_layout( + mut self, + preserve_flat_authority_layout: bool, + ) -> Self { + self.preserve_flat_authority_layout = preserve_flat_authority_layout; + self + } + + /// Returns a copy of this request with one appended fragmented event message. + pub fn with_fragment_event_message(mut self, message: MuxFragmentEventMessage) -> Self { + self.fragment_event_messages.push(message); + self + } + + /// Returns a copy of this request with one appended fragmented producer-reference-time entry. + pub fn with_producer_reference_time(mut self, entry: MuxProducerReferenceTime) -> Self { + self.producer_reference_times.push(entry); + self + } +} + +/// Interleave policy used when ordering staged media items into one output payload. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum MuxInterleavePolicy { + /// Orders staged items by normalized decode time while keeping ties stable by source and + /// source-offset order. + #[default] + DecodeTime, + /// Orders coordinated chunks by chunk ordinal first, then keeps ties stable by source and + /// source-offset order. This is used only on preserved-authority flat carry paths where the + /// authority layout dictates the interleave window sequence directly. + ChunkOrdinalThenSource, +} + +/// One staged media item that a later mux step can schedule into one output payload. +/// +/// The current foundation expects `decode_time` to already be normalized onto one interleave +/// timeline across every staged source involved in the plan. Future phases can widen the staging +/// model with richer timeline normalization once full container assembly lands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MuxStagedMediaItem { + source_index: usize, + track_id: u32, + decode_time: u64, + composition_time_offset: i32, + duration: u32, + data_offset: u64, + data_size: u32, + is_sync_sample: bool, + sample_description_index: u32, +} + +impl MuxStagedMediaItem { + /// Creates one staged media item for a later mux payload plan. + pub const fn new( + source_index: usize, + track_id: u32, + decode_time: u64, + duration: u32, + data_offset: u64, + data_size: u32, + ) -> Self { + Self { + source_index, + track_id, + decode_time, + composition_time_offset: 0, + duration, + data_offset, + data_size, + is_sync_sample: false, + sample_description_index: 1, + } + } + + /// Returns the staged source slot this item will read from during payload copy. + pub const fn source_index(&self) -> usize { + self.source_index + } + + /// Returns the destination track identifier for this item. + pub const fn track_id(&self) -> u32 { + self.track_id + } + + /// Returns the normalized decode time used by the current interleave planner. + pub const fn decode_time(&self) -> u64 { + self.decode_time + } + + /// Returns the composition offset carried with this item. + pub const fn composition_time_offset(&self) -> i32 { + self.composition_time_offset + } + + /// Returns this item's decode duration on the staged mux timeline. + pub const fn duration(&self) -> u32 { + self.duration + } + + /// Returns the source byte offset for this item's sample payload. + pub const fn data_offset(&self) -> u64 { + self.data_offset + } + + /// Returns the number of bytes to copy for this item's sample payload. + pub const fn data_size(&self) -> u32 { + self.data_size + } + + /// Returns whether the staged item is marked as a sync sample. + pub const fn is_sync_sample(&self) -> bool { + self.is_sync_sample + } + + /// Returns the one-based sample-description index used for this staged sample. + pub const fn sample_description_index(&self) -> u32 { + self.sample_description_index + } + + /// Returns a copy of this item with a non-zero composition offset. + pub const fn with_composition_time_offset(mut self, composition_time_offset: i32) -> Self { + self.composition_time_offset = composition_time_offset; + self + } + + /// Returns a copy of this item with an explicit sync-sample marker. + pub const fn with_sync_sample(mut self, is_sync_sample: bool) -> Self { + self.is_sync_sample = is_sync_sample; + self + } + + /// Returns a copy of this item with an explicit one-based sample-description index. + pub const fn with_sample_description_index(mut self, sample_description_index: u32) -> Self { + self.sample_description_index = sample_description_index; + self + } +} + +/// One planned media item with its final output payload placement. +/// +/// This is the current mux-side boundary surface for future higher-level work: one item carries +/// the sample order, the source byte range, the decode interval, and the output payload span +/// without exposing the crate-private queue internals directly. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MuxPlannedMediaItem { + staged: MuxStagedMediaItem, + output_offset: u64, +} + +impl MuxPlannedMediaItem { + /// Returns the original staged media item. + pub const fn staged(&self) -> &MuxStagedMediaItem { + &self.staged + } + + /// Returns the byte offset this item occupies in the final payload order. + pub const fn output_offset(&self) -> u64 { + self.output_offset + } + + /// Returns the first byte offset after this item's payload in the final output order. + pub const fn output_end_offset(&self) -> u64 { + self.output_offset + self.staged.data_size as u64 + } + + /// Returns the decode end time of this item on the planned mux timeline. + pub const fn decode_end_time(&self) -> u64 { + self.staged.decode_time + self.staged.duration as u64 + } +} + +/// Aggregate per-track timing and item-count information for a mux plan. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MuxTrackPlan { + track_id: u32, + item_count: u32, + first_decode_time: u64, + end_decode_time: u64, +} + +impl MuxTrackPlan { + /// Returns the track identifier summarized by this plan entry. + pub const fn track_id(&self) -> u32 { + self.track_id + } + + /// Returns the number of staged items scheduled for this track. + pub const fn item_count(&self) -> u32 { + self.item_count + } + + /// Returns the earliest decode time assigned to this track in the current plan. + pub const fn first_decode_time(&self) -> u64 { + self.first_decode_time + } + + /// Returns the decode end time of the last staged item scheduled for this track. + pub const fn end_decode_time(&self) -> u64 { + self.end_decode_time + } +} + +/// Planned mux payload order and per-track timing summaries. +/// +/// The stable task-level plan view intentionally mirrors the internal mux event graph. Callers +/// continue to consume planned items and per-track summaries, while the crate-private event graph +/// drives the current payload-copy, chunk coordination, and planned sample-reader helpers +/// underneath. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MuxPlan { + interleave_policy: MuxInterleavePolicy, + planned_items: Vec, + track_plans: Vec, + total_payload_size: u64, + coordination: MuxCoordinationPlan, + event_graph: MuxEventGraph, +} + +impl MuxPlan { + /// Returns the interleave policy used when building this plan. + pub const fn interleave_policy(&self) -> MuxInterleavePolicy { + self.interleave_policy + } + + /// Returns the staged items in final payload order. + /// + /// This slice is the stable task-level view of the current mux event graph. Callers that need + /// sample-order timing or payload spans should build on these planned items instead of + /// depending on the crate-private event graph directly. + pub fn planned_items(&self) -> &[MuxPlannedMediaItem] { + &self.planned_items + } + + /// Returns the per-track summaries collected during planning. + pub fn track_plans(&self) -> &[MuxTrackPlan] { + &self.track_plans + } + + /// Returns the total number of bytes the planned payload copy will emit. + pub const fn total_payload_size(&self) -> u64 { + self.total_payload_size + } + + pub(crate) fn chunk_sample_counts(&self, track_id: u32) -> Result<&[u32], MuxError> { + self.coordination.chunk_sample_counts(track_id) + } + + pub(crate) fn event_graph(&self) -> &MuxEventGraph { + &self.event_graph + } +} + +/// File-level MP4 mux configuration for the real container-writing surface. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MuxFileConfig { + movie_timescale: u32, + major_brand: FourCc, + minor_version: u32, + compatible_brands: Vec, + auto_flat_profile: bool, + allow_audio_only_iods: bool, + keep_flat_free_box: bool, + keep_flat_authority_brands: bool, + preserve_auto_flat_movie_timescale: bool, + emit_default_flat_tool_metadata: bool, + flat_source_encoding_metadata: Option, + flat_source_encoder_metadata: Option, + flat_source_movie_creation_time: Option, + flat_source_movie_modification_time: Option, + preserved_flat_prefix_bytes: Vec, + preserved_flat_iods_bytes: Option>, + preserved_flat_udta_bytes: Option>, + fragment_event_messages: Vec, + producer_reference_times: Vec, +} + +impl MuxFileConfig { + /// Creates one MP4 mux configuration with the supplied movie timescale. + /// + /// The default brand layout is `isom` plus `mp42` compatibility. + pub fn new(movie_timescale: u32) -> Self { + Self { + movie_timescale, + major_brand: FourCc::from_bytes(*b"isom"), + minor_version: 0, + compatible_brands: vec![FourCc::from_bytes(*b"isom"), FourCc::from_bytes(*b"mp42")], + auto_flat_profile: false, + allow_audio_only_iods: false, + keep_flat_free_box: false, + keep_flat_authority_brands: false, + preserve_auto_flat_movie_timescale: false, + emit_default_flat_tool_metadata: true, + flat_source_encoding_metadata: None, + flat_source_encoder_metadata: None, + flat_source_movie_creation_time: None, + flat_source_movie_modification_time: None, + preserved_flat_prefix_bytes: Vec::new(), + preserved_flat_iods_bytes: None, + preserved_flat_udta_bytes: None, + fragment_event_messages: Vec::new(), + producer_reference_times: Vec::new(), + } + } + + /// Returns the movie timescale used for `mvhd` and `tkhd` durations. + pub const fn movie_timescale(&self) -> u32 { + self.movie_timescale + } + + /// Returns the file's major brand. + pub const fn major_brand(&self) -> FourCc { + self.major_brand + } + + /// Returns the file's minor version. + pub const fn minor_version(&self) -> u32 { + self.minor_version + } + + /// Returns the compatible brands written into `ftyp`. + pub fn compatible_brands(&self) -> &[FourCc] { + &self.compatible_brands + } + + /// Returns a copy of this configuration with a different major brand. + pub const fn with_major_brand(mut self, major_brand: FourCc) -> Self { + self.major_brand = major_brand; + self + } + + /// Returns a copy of this configuration with a different minor version. + pub const fn with_minor_version(mut self, minor_version: u32) -> Self { + self.minor_version = minor_version; + self + } + + /// Adds `brand` to the compatibility list if it is not already present. + pub fn add_compatible_brand(&mut self, brand: FourCc) { + if !self.compatible_brands.contains(&brand) { + self.compatible_brands.push(brand); + } + } + + /// Returns a copy of this configuration with one extra compatible brand. + pub fn with_compatible_brand(mut self, brand: FourCc) -> Self { + self.add_compatible_brand(brand); + self + } + + pub(crate) fn with_compatible_brands(mut self, compatible_brands: Vec) -> Self { + self.compatible_brands = compatible_brands; + self + } + + pub(crate) const fn auto_flat_profile(&self) -> bool { + self.auto_flat_profile + } + + pub(crate) const fn with_auto_flat_profile(mut self, auto_flat_profile: bool) -> Self { + self.auto_flat_profile = auto_flat_profile; + self + } + + pub(crate) const fn allow_audio_only_iods(&self) -> bool { + self.allow_audio_only_iods + } + + pub(crate) const fn with_allow_audio_only_iods(mut self, allow_audio_only_iods: bool) -> Self { + self.allow_audio_only_iods = allow_audio_only_iods; + self + } + + pub(crate) const fn keep_flat_free_box(&self) -> bool { + self.keep_flat_free_box + } + + pub(crate) const fn with_keep_flat_free_box(mut self, keep_flat_free_box: bool) -> Self { + self.keep_flat_free_box = keep_flat_free_box; + self + } + + pub(crate) const fn keep_flat_authority_brands(&self) -> bool { + self.keep_flat_authority_brands + } + + pub(crate) const fn with_keep_flat_authority_brands( + mut self, + keep_flat_authority_brands: bool, + ) -> Self { + self.keep_flat_authority_brands = keep_flat_authority_brands; + self + } + + pub(crate) const fn preserve_auto_flat_movie_timescale(&self) -> bool { + self.preserve_auto_flat_movie_timescale + } + + pub(crate) const fn with_preserve_auto_flat_movie_timescale( + mut self, + preserve_auto_flat_movie_timescale: bool, + ) -> Self { + self.preserve_auto_flat_movie_timescale = preserve_auto_flat_movie_timescale; + self + } + + pub(crate) const fn emit_default_flat_tool_metadata(&self) -> bool { + self.emit_default_flat_tool_metadata + } + + pub(crate) const fn with_emit_default_flat_tool_metadata( + mut self, + emit_default_flat_tool_metadata: bool, + ) -> Self { + self.emit_default_flat_tool_metadata = emit_default_flat_tool_metadata; + self + } + + pub(crate) fn flat_source_encoding_metadata(&self) -> Option<&str> { + self.flat_source_encoding_metadata.as_deref() + } + + pub(crate) fn with_flat_source_encoding_metadata( + mut self, + flat_source_encoding_metadata: Option, + ) -> Self { + self.flat_source_encoding_metadata = flat_source_encoding_metadata; + self + } + + pub(crate) fn flat_source_encoder_metadata(&self) -> Option<&str> { + self.flat_source_encoder_metadata.as_deref() + } + + pub(crate) fn with_flat_source_encoder_metadata( + mut self, + flat_source_encoder_metadata: Option, + ) -> Self { + self.flat_source_encoder_metadata = flat_source_encoder_metadata; + self + } + + pub(crate) const fn flat_source_movie_creation_time(&self) -> Option { + self.flat_source_movie_creation_time + } + + pub(crate) const fn with_flat_source_movie_creation_time( + mut self, + flat_source_movie_creation_time: Option, + ) -> Self { + self.flat_source_movie_creation_time = flat_source_movie_creation_time; + self + } + + pub(crate) const fn flat_source_movie_modification_time(&self) -> Option { + self.flat_source_movie_modification_time + } + + pub(crate) const fn with_flat_source_movie_modification_time( + mut self, + flat_source_movie_modification_time: Option, + ) -> Self { + self.flat_source_movie_modification_time = flat_source_movie_modification_time; + self + } + + pub(crate) fn preserved_flat_prefix_bytes(&self) -> &[u8] { + &self.preserved_flat_prefix_bytes + } + + pub(crate) fn with_preserved_flat_prefix_bytes( + mut self, + preserved_flat_prefix_bytes: Vec, + ) -> Self { + self.preserved_flat_prefix_bytes = preserved_flat_prefix_bytes; + self + } + + pub(crate) fn preserved_flat_iods_bytes(&self) -> Option<&[u8]> { + self.preserved_flat_iods_bytes.as_deref() + } + + pub(crate) fn with_preserved_flat_iods_bytes( + mut self, + preserved_flat_iods_bytes: Option>, + ) -> Self { + self.preserved_flat_iods_bytes = preserved_flat_iods_bytes; + self + } + + pub(crate) fn preserved_flat_udta_bytes(&self) -> Option<&[u8]> { + self.preserved_flat_udta_bytes.as_deref() + } + + pub(crate) fn with_preserved_flat_udta_bytes( + mut self, + preserved_flat_udta_bytes: Option>, + ) -> Self { + self.preserved_flat_udta_bytes = preserved_flat_udta_bytes; + self + } + + pub(crate) fn fragment_event_messages(&self) -> &[MuxFragmentEventMessage] { + &self.fragment_event_messages + } + + pub(crate) fn with_fragment_event_messages( + mut self, + fragment_event_messages: Vec, + ) -> Self { + self.fragment_event_messages = fragment_event_messages; + self + } + + pub(crate) fn producer_reference_times(&self) -> &[MuxProducerReferenceTime] { + &self.producer_reference_times + } + + pub(crate) fn with_producer_reference_times( + mut self, + producer_reference_times: Vec, + ) -> Self { + self.producer_reference_times = producer_reference_times; + self + } +} + +/// Track kind used by the real MP4 mux surface. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MuxTrackKind { + /// Sound track with `smhd`, `soun`, and non-zero default track volume. + Audio, + /// Visual track with `vmhd`, `vide`, width, and height metadata. + Video, + /// Timed text track with `nmhd`, `text`, and zero default track volume. + Text, + /// Timed subtitle track with `sthd`, `subt`, and zero default track volume. + Subtitle, +} + +impl MuxTrackKind { + /// Returns whether this track kind is audio. + pub const fn is_audio(self) -> bool { + matches!(self, Self::Audio) + } + + /// Returns whether this track kind is video. + pub const fn is_video(self) -> bool { + matches!(self, Self::Video) + } + + /// Returns whether this track kind is one of the timed-text families. + pub const fn is_textual(self) -> bool { + matches!(self, Self::Text | Self::Subtitle) + } +} + +const DEFAULT_TKHD_FLAGS: u32 = 0x0000_0001 | 0x0000_0002 | 0x0000_0004; +const DEFAULT_AUDIO_ALTERNATE_GROUP: i16 = 1; +const DEFAULT_SUBTITLE_ALTERNATE_GROUP: i16 = 0; +const DEFAULT_TKHD_MATRIX: [i32; 9] = [0x0001_0000, 0, 0, 0, 0x0001_0000, 0, 0, 0, 0x4000_0000]; + +const fn default_alternate_group_for_kind(kind: MuxTrackKind) -> i16 { + match kind { + MuxTrackKind::Audio => DEFAULT_AUDIO_ALTERNATE_GROUP, + MuxTrackKind::Subtitle => DEFAULT_SUBTITLE_ALTERNATE_GROUP, + MuxTrackKind::Video | MuxTrackKind::Text => 0, + } +} + +/// Per-track configuration for the real MP4 mux surface. +/// +/// The muxer accepts a primary encoded sample-entry box and can retain additional entries for +/// imported tracks that switch sample descriptions over time. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MuxTrackConfig { + track_id: u32, + kind: MuxTrackKind, + timescale: u32, + language: [u8; 3], + handler_name: String, + track_width: u16, + track_height: u16, + track_width_fixed_16_16: Option, + track_height_fixed_16_16: Option, + tkhd_flags: u32, + alternate_group: i16, + volume: i16, + matrix: [i32; 9], + edit_media_time: Option, + sample_roll_distance: Option, + emit_roll_sbgp: bool, + sample_entry_box: Vec, + sample_entry_boxes: Vec>, + sync_sample_table_mode: SyncSampleTableMode, + stts_run_encoding_mode: SttsRunEncodingMode, + stsc_run_encoding_mode: StscRunEncodingMode, + flat_timing_override: Option, + flat_audio_profile_level_indication: Option, + fragmented_decode_time_offset: Option, + fragmented_reference_group_fragment_counts: Option>, + flat_source_track_creation_time: Option, + flat_source_track_modification_time: Option, + flat_source_media_creation_time: Option, + flat_source_media_modification_time: Option, + omit_flat_iods: bool, + flat_stsc_override: Option, + preserved_flat_stbl_boxes: Vec>, + preserved_flat_trak_boxes: Vec>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SyncSampleTableMode { + Auto, + ForceEmpty, + ForceFirstOnly, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum StscRunEncodingMode { + CollapseIdentical, + PreserveTerminalBoundary, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SttsRunEncodingMode { + CollapseIdentical, + PreservePerSample, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FlatTimingOverride { + pub(crate) sample_durations: Vec, + pub(crate) composition_offsets: Vec, + pub(crate) media_duration: u64, + pub(crate) presentation_duration: u64, +} + +impl MuxTrackConfig { + /// Creates one audio-track configuration with a full encoded sample-entry box. + pub fn new_audio(track_id: u32, timescale: u32, sample_entry_box: Vec) -> Self { + Self { + track_id, + kind: MuxTrackKind::Audio, + timescale, + language: *b"und", + handler_name: "SoundHandler".to_string(), + track_width: 0, + track_height: 0, + track_width_fixed_16_16: None, + track_height_fixed_16_16: None, + tkhd_flags: DEFAULT_TKHD_FLAGS, + alternate_group: default_alternate_group_for_kind(MuxTrackKind::Audio), + volume: 0x0100, + matrix: DEFAULT_TKHD_MATRIX, + edit_media_time: None, + sample_roll_distance: None, + emit_roll_sbgp: true, + sample_entry_box: sample_entry_box.clone(), + sample_entry_boxes: vec![sample_entry_box], + sync_sample_table_mode: SyncSampleTableMode::Auto, + stts_run_encoding_mode: SttsRunEncodingMode::CollapseIdentical, + stsc_run_encoding_mode: StscRunEncodingMode::CollapseIdentical, + flat_timing_override: None, + flat_audio_profile_level_indication: None, + fragmented_decode_time_offset: None, + fragmented_reference_group_fragment_counts: None, + flat_source_track_creation_time: None, + flat_source_track_modification_time: None, + flat_source_media_creation_time: None, + flat_source_media_modification_time: None, + omit_flat_iods: false, + flat_stsc_override: None, + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + } + } + + /// Creates one video-track configuration with a full encoded sample-entry box. + pub fn new_video( + track_id: u32, + timescale: u32, + width: u16, + height: u16, + sample_entry_box: Vec, + ) -> Self { + Self { + track_id, + kind: MuxTrackKind::Video, + timescale, + language: *b"und", + handler_name: "VideoHandler".to_string(), + track_width: width, + track_height: height, + track_width_fixed_16_16: None, + track_height_fixed_16_16: None, + tkhd_flags: DEFAULT_TKHD_FLAGS, + alternate_group: default_alternate_group_for_kind(MuxTrackKind::Video), + volume: 0, + matrix: DEFAULT_TKHD_MATRIX, + edit_media_time: None, + sample_roll_distance: None, + emit_roll_sbgp: true, + sample_entry_box: sample_entry_box.clone(), + sample_entry_boxes: vec![sample_entry_box], + sync_sample_table_mode: SyncSampleTableMode::Auto, + stts_run_encoding_mode: SttsRunEncodingMode::CollapseIdentical, + stsc_run_encoding_mode: StscRunEncodingMode::CollapseIdentical, + flat_timing_override: None, + flat_audio_profile_level_indication: None, + fragmented_decode_time_offset: None, + fragmented_reference_group_fragment_counts: None, + flat_source_track_creation_time: None, + flat_source_track_modification_time: None, + flat_source_media_creation_time: None, + flat_source_media_modification_time: None, + omit_flat_iods: false, + flat_stsc_override: None, + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + } + } + + /// Creates one timed-text track configuration with a full encoded sample-entry box. + pub fn new_text( + track_id: u32, + timescale: u32, + width: u16, + height: u16, + sample_entry_box: Vec, + ) -> Self { + Self { + track_id, + kind: MuxTrackKind::Text, + timescale, + language: *b"und", + handler_name: "TextHandler".to_string(), + track_width: width, + track_height: height, + track_width_fixed_16_16: None, + track_height_fixed_16_16: None, + tkhd_flags: DEFAULT_TKHD_FLAGS, + alternate_group: default_alternate_group_for_kind(MuxTrackKind::Text), + volume: 0, + matrix: DEFAULT_TKHD_MATRIX, + edit_media_time: None, + sample_roll_distance: None, + emit_roll_sbgp: true, + sample_entry_box: sample_entry_box.clone(), + sample_entry_boxes: vec![sample_entry_box], + sync_sample_table_mode: SyncSampleTableMode::Auto, + stts_run_encoding_mode: SttsRunEncodingMode::CollapseIdentical, + stsc_run_encoding_mode: StscRunEncodingMode::CollapseIdentical, + flat_timing_override: None, + flat_audio_profile_level_indication: None, + fragmented_decode_time_offset: None, + fragmented_reference_group_fragment_counts: None, + flat_source_track_creation_time: None, + flat_source_track_modification_time: None, + flat_source_media_creation_time: None, + flat_source_media_modification_time: None, + omit_flat_iods: false, + flat_stsc_override: None, + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + } + } + + /// Creates one timed-subtitle track configuration with a full encoded sample-entry box. + pub fn new_subtitle( + track_id: u32, + timescale: u32, + width: u16, + height: u16, + sample_entry_box: Vec, + ) -> Self { + Self { + track_id, + kind: MuxTrackKind::Subtitle, + timescale, + language: *b"und", + handler_name: "SubtitleHandler".to_string(), + track_width: width, + track_height: height, + track_width_fixed_16_16: None, + track_height_fixed_16_16: None, + tkhd_flags: DEFAULT_TKHD_FLAGS, + alternate_group: default_alternate_group_for_kind(MuxTrackKind::Subtitle), + volume: 0, + matrix: DEFAULT_TKHD_MATRIX, + edit_media_time: None, + sample_roll_distance: None, + emit_roll_sbgp: true, + sample_entry_box: sample_entry_box.clone(), + sample_entry_boxes: vec![sample_entry_box], + sync_sample_table_mode: SyncSampleTableMode::Auto, + stts_run_encoding_mode: SttsRunEncodingMode::CollapseIdentical, + stsc_run_encoding_mode: StscRunEncodingMode::CollapseIdentical, + flat_timing_override: None, + flat_audio_profile_level_indication: None, + fragmented_decode_time_offset: None, + fragmented_reference_group_fragment_counts: None, + flat_source_track_creation_time: None, + flat_source_track_modification_time: None, + flat_source_media_creation_time: None, + flat_source_media_modification_time: None, + omit_flat_iods: false, + flat_stsc_override: None, + preserved_flat_stbl_boxes: Vec::new(), + preserved_flat_trak_boxes: Vec::new(), + } + } + + /// Returns the track identifier. + pub const fn track_id(&self) -> u32 { + self.track_id + } + + /// Returns the configured track kind. + pub const fn kind(&self) -> MuxTrackKind { + self.kind + } + + /// Returns the media timescale used by this track's `mdhd` and sample tables. + pub const fn timescale(&self) -> u32 { + self.timescale + } + + /// Returns the three-letter ISO-639-2 language code carried by this track. + pub const fn language(&self) -> [u8; 3] { + self.language + } + + /// Returns the handler name written into `hdlr`. + pub fn handler_name(&self) -> &str { + &self.handler_name + } + + /// Returns the width recorded in `tkhd` for this track. + pub const fn track_width(&self) -> u16 { + self.track_width + } + + /// Returns the height recorded in `tkhd` for this track. + pub const fn track_height(&self) -> u16 { + self.track_height + } + + pub(crate) const fn track_width_fixed_16_16(&self) -> Option { + self.track_width_fixed_16_16 + } + + pub(crate) const fn track_height_fixed_16_16(&self) -> Option { + self.track_height_fixed_16_16 + } + + pub(crate) const fn tkhd_flags(&self) -> u32 { + self.tkhd_flags + } + + pub(crate) const fn flat_source_track_creation_time(&self) -> Option { + self.flat_source_track_creation_time + } + + pub(crate) const fn flat_source_track_modification_time(&self) -> Option { + self.flat_source_track_modification_time + } + + pub(crate) const fn flat_source_media_creation_time(&self) -> Option { + self.flat_source_media_creation_time + } + + pub(crate) const fn flat_source_media_modification_time(&self) -> Option { + self.flat_source_media_modification_time + } + + pub(crate) const fn omit_flat_iods(&self) -> bool { + self.omit_flat_iods + } + + pub(crate) const fn alternate_group(&self) -> i16 { + self.alternate_group + } + + /// Returns the fixed-point 8.8 track volume written into `tkhd`. + pub const fn volume(&self) -> i16 { + self.volume + } + + pub(crate) const fn matrix(&self) -> [i32; 9] { + self.matrix + } + + /// Returns the optional media-time trim that should be written into one edit list. + pub const fn edit_media_time(&self) -> Option { + self.edit_media_time + } + + pub(crate) const fn sample_roll_distance(&self) -> Option { + self.sample_roll_distance + } + + pub(crate) const fn emit_roll_sbgp(&self) -> bool { + self.emit_roll_sbgp + } + + /// Returns the primary full encoded sample-entry box written under `stsd`. + pub fn sample_entry_box(&self) -> &[u8] { + &self.sample_entry_box + } + + /// Returns every encoded sample-entry box written under `stsd`. + pub fn sample_entry_boxes(&self) -> &[Vec] { + &self.sample_entry_boxes + } + + /// Returns a copy of this configuration with a different language code. + pub const fn with_language(mut self, language: [u8; 3]) -> Self { + self.language = language; + self + } + + /// Returns a copy of this configuration with a different `hdlr` name. + pub fn with_handler_name(mut self, handler_name: impl Into) -> Self { + self.handler_name = handler_name.into(); + self + } + + pub(crate) const fn with_tkhd_flags(mut self, tkhd_flags: u32) -> Self { + self.tkhd_flags = tkhd_flags; + self + } + + pub(crate) const fn with_flat_source_track_creation_time( + mut self, + flat_source_track_creation_time: Option, + ) -> Self { + self.flat_source_track_creation_time = flat_source_track_creation_time; + self + } + + pub(crate) const fn with_flat_source_track_modification_time( + mut self, + flat_source_track_modification_time: Option, + ) -> Self { + self.flat_source_track_modification_time = flat_source_track_modification_time; + self + } + + pub(crate) const fn with_flat_source_media_creation_time( + mut self, + flat_source_media_creation_time: Option, + ) -> Self { + self.flat_source_media_creation_time = flat_source_media_creation_time; + self + } + + pub(crate) const fn with_flat_source_media_modification_time( + mut self, + flat_source_media_modification_time: Option, + ) -> Self { + self.flat_source_media_modification_time = flat_source_media_modification_time; + self + } + + pub(crate) const fn with_omit_flat_iods(mut self, omit_flat_iods: bool) -> Self { + self.omit_flat_iods = omit_flat_iods; + self + } + + pub(crate) const fn with_alternate_group(mut self, alternate_group: i16) -> Self { + self.alternate_group = alternate_group; + self + } + + /// Returns a copy of this configuration with a different fixed-point 8.8 track volume. + pub const fn with_volume(mut self, volume: i16) -> Self { + self.volume = volume; + self + } + + pub(crate) const fn with_matrix(mut self, matrix: [i32; 9]) -> Self { + self.matrix = matrix; + self + } + + pub(crate) const fn with_tkhd_dimensions_fixed_16_16( + mut self, + track_width_fixed_16_16: u32, + track_height_fixed_16_16: u32, + ) -> Self { + self.track_width_fixed_16_16 = Some(track_width_fixed_16_16); + self.track_height_fixed_16_16 = Some(track_height_fixed_16_16); + self + } + + /// Returns a copy of this configuration with one edit-list media-time trim. + pub const fn with_edit_media_time(mut self, edit_media_time: u64) -> Self { + self.edit_media_time = Some(edit_media_time); + self + } + + pub(crate) const fn with_sample_roll_distance(mut self, sample_roll_distance: i16) -> Self { + self.sample_roll_distance = Some(sample_roll_distance); + self + } + + pub(crate) const fn with_emit_roll_sbgp(mut self, emit_roll_sbgp: bool) -> Self { + self.emit_roll_sbgp = emit_roll_sbgp; + self + } + + pub(crate) fn with_sample_entry_boxes(mut self, sample_entry_boxes: Vec>) -> Self { + if let Some(first) = sample_entry_boxes.first() { + self.sample_entry_box = first.clone(); + } + self.sample_entry_boxes = sample_entry_boxes; + self + } + + pub(crate) const fn with_sync_sample_table_mode( + mut self, + sync_sample_table_mode: SyncSampleTableMode, + ) -> Self { + self.sync_sample_table_mode = sync_sample_table_mode; + self + } + + pub(crate) const fn stts_run_encoding_mode(&self) -> SttsRunEncodingMode { + self.stts_run_encoding_mode + } + + pub(crate) const fn with_stts_run_encoding_mode( + mut self, + stts_run_encoding_mode: SttsRunEncodingMode, + ) -> Self { + self.stts_run_encoding_mode = stts_run_encoding_mode; + self + } + + pub(crate) const fn stsc_run_encoding_mode(&self) -> StscRunEncodingMode { + self.stsc_run_encoding_mode + } + + pub(crate) const fn with_stsc_run_encoding_mode( + mut self, + stsc_run_encoding_mode: StscRunEncodingMode, + ) -> Self { + self.stsc_run_encoding_mode = stsc_run_encoding_mode; + self + } + + pub(crate) fn flat_timing_override(&self) -> Option<&FlatTimingOverride> { + self.flat_timing_override.as_ref() + } + + pub(crate) fn with_flat_timing_override( + mut self, + flat_timing_override: FlatTimingOverride, + ) -> Self { + self.flat_timing_override = Some(flat_timing_override); + self + } + + pub(crate) const fn flat_audio_profile_level_indication(&self) -> Option { + self.flat_audio_profile_level_indication + } + + pub(crate) const fn with_flat_audio_profile_level_indication( + mut self, + flat_audio_profile_level_indication: u8, + ) -> Self { + self.flat_audio_profile_level_indication = Some(flat_audio_profile_level_indication); + self + } + + pub(crate) const fn fragmented_decode_time_offset(&self) -> Option { + self.fragmented_decode_time_offset + } + + pub(crate) const fn with_fragmented_decode_time_offset( + mut self, + fragmented_decode_time_offset: u64, + ) -> Self { + self.fragmented_decode_time_offset = Some(fragmented_decode_time_offset); + self + } + + pub(crate) fn fragmented_reference_group_fragment_counts(&self) -> Option<&[u32]> { + self.fragmented_reference_group_fragment_counts.as_deref() + } + + pub(crate) fn with_fragmented_reference_group_fragment_counts( + mut self, + fragmented_reference_group_fragment_counts: Vec, + ) -> Self { + self.fragmented_reference_group_fragment_counts = + Some(fragmented_reference_group_fragment_counts); + self + } + + pub(crate) fn flat_stsc_override(&self) -> Option<&crate::boxes::iso14496_12::Stsc> { + self.flat_stsc_override.as_ref() + } + + pub(crate) fn with_flat_stsc_override( + mut self, + flat_stsc_override: crate::boxes::iso14496_12::Stsc, + ) -> Self { + self.flat_stsc_override = Some(flat_stsc_override); + self + } + + pub(crate) fn preserved_flat_stbl_boxes(&self) -> &[Vec] { + &self.preserved_flat_stbl_boxes + } + + pub(crate) fn with_preserved_flat_stbl_boxes( + mut self, + preserved_flat_stbl_boxes: Vec>, + ) -> Self { + self.preserved_flat_stbl_boxes = preserved_flat_stbl_boxes; + self + } + + pub(crate) fn preserved_flat_trak_boxes(&self) -> &[Vec] { + &self.preserved_flat_trak_boxes + } + + pub(crate) fn with_preserved_flat_trak_boxes( + mut self, + preserved_flat_trak_boxes: Vec>, + ) -> Self { + self.preserved_flat_trak_boxes = preserved_flat_trak_boxes; + self + } +} + +/// Errors returned by the additive mux foundation helpers. +#[derive(Debug)] +pub enum MuxError { + /// One public mux track spec did not match the fixed supported grammar. + InvalidTrackSpec { spec: String, message: String }, + /// The current fragmented mux request selected more than one video track for one output. + MultipleVideoTracks { count: usize }, + /// The current mux request did not carry any tracks. + MissingTrackSpecs, + /// One requested MP4 track selector did not resolve to a matching track. + MissingTrackSelection { spec: String }, + /// One track import was recognized but is not supported by the current mux follow-on. + UnsupportedTrackImport { spec: String, message: String }, + /// One duration-boundary mode conflicts with the current request shape or requested value. + InvalidDurationMode { mode: &'static str, message: String }, + /// One explicit mux output layout conflicts with the current request shape. + InvalidOutputLayout { + layout: &'static str, + message: String, + }, + /// One explicit destination mode conflicts with the current request shape. + InvalidDestinationMode { mode: &'static str, message: String }, + /// The output path conflicts with one of the supplied input paths. + OutputPathConflict { output: PathBuf, input: PathBuf }, + /// One track timeline could not be normalized onto the selected movie timescale exactly. + IncompatibleTrackTiming { + track_id: u32, + track_timescale: u32, + movie_timescale: u32, + value: i64, + }, + /// One chunk or segment coordination plan was internally inconsistent. + InvalidChunkPlan { track_id: u32, message: String }, + /// The planned payload would overflow a 64-bit output offset or size. + PayloadSizeOverflow, + /// One planned item referenced a staged source index that was not provided by the caller. + MissingSourceIndex { + source_index: usize, + source_count: usize, + }, + /// A progressive source would need to seek backward to satisfy the staged plan. + NonMonotonicSourceOffset { + source_index: usize, + previous_offset: u64, + next_offset: u64, + }, + /// A progressive source ended before it reached the requested staged offset. + IncompleteAdvance { + source_index: usize, + expected_offset: u64, + actual_offset: u64, + }, + /// A source did not produce the number of bytes described by the plan. + IncompleteCopy { + source_index: usize, + expected_size: u64, + actual_size: u64, + }, + /// The real mux surface requires a non-zero movie timescale. + InvalidMovieTimescale, + /// One real mux track configuration used a zero or otherwise incompatible media timescale. + InvalidTrackTimescale { track_id: u32 }, + /// One real mux track language code was not a valid three-letter ISO-639-2 code. + InvalidTrackLanguage { track_id: u32, language: String }, + /// More than one track configuration used the same track identifier. + DuplicateTrackId { track_id: u32 }, + /// The plan referenced a track that was not configured for the real mux surface. + MissingTrackId { track_id: u32 }, + /// One configured track had no planned samples. + TrackHasNoSamples { track_id: u32 }, + /// One track regressed in decode ordering inside the mux event graph. + NonMonotonicTrackDecodeTime { + track_id: u32, + previous_decode_time: u64, + next_decode_time: u64, + }, + /// One configured sample-entry box was not a single valid encoded box. + InvalidSampleEntryBox { track_id: u32, message: String }, + /// The real mux layout overflowed one container field. + LayoutOverflow(&'static str), + /// A typed box payload could not be encoded. + Codec(CodecError), + /// A container box could not be written or finalized. + Writer(WriterError), + /// A box header could not be parsed or encoded. + Header(HeaderError), + /// One typed extract helper failed while importing a track. + Extract(crate::extract::ExtractError), + /// One typed probe helper failed while importing a track. + Probe(crate::probe::ProbeError), + /// An I/O error occurred while reading staged payloads or writing output bytes. + Io(io::Error), +} + +impl fmt::Display for MuxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidTrackSpec { spec, message } => { + write!(f, "invalid mux track spec `{spec}`: {message}") + } + Self::MultipleVideoTracks { count } => write!( + f, + "fragmented output supports at most one video track per mux output, but {count} were requested" + ), + Self::MissingTrackSpecs => { + write!( + f, + "the current mux surface requires at least one `--track` input" + ) + } + Self::MissingTrackSelection { spec } => { + write!( + f, + "mux track spec `{spec}` did not resolve to a matching input track" + ) + } + Self::UnsupportedTrackImport { spec, message } => { + write!(f, "mux track spec `{spec}` is not supported: {message}") + } + Self::InvalidDurationMode { mode, message } => { + write!(f, "invalid mux {mode}: {message}") + } + Self::InvalidOutputLayout { layout, message } => { + write!(f, "invalid mux layout `{layout}`: {message}") + } + Self::InvalidDestinationMode { mode, message } => { + write!(f, "invalid mux destination mode `{mode}`: {message}") + } + Self::OutputPathConflict { output, input } => write!( + f, + "output path `{}` conflicts with input `{}`", + output.display(), + input.display() + ), + Self::IncompatibleTrackTiming { + track_id, + track_timescale, + movie_timescale, + value, + } => write!( + f, + "track {track_id} timing value {value} from timescale {track_timescale} cannot be normalized exactly onto movie timescale {movie_timescale}" + ), + Self::InvalidChunkPlan { track_id, message } => { + write!( + f, + "track {track_id} produced an invalid chunk plan: {message}" + ) + } + Self::PayloadSizeOverflow => { + write!(f, "planned mux payload size overflowed the supported range") + } + Self::MissingSourceIndex { + source_index, + source_count, + } => write!( + f, + "mux plan referenced source index {source_index}, but only {source_count} sources were provided" + ), + Self::NonMonotonicSourceOffset { + source_index, + previous_offset, + next_offset, + } => write!( + f, + "source index {source_index} would need to move backward from offset {previous_offset} to {next_offset}" + ), + Self::IncompleteAdvance { + source_index, + expected_offset, + actual_offset, + } => write!( + f, + "source index {source_index} ended while advancing to offset {expected_offset}; only reached {actual_offset}" + ), + Self::IncompleteCopy { + source_index, + expected_size, + actual_size, + } => write!( + f, + "source index {source_index} produced {actual_size} bytes, expected {expected_size}" + ), + Self::InvalidMovieTimescale => { + write!(f, "real mux output requires a non-zero movie timescale") + } + Self::InvalidTrackTimescale { track_id } => { + write!( + f, + "track {track_id} uses an invalid or incompatible media timescale for the planned mux timeline" + ) + } + Self::InvalidTrackLanguage { track_id, language } => write!( + f, + "track {track_id} uses invalid language code `{language}`; expected three ASCII letters" + ), + Self::DuplicateTrackId { track_id } => { + write!(f, "duplicate mux track id {track_id}") + } + Self::MissingTrackId { track_id } => { + write!( + f, + "mux plan referenced track id {track_id}, but no matching track configuration was provided" + ) + } + Self::TrackHasNoSamples { track_id } => { + write!(f, "mux track {track_id} has no planned samples") + } + Self::NonMonotonicTrackDecodeTime { + track_id, + previous_decode_time, + next_decode_time, + } => write!( + f, + "track {track_id} regressed in decode order from {previous_decode_time} to {next_decode_time}" + ), + Self::InvalidSampleEntryBox { track_id, message } => write!( + f, + "track {track_id} provided an invalid sample-entry box: {message}" + ), + Self::LayoutOverflow(field) => write!( + f, + "real mux layout overflowed the supported range while building {field}" + ), + Self::Codec(error) => error.fmt(f), + Self::Writer(error) => error.fmt(f), + Self::Header(error) => error.fmt(f), + Self::Extract(error) => error.fmt(f), + Self::Probe(error) => error.fmt(f), + Self::Io(error) => write!(f, "{error}"), + } + } +} + +impl MuxError { + /// Stable coarse category label for additive mux diagnostics. + pub fn category(&self) -> &'static str { + match self { + Self::InvalidTrackSpec { .. } + | Self::MultipleVideoTracks { .. } + | Self::MissingTrackSpecs + | Self::MissingTrackSelection { .. } + | Self::InvalidDurationMode { .. } + | Self::InvalidOutputLayout { .. } + | Self::InvalidDestinationMode { .. } + | Self::OutputPathConflict { .. } + | Self::InvalidMovieTimescale + | Self::InvalidTrackTimescale { .. } + | Self::InvalidTrackLanguage { .. } => "input", + Self::UnsupportedTrackImport { .. } => "unsupported", + Self::IncompatibleTrackTiming { .. } | Self::NonMonotonicTrackDecodeTime { .. } => { + "timing" + } + Self::InvalidChunkPlan { .. } + | Self::PayloadSizeOverflow + | Self::MissingSourceIndex { .. } + | Self::NonMonotonicSourceOffset { .. } + | Self::IncompleteAdvance { .. } + | Self::IncompleteCopy { .. } + | Self::DuplicateTrackId { .. } + | Self::MissingTrackId { .. } + | Self::TrackHasNoSamples { .. } + | Self::InvalidSampleEntryBox { .. } + | Self::LayoutOverflow(_) => "layout", + Self::Codec(_) | Self::Writer(_) | Self::Header(_) => "writer", + Self::Extract(_) | Self::Probe(_) => "input", + Self::Io(_) => "io", + } + } + + /// Stable coarse stage label for additive mux diagnostics. + pub fn stage(&self) -> &'static str { + match self { + Self::InvalidTrackSpec { .. } + | Self::MultipleVideoTracks { .. } + | Self::MissingTrackSpecs + | Self::InvalidDurationMode { .. } + | Self::InvalidOutputLayout { .. } + | Self::InvalidDestinationMode { .. } + | Self::OutputPathConflict { .. } => "request", + Self::MissingTrackSelection { .. } + | Self::UnsupportedTrackImport { .. } + | Self::Extract(_) + | Self::Probe(_) => "import", + Self::IncompatibleTrackTiming { .. } + | Self::InvalidChunkPlan { .. } + | Self::PayloadSizeOverflow + | Self::MissingSourceIndex { .. } + | Self::InvalidMovieTimescale + | Self::InvalidTrackTimescale { .. } + | Self::InvalidTrackLanguage { .. } + | Self::DuplicateTrackId { .. } + | Self::MissingTrackId { .. } + | Self::TrackHasNoSamples { .. } + | Self::NonMonotonicTrackDecodeTime { .. } + | Self::InvalidSampleEntryBox { .. } + | Self::LayoutOverflow(_) => "plan", + Self::NonMonotonicSourceOffset { .. } + | Self::IncompleteAdvance { .. } + | Self::IncompleteCopy { .. } + | Self::Io(_) => "payload", + Self::Codec(_) | Self::Writer(_) | Self::Header(_) => "write", + } + } +} + +impl Error for MuxError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Codec(error) => Some(error), + Self::Writer(error) => Some(error), + Self::Header(error) => Some(error), + Self::Extract(error) => Some(error), + Self::Probe(error) => Some(error), + Self::Io(error) => Some(error), + _ => None, + } + } +} + +impl From for MuxError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl From for MuxError { + fn from(error: CodecError) -> Self { + Self::Codec(error) + } +} + +impl From for MuxError { + fn from(error: WriterError) -> Self { + Self::Writer(error) + } +} + +impl From for MuxError { + fn from(error: HeaderError) -> Self { + Self::Header(error) + } +} + +impl From for MuxError { + fn from(error: crate::extract::ExtractError) -> Self { + Self::Extract(error) + } +} + +impl From for MuxError { + fn from(error: crate::probe::ProbeError) -> Self { + Self::Probe(error) + } +} + +/// Plans one output payload order from staged media items using the selected interleave policy. +pub fn plan_staged_media_items( + items: Vec, + interleave_policy: MuxInterleavePolicy, +) -> Result { + plan_staged_media_items_with_coordination(items, interleave_policy, Vec::new()) +} + +/// Plans one output payload order with explicit per-track chunk sample counts. +/// +/// The chunk counts are required by fragmented low-level writers because each media fragment is +/// built from one chunk ordinal across the participating tracks. The counts for each track must +/// cover that track's staged samples exactly. +pub fn plan_staged_media_items_with_chunk_sample_counts( + items: Vec, + interleave_policy: MuxInterleavePolicy, + chunk_sample_counts_by_track: I, +) -> Result +where + I: IntoIterator)>, +{ + let coordination = chunk_sample_counts_by_track + .into_iter() + .map(|(track_id, chunk_sample_counts)| { + TrackCoordinationDirective::new(track_id, chunk_sample_counts) + }) + .collect(); + plan_staged_media_items_with_coordination(items, interleave_policy, coordination) +} + +pub(crate) fn plan_staged_media_items_with_coordination( + items: Vec, + interleave_policy: MuxInterleavePolicy, + coordination_directives: Vec, +) -> Result { + let mut queue_items = items + .into_iter() + .map(MuxQueueItem::from_staged) + .collect::>(); + + match interleave_policy { + MuxInterleavePolicy::DecodeTime | MuxInterleavePolicy::ChunkOrdinalThenSource => { + // Keep equal decode-time items stable by source and byte offset before the queue + // layer applies the decode-time ordering key. This preserves path-first merge order + // even when a carried track keeps a large external track identifier such as a TS PID. + queue_items.sort_by_key(|item| { + ( + item.staged.source_index, + item.staged.data_offset, + item.staged.track_id, + ) + }); + } + } + + let queue = OrderedWorkQueue::new(queue_items); + let mut items_by_track = BTreeMap::>::new(); + let mut track_state = BTreeMap::::new(); + + for item in queue.iter() { + let end_decode_time = item + .staged + .decode_time + .checked_add(u64::from(item.staged.duration)) + .ok_or(MuxError::PayloadSizeOverflow)?; + items_by_track + .entry(item.staged.track_id) + .or_default() + .push(item.staged); + track_state + .entry(item.staged.track_id) + .and_modify(|state| { + state.item_count += 1; + state.end_decode_time = state.end_decode_time.max(end_decode_time); + state.first_decode_time = state.first_decode_time.min(item.staged.decode_time); + }) + .or_insert(MuxTrackPlanState { + item_count: 1, + first_decode_time: item.staged.decode_time, + end_decode_time, + }); + } + + let track_plans = track_state + .into_iter() + .map(|(track_id, state)| MuxTrackPlan { + track_id, + item_count: state.item_count, + first_decode_time: state.first_decode_time, + end_decode_time: state.end_decode_time, + }) + .collect::>(); + + let coordination = + MuxCoordinationPlan::from_track_plans(&track_plans, coordination_directives)?; + let (planned_items, total_payload_size) = + build_planned_items_from_tracks(&items_by_track, &coordination, interleave_policy)?; + let event_graph = MuxEventGraph::from_plan( + &planned_items, + &track_plans, + total_payload_size, + &coordination, + ); + + Ok(MuxPlan { + interleave_policy, + planned_items, + track_plans, + total_payload_size, + coordination, + event_graph, + }) +} + +/// Writes one real MP4 file to `writer` from staged seekable `sources`, `plan`, and track +/// metadata. +/// +/// This higher-level mux surface assembles `ftyp`, `moov`, and `mdat` around the staged sample +/// order produced by [`plan_staged_media_items`]. The lower-level payload-copy helpers remain +/// available for callers that only need interleaved raw payload output. +pub fn write_mp4_mux( + sources: &mut [R], + writer: &mut W, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: Read + Seek, + W: Write, +{ + mp4::write_mp4_mux(sources, writer, file_config, track_configs, plan) +} + +/// Opens staged source files and writes one real MP4 file to `output_path`. +pub fn write_mp4_mux_to_path( + source_paths: &[P], + output_path: Q, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + plan: &MuxPlan, +) -> Result<(), MuxError> +where + P: AsRef, + Q: AsRef, +{ + mp4::write_mp4_mux_to_path(source_paths, output_path, file_config, track_configs, plan) +} + +/// Writes one fragmented MP4 to `writer` from staged seekable `sources`, `plan`, and track +/// metadata. +/// +/// The emitted byte stream is one initialization section, one top-level index when present, and +/// one or more media fragments in the same order as the high-level fragmented mux request path. +pub fn write_fragmented_mp4_mux( + sources: &mut [R], + writer: &mut W, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: Read + Seek, + W: Write, +{ + mp4::write_fragmented_mp4_mux( + sources, + writer, + file_config, + track_configs, + single_sidx_reference, + plan, + ) +} + +/// Opens staged source files and writes one fragmented MP4 file to `output_path`. +pub fn write_fragmented_mp4_mux_to_path( + source_paths: &[P], + output_path: Q, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + P: AsRef, + Q: AsRef, +{ + let mut sources = source_paths + .iter() + .map(File::open) + .collect::, _>>()?; + let mut writer = BufWriter::new(File::create(output_path)?); + write_fragmented_mp4_mux( + &mut sources, + &mut writer, + file_config, + track_configs, + single_sidx_reference, + plan, + ) +} + +/// Writes fragmented initialization bytes and media-fragment bytes to separate writers. +/// +/// Concatenating the init writer output followed by the media writer output yields the same byte +/// stream as [`write_fragmented_mp4_mux`] except for volatile creation-time fields. +pub fn write_fragmented_mp4_mux_split( + sources: &mut [R], + init_writer: &mut I, + media_writer: &mut M, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: Read + Seek, + I: Write, + M: Write, +{ + mp4::write_fragmented_mp4_mux_split( + sources, + init_writer, + media_writer, + file_config, + track_configs, + single_sidx_reference, + plan, + ) +} + +/// Writes fragmented initialization bytes and standalone media-segment bytes to separate writers. +/// +/// The media writer receives concatenated standalone media-segment units. Each unit starts with a +/// segment type box, then a local segment index, then the media fragment bytes. +pub fn write_fragmented_mp4_mux_segmented( + sources: &mut [R], + init_writer: &mut I, + media_writer: &mut M, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: Read + Seek, + I: Write, + M: Write, +{ + mp4::write_fragmented_mp4_mux_segmented( + sources, + init_writer, + media_writer, + file_config, + track_configs, + plan, + ) +} + +/// Opens staged source files and writes fragmented init/media outputs to separate paths. +pub fn write_fragmented_mp4_mux_split_to_paths( + source_paths: &[P], + init_path: I, + media_path: M, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + P: AsRef, + I: AsRef, + M: AsRef, +{ + let mut sources = source_paths + .iter() + .map(File::open) + .collect::, _>>()?; + let mut init_writer = BufWriter::new(File::create(init_path)?); + let mut media_writer = BufWriter::new(File::create(media_path)?); + write_fragmented_mp4_mux_split( + &mut sources, + &mut init_writer, + &mut media_writer, + file_config, + track_configs, + single_sidx_reference, + plan, + ) +} + +/// Writes one fragmented MP4 and flushes the writer after the top-level index and after each +/// media fragment. +/// +/// This preserves the same final bytes as [`write_fragmented_mp4_mux`] while exposing deterministic +/// flush points for callers that send the stream incrementally. +pub fn write_fragmented_mp4_mux_chunked( + sources: &mut [R], + writer: &mut W, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: Read + Seek, + W: Write, +{ + mp4::write_fragmented_mp4_mux_chunked( + sources, + writer, + file_config, + track_configs, + single_sidx_reference, + plan, + ) +} + +/// Writes one real MP4 file through the additive Tokio-based async mux surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(all(feature = "mux", feature = "async"))))] +pub async fn write_mp4_mux_async( + sources: &mut [R], + writer: &mut W, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + mp4::write_mp4_mux_async(sources, writer, file_config, track_configs, plan).await +} + +/// Opens staged source files asynchronously and writes one real MP4 file to `output_path`. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(all(feature = "mux", feature = "async"))))] +pub async fn write_mp4_mux_to_path_async( + source_paths: &[P], + output_path: Q, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + plan: &MuxPlan, +) -> Result<(), MuxError> +where + P: AsRef, + Q: AsRef, +{ + mp4::write_mp4_mux_to_path_async(source_paths, output_path, file_config, track_configs, plan) + .await +} + +/// Writes one fragmented MP4 through the additive Tokio-based async mux surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(all(feature = "mux", feature = "async"))))] +pub async fn write_fragmented_mp4_mux_async( + sources: &mut [R], + writer: &mut W, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + mp4::write_fragmented_mp4_mux_async( + sources, + writer, + file_config, + track_configs, + single_sidx_reference, + plan, + ) + .await +} + +/// Opens staged source files asynchronously and writes one fragmented MP4 to `output_path`. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(all(feature = "mux", feature = "async"))))] +pub async fn write_fragmented_mp4_mux_to_path_async( + source_paths: &[P], + output_path: Q, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + P: AsRef, + Q: AsRef, +{ + let mut sources = Vec::with_capacity(source_paths.len()); + for path in source_paths { + sources.push(TokioFile::open(path).await?); + } + let output = TokioFile::create(output_path).await?; + let mut writer = tokio::io::BufWriter::new(output); + write_fragmented_mp4_mux_async( + &mut sources, + &mut writer, + file_config, + track_configs, + single_sidx_reference, + plan, + ) + .await +} + +/// Writes fragmented initialization bytes and media-fragment bytes to separate async writers. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(all(feature = "mux", feature = "async"))))] +pub async fn write_fragmented_mp4_mux_split_async( + sources: &mut [R], + init_writer: &mut I, + media_writer: &mut M, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: AsyncReadSeek, + I: AsyncWrite + Unpin, + M: AsyncWrite + Unpin, +{ + mp4::write_fragmented_mp4_mux_split_async( + sources, + init_writer, + media_writer, + file_config, + track_configs, + single_sidx_reference, + plan, + ) + .await +} + +/// Writes fragmented initialization bytes and standalone media-segment bytes to separate async +/// writers. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(all(feature = "mux", feature = "async"))))] +pub async fn write_fragmented_mp4_mux_segmented_async( + sources: &mut [R], + init_writer: &mut I, + media_writer: &mut M, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: AsyncReadSeek, + I: AsyncWrite + Unpin, + M: AsyncWrite + Unpin, +{ + mp4::write_fragmented_mp4_mux_segmented_async( + sources, + init_writer, + media_writer, + file_config, + track_configs, + plan, + ) + .await +} + +/// Writes one fragmented MP4 asynchronously and flushes after the top-level index and each media +/// fragment. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(all(feature = "mux", feature = "async"))))] +pub async fn write_fragmented_mp4_mux_chunked_async( + sources: &mut [R], + writer: &mut W, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + mp4::write_fragmented_mp4_mux_chunked_async( + sources, + writer, + file_config, + track_configs, + single_sidx_reference, + plan, + ) + .await +} + +/// Copies the payload bytes described by `plan` from the staged seekable `sources` into +/// `writer`. +pub fn copy_planned_payloads( + sources: &mut [R], + writer: &mut W, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: Read + Seek, + W: Write, +{ + let mut cursor = plan.event_graph.cursor(); + while let Some(sample) = cursor.next_sample() { + let staged = sample.planned_item().staged(); + let Some(source) = sources.get_mut(staged.source_index()) else { + return Err(MuxError::MissingSourceIndex { + source_index: staged.source_index(), + source_count: sources.len(), + }); + }; + + source.seek(SeekFrom::Start(staged.data_offset()))?; + let mut limited = source.take(u64::from(staged.data_size())); + let copied = io::copy(&mut limited, writer)?; + if copied != u64::from(staged.data_size()) { + return Err(MuxError::IncompleteCopy { + source_index: staged.source_index(), + expected_size: u64::from(staged.data_size()), + actual_size: copied, + }); + } + } + + Ok(()) +} + +/// Copies the payload bytes described by `plan` from staged non-seekable `sources` into `writer`. +/// +/// This progressive path keeps one forward-only read cursor per source. It supports plans whose +/// staged items consume each source in monotonic byte-offset order, and it reports a structured +/// error when a caller asks it to seek backward implicitly. +pub fn copy_planned_payloads_progressive( + sources: &mut [R], + writer: &mut W, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: Read, + W: Write, +{ + let mut source_offsets = vec![0_u64; sources.len()]; + let mut cursor = plan.event_graph.cursor(); + while let Some(sample) = cursor.next_sample() { + let staged = sample.planned_item().staged(); + let Some(source) = sources.get_mut(staged.source_index()) else { + return Err(MuxError::MissingSourceIndex { + source_index: staged.source_index(), + source_count: sources.len(), + }); + }; + + let source_offset = source_offsets.get_mut(staged.source_index()).unwrap(); + advance_progressive_source( + source, + staged.source_index(), + source_offset, + staged.data_offset(), + )?; + copy_progressive_payload( + source, + writer, + staged.source_index(), + source_offset, + u64::from(staged.data_size()), + )?; + } + + Ok(()) +} + +/// Opens staged source files and copies the payload bytes described by `plan` into `output_path`. +pub fn copy_planned_payloads_to_path( + source_paths: &[P], + output_path: Q, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + P: AsRef, + Q: AsRef, +{ + let mut sources = source_paths + .iter() + .map(File::open) + .collect::, _>>()?; + let mut writer = BufWriter::new(File::create(output_path)?); + copy_planned_payloads(&mut sources, &mut writer, plan) +} + +/// Copies the payload bytes described by `plan` from the staged seekable async `sources` into +/// `writer`. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(all(feature = "mux", feature = "async"))))] +pub async fn copy_planned_payloads_async( + sources: &mut [R], + writer: &mut W, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + let mut buffer = vec![0_u8; 16 * 1024]; + let mut cursor = plan.event_graph.cursor(); + while let Some(sample) = cursor.next_sample() { + let staged = sample.planned_item().staged(); + let Some(source) = sources.get_mut(staged.source_index()) else { + return Err(MuxError::MissingSourceIndex { + source_index: staged.source_index(), + source_count: sources.len(), + }); + }; + + source.seek(SeekFrom::Start(staged.data_offset())).await?; + let mut remaining = u64::from(staged.data_size()); + let mut copied = 0_u64; + while remaining > 0 { + let chunk_len = remaining.min(buffer.len() as u64) as usize; + let read = source.read(&mut buffer[..chunk_len]).await?; + if read == 0 { + break; + } + writer.write_all(&buffer[..read]).await?; + copied += read as u64; + remaining -= read as u64; + } + + if copied != u64::from(staged.data_size()) { + return Err(MuxError::IncompleteCopy { + source_index: staged.source_index(), + expected_size: u64::from(staged.data_size()), + actual_size: copied, + }); + } + } + + writer.flush().await?; + Ok(()) +} + +/// Copies the payload bytes described by `plan` from staged non-seekable async `sources` into +/// `writer`. +/// +/// Like [`copy_planned_payloads_progressive`], this path supports only plans whose staged items +/// consume each source in monotonic byte-offset order. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(all(feature = "mux", feature = "async"))))] +pub async fn copy_planned_payloads_async_progressive( + sources: &mut [R], + writer: &mut W, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: AsyncReadForward, + W: AsyncWriteForward, +{ + let mut source_offsets = vec![0_u64; sources.len()]; + let mut buffer = vec![0_u8; 16 * 1024]; + let mut cursor = plan.event_graph.cursor(); + while let Some(sample) = cursor.next_sample() { + let staged = sample.planned_item().staged(); + let Some(source) = sources.get_mut(staged.source_index()) else { + return Err(MuxError::MissingSourceIndex { + source_index: staged.source_index(), + source_count: sources.len(), + }); + }; + + let source_offset = source_offsets.get_mut(staged.source_index()).unwrap(); + advance_progressive_source_async( + source, + staged.source_index(), + source_offset, + staged.data_offset(), + &mut buffer, + ) + .await?; + copy_progressive_payload_async( + source, + writer, + staged.source_index(), + source_offset, + u64::from(staged.data_size()), + &mut buffer, + ) + .await?; + } + + writer.flush().await?; + Ok(()) +} + +/// Opens staged source files asynchronously and copies the payload bytes described by `plan` into +/// `output_path`. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(all(feature = "mux", feature = "async"))))] +pub async fn copy_planned_payloads_to_path_async( + source_paths: &[P], + output_path: Q, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + P: AsRef, + Q: AsRef, +{ + let mut sources = Vec::with_capacity(source_paths.len()); + for path in source_paths { + sources.push(TokioFile::open(path).await?); + } + let mut writer = TokioFile::create(output_path).await?; + copy_planned_payloads_async(&mut sources, &mut writer, plan).await +} + +struct MuxQueueItem { + staged: MuxStagedMediaItem, +} + +impl MuxQueueItem { + fn from_staged(staged: MuxStagedMediaItem) -> Self { + Self { staged } + } +} + +impl QueueWorkItem for MuxQueueItem { + fn queue_order_key(&self) -> u64 { + self.staged.decode_time + } +} + +struct MuxTrackPlanState { + item_count: u32, + first_decode_time: u64, + end_decode_time: u64, +} + +#[derive(Clone, Copy)] +struct PlannedChunk { + chunk_index: usize, + order_key: PlannedChunkOrderKey, + track_id: u32, + start_index: usize, + end_index: usize, +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct PlannedChunkOrderKey { + decode_time: u64, + source_index: usize, + data_offset: u64, + track_id: u32, +} + +fn build_planned_items_from_tracks( + items_by_track: &BTreeMap>, + coordination: &MuxCoordinationPlan, + interleave_policy: MuxInterleavePolicy, +) -> Result<(Vec, u64), MuxError> { + let mut chunks = Vec::new(); + let total_sample_count = items_by_track.values().map(Vec::len).sum(); + for (&track_id, items) in items_by_track { + let chunk_sample_counts = coordination.chunk_sample_counts(track_id)?; + let mut start_index = 0_usize; + for (chunk_index, &samples_per_chunk) in chunk_sample_counts.iter().enumerate() { + let chunk_len = usize::try_from(samples_per_chunk) + .map_err(|_| MuxError::LayoutOverflow("chunk sample-count conversion"))?; + let end_index = start_index + .checked_add(chunk_len) + .ok_or(MuxError::LayoutOverflow("chunk sample indexing"))?; + let first_sample = + items + .get(start_index) + .ok_or_else(|| MuxError::InvalidChunkPlan { + track_id, + message: "chunk boundaries ran past the staged sample count".to_string(), + })?; + chunks.push(PlannedChunk { + chunk_index, + order_key: PlannedChunkOrderKey { + decode_time: first_sample.decode_time(), + source_index: first_sample.source_index(), + data_offset: first_sample.data_offset(), + track_id, + }, + track_id, + start_index, + end_index, + }); + start_index = end_index; + } + if start_index != items.len() { + return Err(MuxError::InvalidChunkPlan { + track_id, + message: "chunk boundaries did not cover every staged sample".to_string(), + }); + } + } + + match interleave_policy { + MuxInterleavePolicy::DecodeTime => { + chunks.sort_by_key(|chunk| chunk.order_key); + } + MuxInterleavePolicy::ChunkOrdinalThenSource => { + chunks.sort_by_key(|chunk| { + ( + chunk.chunk_index, + chunk.order_key.source_index, + chunk.order_key.data_offset, + chunk.order_key.track_id, + chunk.order_key.decode_time, + ) + }); + } + } + + let mut planned_items = Vec::with_capacity(total_sample_count); + let mut total_payload_size = 0_u64; + for chunk in chunks { + let items = items_by_track + .get(&chunk.track_id) + .ok_or(MuxError::MissingTrackId { + track_id: chunk.track_id, + })?; + for staged in &items[chunk.start_index..chunk.end_index] { + planned_items.push(MuxPlannedMediaItem { + staged: *staged, + output_offset: total_payload_size, + }); + total_payload_size = total_payload_size + .checked_add(u64::from(staged.data_size())) + .ok_or(MuxError::PayloadSizeOverflow)?; + } + } + + Ok((planned_items, total_payload_size)) +} + +fn advance_progressive_source( + source: &mut R, + source_index: usize, + current_offset: &mut u64, + target_offset: u64, +) -> Result<(), MuxError> +where + R: Read, +{ + if target_offset < *current_offset { + return Err(MuxError::NonMonotonicSourceOffset { + source_index, + previous_offset: *current_offset, + next_offset: target_offset, + }); + } + + let mut remaining = target_offset - *current_offset; + let mut buffer = [0_u8; 16 * 1024]; + while remaining > 0 { + let chunk_len = remaining.min(buffer.len() as u64) as usize; + let read = source.read(&mut buffer[..chunk_len])?; + if read == 0 { + return Err(MuxError::IncompleteAdvance { + source_index, + expected_offset: target_offset, + actual_offset: *current_offset, + }); + } + *current_offset += read as u64; + remaining -= read as u64; + } + + Ok(()) +} + +fn copy_progressive_payload( + source: &mut R, + writer: &mut W, + source_index: usize, + current_offset: &mut u64, + size: u64, +) -> Result<(), MuxError> +where + R: Read, + W: Write, +{ + let mut remaining = size; + let mut copied = 0_u64; + let mut buffer = [0_u8; 16 * 1024]; + while remaining > 0 { + let chunk_len = remaining.min(buffer.len() as u64) as usize; + let read = source.read(&mut buffer[..chunk_len])?; + if read == 0 { + return Err(MuxError::IncompleteCopy { + source_index, + expected_size: size, + actual_size: copied, + }); + } + writer.write_all(&buffer[..read])?; + *current_offset += read as u64; + copied += read as u64; + remaining -= read as u64; + } + + Ok(()) +} + +#[cfg(feature = "async")] +async fn advance_progressive_source_async( + source: &mut R, + source_index: usize, + current_offset: &mut u64, + target_offset: u64, + buffer: &mut [u8], +) -> Result<(), MuxError> +where + R: AsyncReadForward, +{ + if target_offset < *current_offset { + return Err(MuxError::NonMonotonicSourceOffset { + source_index, + previous_offset: *current_offset, + next_offset: target_offset, + }); + } + + let mut remaining = target_offset - *current_offset; + while remaining > 0 { + let chunk_len = remaining.min(buffer.len() as u64) as usize; + let read = source.read(&mut buffer[..chunk_len]).await?; + if read == 0 { + return Err(MuxError::IncompleteAdvance { + source_index, + expected_offset: target_offset, + actual_offset: *current_offset, + }); + } + *current_offset += read as u64; + remaining -= read as u64; + } + + Ok(()) +} + +#[cfg(feature = "async")] +async fn copy_progressive_payload_async( + source: &mut R, + writer: &mut W, + source_index: usize, + current_offset: &mut u64, + size: u64, + buffer: &mut [u8], +) -> Result<(), MuxError> +where + R: AsyncReadForward, + W: AsyncWriteForward, +{ + let mut remaining = size; + let mut copied = 0_u64; + while remaining > 0 { + let chunk_len = remaining.min(buffer.len() as u64) as usize; + let read = source.read(&mut buffer[..chunk_len]).await?; + if read == 0 { + return Err(MuxError::IncompleteCopy { + source_index, + expected_size: size, + actual_size: copied, + }); + } + writer.write_all(&buffer[..read]).await?; + *current_offset += read as u64; + copied += read as u64; + remaining -= read as u64; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coordinated_chunk_plans_keep_multi_sample_chunks_contiguous_in_output_order() { + let plan = plan_staged_media_items_with_coordination( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 10, 0, 4), + MuxStagedMediaItem::new(0, 1, 10, 10, 4, 4), + MuxStagedMediaItem::new(1, 2, 0, 10, 0, 3), + MuxStagedMediaItem::new(1, 2, 10, 10, 3, 3), + ], + MuxInterleavePolicy::DecodeTime, + vec![ + TrackCoordinationDirective::new(1, vec![2]), + TrackCoordinationDirective::new(2, vec![2]), + ], + ) + .unwrap(); + + let planned = plan.planned_items(); + assert_eq!(planned.len(), 4); + assert_eq!(planned[0].staged().track_id(), 1); + assert_eq!(planned[1].staged().track_id(), 1); + assert_eq!(planned[2].staged().track_id(), 2); + assert_eq!(planned[3].staged().track_id(), 2); + assert_eq!(planned[0].output_offset(), 0); + assert_eq!(planned[1].output_offset(), 4); + assert_eq!(planned[2].output_offset(), 8); + assert_eq!(planned[3].output_offset(), 11); + } + + #[test] + fn chunk_ordinal_interleave_keeps_aligned_chunks_in_source_pair_order() { + let plan = plan_staged_media_items_with_coordination( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 10, 0, 4), + MuxStagedMediaItem::new(0, 1, 10, 10, 4, 4), + MuxStagedMediaItem::new(0, 1, 20, 10, 8, 4), + MuxStagedMediaItem::new(0, 1, 30, 10, 12, 4), + MuxStagedMediaItem::new(1, 2, 0, 10, 0, 3), + MuxStagedMediaItem::new(1, 2, 15, 10, 3, 3), + MuxStagedMediaItem::new(1, 2, 31, 10, 6, 3), + ], + MuxInterleavePolicy::ChunkOrdinalThenSource, + vec![ + TrackCoordinationDirective::new(1, vec![1, 1, 1, 1]), + TrackCoordinationDirective::new(2, vec![1, 1, 1]), + ], + ) + .unwrap(); + + let track_order = plan + .planned_items() + .iter() + .map(|item| item.staged().track_id()) + .collect::>(); + assert_eq!(track_order, vec![1, 2, 1, 2, 1, 2, 1]); + } +} diff --git a/src/mux/mp4.rs b/src/mux/mp4.rs new file mode 100644 index 0000000..468b0f2 --- /dev/null +++ b/src/mux/mp4.rs @@ -0,0 +1,6772 @@ +use std::collections::{BTreeMap, btree_map::Entry}; +use std::fs::File; +use std::io::{Cursor, Read, Seek, Write}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt, BufWriter}; + +use crate::FourCc; +#[cfg(feature = "async")] +use crate::async_io::{AsyncReadSeek, AsyncWrite}; +use crate::boxes::AnyTypeBox; +use crate::boxes::etsi_ts_102_366::Dec3; +use crate::boxes::iso14496_12::{ + AudioSampleEntry, Btrt, Co64, Ctts, CttsEntry, Dinf, Dref, Edts, Elst, ElstEntry, Emsg, Ftyp, + Hdlr, Mdhd, Mdia, Mehd, Meta, Mfhd, Minf, Moof, Moov, Mvex, Mvhd, Nmhd, Pasp, Prft, + SampleEntry, Sbgp, SbgpEntry, Sgpd, Sidx, SidxReference, Smhd, Stbl, Stco, Sthd, Stsc, + StscEntry, Stsd, Stss, Stsz, Stts, SttsEntry, TFHD_DEFAULT_BASE_IS_MOOF, + TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, TFHD_DEFAULT_SAMPLE_FLAGS_PRESENT, + TFHD_DEFAULT_SAMPLE_SIZE_PRESENT, TFHD_SAMPLE_DESCRIPTION_INDEX_PRESENT, + TRUN_DATA_OFFSET_PRESENT, TRUN_SAMPLE_COMPOSITION_TIME_OFFSET_PRESENT, + TRUN_SAMPLE_DURATION_PRESENT, TRUN_SAMPLE_FLAGS_PRESENT, TRUN_SAMPLE_SIZE_PRESENT, Tfdt, Tfhd, + Tkhd, Traf, Trak, Trex, Trun, TrunEntry, Udta, Url, VisualRandomAccessEntry, VisualSampleEntry, + Vmhd, split_box_children_with_optional_trailing_bytes, +}; +use crate::boxes::iso14496_14::{ + Descriptor, ES_DESCRIPTOR_TAG, Esds, InitialObjectDescriptor, Iods, +}; +use crate::boxes::metadata::{DATA_TYPE_STRING_UTF8, Data, Id32, Ilst, IlstMetaContainer}; +use crate::codec::{CodecBox, ImmutableBox, MutableBox, marshal, unmarshal}; +use crate::header::BoxInfo; +use crate::probe::{detect_aac_effective_sample_rate, detect_aac_profile}; + +#[cfg(feature = "async")] +use super::copy_planned_payloads_async; +use super::{ + MuxError, MuxFileConfig, MuxPlan, MuxTrackConfig, MuxTrackKind, copy_planned_payloads, +}; + +const IDENTITY_MATRIX: [i32; 9] = [0x0001_0000, 0, 0, 0, 0x0001_0000, 0, 0, 0, 0x4000_0000]; +const VMHD_DEFAULT_FLAGS: u32 = 0x0000_0001; +const NON_KEY_SAMPLE_FLAGS: u32 = 0x0001_0000; +const CSLG: FourCc = FourCc::from_bytes(*b"cslg"); +const SBGP: FourCc = FourCc::from_bytes(*b"sbgp"); +const SGPD: FourCc = FourCc::from_bytes(*b"sgpd"); +const SDSM: FourCc = FourCc::from_bytes(*b"sdsm"); +const ISOM_UNIX_EPOCH_OFFSET: u64 = 2_082_844_800; +const AUTO_FLAT_MOVIE_TIMESCALE: u32 = 600; +const DEFAULT_FREE_PADDING_SIZE: usize = 67; +const FLAT_TOOL_METADATA_VALUE: &str = + concat!(env!("CARGO_PKG_NAME"), " v", env!("CARGO_PKG_VERSION")); +const FRAGMENTED_ID3_OWNER: &str = env!("CARGO_PKG_REPOSITORY"); +const FRAGMENTED_ID3_VALUE: &str = concat!(env!("CARGO_PKG_NAME"), " v", env!("CARGO_PKG_VERSION")); +const DEFAULT_FRAGMENTED_TKHD_FLAGS: u32 = 0x0000_0001 | 0x0000_0002 | 0x0000_0004; +const MAX_SIDX_REFERENCES: usize = u16::MAX as usize; + +pub(super) fn write_mp4_mux( + sources: &mut [R], + writer: &mut W, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: Read + Seek, + W: Write, +{ + let layout = build_container_layout(file_config, track_configs, plan)?; + writer.write_all(&layout.ftyp_bytes)?; + if !layout.leading_bytes.is_empty() { + writer.write_all(&layout.leading_bytes)?; + } + writer.write_all(&layout.moov_bytes)?; + writer.write_all(&layout.mdat_header)?; + copy_planned_payloads(sources, writer, plan)?; + if !layout.trailing_bytes.is_empty() { + writer.write_all(&layout.trailing_bytes)?; + } + writer.flush()?; + Ok(()) +} + +pub(super) fn write_mp4_mux_to_path( + source_paths: &[P], + output_path: Q, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + plan: &MuxPlan, +) -> Result<(), MuxError> +where + P: AsRef, + Q: AsRef, +{ + let mut sources = source_paths + .iter() + .map(File::open) + .collect::, _>>()?; + let mut writer = std::io::BufWriter::new(File::create(output_path)?); + write_mp4_mux(&mut sources, &mut writer, file_config, track_configs, plan) +} + +#[cfg(feature = "async")] +pub(super) async fn write_mp4_mux_async( + sources: &mut [R], + writer: &mut W, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + let layout = build_container_layout(file_config, track_configs, plan)?; + writer.write_all(&layout.ftyp_bytes).await?; + if !layout.leading_bytes.is_empty() { + writer.write_all(&layout.leading_bytes).await?; + } + writer.write_all(&layout.moov_bytes).await?; + writer.write_all(&layout.mdat_header).await?; + copy_planned_payloads_async(sources, writer, plan).await?; + if !layout.trailing_bytes.is_empty() { + writer.write_all(&layout.trailing_bytes).await?; + } + writer.flush().await?; + Ok(()) +} + +#[cfg(feature = "async")] +pub(super) async fn write_mp4_mux_to_path_async( + source_paths: &[P], + output_path: Q, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + plan: &MuxPlan, +) -> Result<(), MuxError> +where + P: AsRef, + Q: AsRef, +{ + let mut sources = Vec::with_capacity(source_paths.len()); + for path in source_paths { + sources.push(TokioFile::open(path).await?); + } + let output = TokioFile::create(output_path).await?; + let mut writer = BufWriter::new(output); + write_mp4_mux_async(&mut sources, &mut writer, file_config, track_configs, plan).await +} + +pub(super) fn write_fragmented_mp4_mux( + sources: &mut [R], + writer: &mut W, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: Read + Seek, + W: Write, +{ + let layout = build_fragmented_layout(file_config, track_configs, single_sidx_reference, plan)?; + write_fragmented_init(&layout, writer)?; + write_fragmented_media(sources, writer, &layout, false)?; + writer.flush()?; + Ok(()) +} + +pub(super) fn write_fragmented_mp4_mux_split( + sources: &mut [R], + init_writer: &mut I, + media_writer: &mut M, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: Read + Seek, + I: Write, + M: Write, +{ + let layout = build_fragmented_layout(file_config, track_configs, single_sidx_reference, plan)?; + write_fragmented_init(&layout, init_writer)?; + init_writer.flush()?; + write_fragmented_media(sources, media_writer, &layout, false)?; + media_writer.flush()?; + Ok(()) +} + +pub(super) fn write_fragmented_mp4_mux_segmented( + sources: &mut [R], + init_writer: &mut I, + media_writer: &mut M, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: Read + Seek, + I: Write, + M: Write, +{ + let layout = build_fragmented_segmented_layout(file_config, track_configs, plan)?; + write_fragmented_init(&layout, init_writer)?; + init_writer.flush()?; + write_fragmented_media(sources, media_writer, &layout, false)?; + media_writer.flush()?; + Ok(()) +} + +pub(super) fn write_fragmented_mp4_mux_chunked( + sources: &mut [R], + writer: &mut W, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: Read + Seek, + W: Write, +{ + let layout = build_fragmented_layout(file_config, track_configs, single_sidx_reference, plan)?; + write_fragmented_init(&layout, writer)?; + write_fragmented_media(sources, writer, &layout, true)?; + Ok(()) +} + +fn write_fragmented_init(layout: &FragmentedLayout, writer: &mut W) -> Result<(), MuxError> +where + W: Write, +{ + writer.write_all(&layout.ftyp_bytes)?; + writer.write_all(&layout.moov_bytes)?; + Ok(()) +} + +fn write_fragmented_media( + sources: &mut [R], + writer: &mut W, + layout: &FragmentedLayout, + flush_each_fragment: bool, +) -> Result<(), MuxError> +where + R: Read + Seek, + W: Write, +{ + writer.write_all(&layout.sidx_bytes)?; + if flush_each_fragment { + writer.flush()?; + } + for (fragment_index, fragment) in layout.fragments.iter().enumerate() { + write_fragmented_media_fragment( + sources, + writer, + fragment, + layout + .segment_index_bytes + .get(fragment_index) + .map(Vec::as_slice), + )?; + if flush_each_fragment { + writer.flush()?; + } + } + Ok(()) +} + +fn write_fragmented_media_fragment( + sources: &mut [R], + writer: &mut W, + fragment: &FragmentLayout, + segment_index_bytes: Option<&[u8]>, +) -> Result<(), MuxError> +where + R: Read + Seek, + W: Write, +{ + writer.write_all(&fragment.segment_type_bytes)?; + if let Some(segment_index_bytes) = segment_index_bytes { + writer.write_all(segment_index_bytes)?; + } + for metadata_bytes in &fragment.metadata_bytes { + writer.write_all(metadata_bytes)?; + } + writer.write_all(&fragment.moof_bytes)?; + writer.write_all(&fragment.mdat_header)?; + copy_fragment_payloads(sources, writer, fragment)?; + Ok(()) +} + +#[cfg(feature = "async")] +pub(super) async fn write_fragmented_mp4_mux_async( + sources: &mut [R], + writer: &mut W, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + let layout = build_fragmented_layout(file_config, track_configs, single_sidx_reference, plan)?; + write_fragmented_init_async(&layout, writer).await?; + write_fragmented_media_async(sources, writer, &layout, false).await?; + writer.flush().await?; + Ok(()) +} + +#[cfg(feature = "async")] +pub(super) async fn write_fragmented_mp4_mux_split_async( + sources: &mut [R], + init_writer: &mut I, + media_writer: &mut M, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: AsyncReadSeek, + I: AsyncWrite + Unpin, + M: AsyncWrite + Unpin, +{ + let layout = build_fragmented_layout(file_config, track_configs, single_sidx_reference, plan)?; + write_fragmented_init_async(&layout, init_writer).await?; + init_writer.flush().await?; + write_fragmented_media_async(sources, media_writer, &layout, false).await?; + media_writer.flush().await?; + Ok(()) +} + +#[cfg(feature = "async")] +pub(super) async fn write_fragmented_mp4_mux_segmented_async( + sources: &mut [R], + init_writer: &mut I, + media_writer: &mut M, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: AsyncReadSeek, + I: AsyncWrite + Unpin, + M: AsyncWrite + Unpin, +{ + let layout = build_fragmented_segmented_layout(file_config, track_configs, plan)?; + write_fragmented_init_async(&layout, init_writer).await?; + init_writer.flush().await?; + write_fragmented_media_async(sources, media_writer, &layout, false).await?; + media_writer.flush().await?; + Ok(()) +} + +#[cfg(feature = "async")] +pub(super) async fn write_fragmented_mp4_mux_chunked_async( + sources: &mut [R], + writer: &mut W, + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result<(), MuxError> +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + let layout = build_fragmented_layout(file_config, track_configs, single_sidx_reference, plan)?; + write_fragmented_init_async(&layout, writer).await?; + write_fragmented_media_async(sources, writer, &layout, true).await?; + Ok(()) +} + +#[cfg(feature = "async")] +async fn write_fragmented_init_async( + layout: &FragmentedLayout, + writer: &mut W, +) -> Result<(), MuxError> +where + W: AsyncWrite + Unpin, +{ + writer.write_all(&layout.ftyp_bytes).await?; + writer.write_all(&layout.moov_bytes).await?; + Ok(()) +} + +#[cfg(feature = "async")] +async fn write_fragmented_media_async( + sources: &mut [R], + writer: &mut W, + layout: &FragmentedLayout, + flush_each_fragment: bool, +) -> Result<(), MuxError> +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + writer.write_all(&layout.sidx_bytes).await?; + if flush_each_fragment { + writer.flush().await?; + } + for (fragment_index, fragment) in layout.fragments.iter().enumerate() { + write_fragmented_media_fragment_async( + sources, + writer, + fragment, + layout + .segment_index_bytes + .get(fragment_index) + .map(Vec::as_slice), + ) + .await?; + if flush_each_fragment { + writer.flush().await?; + } + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn write_fragmented_media_fragment_async( + sources: &mut [R], + writer: &mut W, + fragment: &FragmentLayout, + segment_index_bytes: Option<&[u8]>, +) -> Result<(), MuxError> +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + writer.write_all(&fragment.segment_type_bytes).await?; + if let Some(segment_index_bytes) = segment_index_bytes { + writer.write_all(segment_index_bytes).await?; + } + for metadata_bytes in &fragment.metadata_bytes { + writer.write_all(metadata_bytes).await?; + } + writer.write_all(&fragment.moof_bytes).await?; + writer.write_all(&fragment.mdat_header).await?; + copy_fragment_payloads_async(sources, writer, fragment).await?; + Ok(()) +} + +struct ContainerLayout { + ftyp_bytes: Vec, + leading_bytes: Vec, + moov_bytes: Vec, + mdat_header: Vec, + trailing_bytes: Vec, +} + +struct FragmentedLayout { + ftyp_bytes: Vec, + moov_bytes: Vec, + sidx_bytes: Vec, + segment_index_bytes: Vec>, + fragments: Vec, +} + +struct FragmentLayout { + segment_type_bytes: Vec, + metadata_bytes: Vec>, + moof_bytes: Vec, + mdat_header: Vec, + samples: Vec, + sidx_samples: Vec, +} + +struct FragmentTrackRun<'a> { + track: &'a PreparedTrack<'a>, + samples: Vec, + payload_offset: u64, +} + +struct BuiltSidxReference { + reference: SidxReference, + earliest_presentation_time: u64, +} + +type SampleEntryChildBoxes = Vec>; +type SampleEntryTrailingBytes = Vec; +type SampleEntryParts = (T, SampleEntryChildBoxes, SampleEntryTrailingBytes); + +struct PreparedTrack<'a> { + config: &'a MuxTrackConfig, + sample_entry_box: &'a [u8], + samples: Vec, + chunk_sample_counts: Vec, + fragmented_reference_group_fragment_counts: Option>, + media_duration: u64, + presentation_duration_media: u64, + edit_media_time: Option, + flat_timing_override: Option<&'a super::FlatTimingOverride>, +} + +#[derive(Clone, Copy)] +struct PreparedSample { + source_index: usize, + source_data_offset: u64, + decode_time_movie: u64, + decode_time_media: u64, + output_offset: u64, + sample_size: u64, + duration_movie: u32, + duration_media: u32, + composition_offset_movie: i32, + composition_offset_media: i32, + is_sync_sample: bool, + sample_description_index: u32, +} + +fn build_container_layout( + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + plan: &MuxPlan, +) -> Result { + if file_config.movie_timescale() == 0 { + return Err(MuxError::InvalidMovieTimescale); + } + + let prepared_tracks = prepare_tracks(file_config, track_configs, plan, true)?; + let ftyp_bytes = build_ftyp_bytes(file_config, &prepared_tracks)?; + let leading_bytes = file_config.preserved_flat_prefix_bytes().to_vec(); + let ftyp_size = + u64::try_from(ftyp_bytes.len()).map_err(|_| MuxError::LayoutOverflow("ftyp size"))?; + let leading_bytes_size = u64::try_from(leading_bytes.len()) + .map_err(|_| MuxError::LayoutOverflow("leading box size"))?; + let moov_start = ftyp_size + .checked_add(leading_bytes_size) + .ok_or(MuxError::LayoutOverflow("moov start"))?; + let mdat_header = encode_header_only( + FourCc::from_bytes(*b"mdat"), + plan.total_payload_size(), + "mdat header", + )?; + let mdat_header_size = + u64::try_from(mdat_header.len()).map_err(|_| MuxError::LayoutOverflow("mdat header"))?; + let provisional_moov = build_moov_bytes( + file_config, + &prepared_tracks, + moov_start, + mdat_header_size, + 0, + )?; + let moov_size = + u64::try_from(provisional_moov.len()).map_err(|_| MuxError::LayoutOverflow("moov size"))?; + let mdat_data_start = moov_start + .checked_add(moov_size) + .and_then(|offset| offset.checked_add(mdat_header_size)) + .ok_or(MuxError::LayoutOverflow("mdat data start"))?; + let moov_bytes = build_moov_bytes( + file_config, + &prepared_tracks, + moov_start, + mdat_header_size, + mdat_data_start, + )?; + let trailing_bytes = if file_config.auto_flat_profile() || file_config.keep_flat_free_box() { + build_free_padding_bytes(file_config)? + } else { + Vec::new() + }; + + if moov_bytes.len() != provisional_moov.len() { + return Err(MuxError::LayoutOverflow( + "moov size changed after chunk-offset resolution", + )); + } + + Ok(ContainerLayout { + ftyp_bytes, + leading_bytes, + moov_bytes, + mdat_header, + trailing_bytes, + }) +} + +fn build_fragmented_layout( + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + plan: &MuxPlan, +) -> Result { + build_fragmented_layout_with_media_mode( + file_config, + track_configs, + single_sidx_reference, + FragmentedMediaMode::SingleFile, + plan, + ) +} + +fn build_fragmented_segmented_layout( + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + plan: &MuxPlan, +) -> Result { + build_fragmented_layout_with_media_mode( + file_config, + track_configs, + false, + FragmentedMediaMode::StandaloneSegments, + plan, + ) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FragmentedMediaMode { + SingleFile, + StandaloneSegments, +} + +fn build_fragmented_layout_with_media_mode( + file_config: &MuxFileConfig, + track_configs: &[MuxTrackConfig], + single_sidx_reference: bool, + media_mode: FragmentedMediaMode, + plan: &MuxPlan, +) -> Result { + if file_config.movie_timescale() == 0 { + return Err(MuxError::InvalidMovieTimescale); + } + + let prepared_tracks = prepare_tracks(file_config, track_configs, plan, false)?; + let mut fragment_layouts = build_fragment_layouts(file_config, &prepared_tracks)?; + let ftyp = build_fragmented_ftyp(&prepared_tracks)?; + let ftyp_bytes = encode_typed_box(&ftyp, &[])?; + if media_mode == FragmentedMediaMode::StandaloneSegments { + let segment_type_bytes = build_fragmented_segment_type_bytes(&ftyp)?; + for fragment in &mut fragment_layouts { + fragment.segment_type_bytes = segment_type_bytes.clone(); + } + } + let moov_bytes = build_fragmented_moov_bytes(file_config, &prepared_tracks)?; + let sidx_track = select_fragmented_sidx_track(&prepared_tracks)?; + let (sidx_bytes, segment_index_bytes) = match media_mode { + FragmentedMediaMode::SingleFile => ( + build_sidx_bytes( + file_config, + sidx_track, + &fragment_layouts, + single_sidx_reference, + )?, + Vec::new(), + ), + FragmentedMediaMode::StandaloneSegments => ( + Vec::new(), + build_fragment_segment_index_bytes(file_config, sidx_track, &fragment_layouts)?, + ), + }; + + Ok(FragmentedLayout { + ftyp_bytes, + moov_bytes, + sidx_bytes, + segment_index_bytes, + fragments: fragment_layouts, + }) +} + +fn build_fragmented_ftyp(tracks: &[PreparedTrack<'_>]) -> Result { + let mut compatible_brands = vec![ + FourCc::from_bytes(*b"iso8"), + FourCc::from_bytes(*b"isom"), + FourCc::from_bytes(*b"mp41"), + FourCc::from_bytes(*b"dash"), + ]; + for track in tracks { + for sample_entry_box in track.config.sample_entry_boxes() { + append_fragmented_sample_entry_brands(sample_entry_box, &mut compatible_brands)?; + } + } + + Ok(Ftyp { + major_brand: FourCc::from_bytes(*b"mp41"), + minor_version: 0, + compatible_brands, + }) +} + +fn build_fragmented_segment_type_bytes(ftyp: &Ftyp) -> Result, MuxError> { + let mut segment_type = ftyp.clone(); + for brand in &mut segment_type.compatible_brands { + if *brand == FourCc::from_bytes(*b"cmfc") { + *brand = FourCc::from_bytes(*b"cmfs"); + } + } + encode_typed_box_as(FourCc::from_bytes(*b"styp"), &segment_type, &[]) +} + +fn push_unique_brand(compatible_brands: &mut Vec, brand: FourCc) { + if !compatible_brands.contains(&brand) { + compatible_brands.push(brand); + } +} + +fn append_fragmented_sample_entry_brands( + sample_entry_box: &[u8], + compatible_brands: &mut Vec, +) -> Result<(), MuxError> { + let sample_entry_type = sample_entry_box_type(sample_entry_box)?; + let carries_dolby_vision_config = sample_entry_carries_child_type( + sample_entry_box, + &[FourCc::from_bytes(*b"dvcC"), FourCc::from_bytes(*b"dvvC")], + ); + match sample_entry_type { + value + if value == FourCc::from_bytes(*b"avc1") + || value == FourCc::from_bytes(*b"avc2") + || value == FourCc::from_bytes(*b"avc3") + || value == FourCc::from_bytes(*b"avc4") => + { + push_unique_brand(compatible_brands, value); + if value == FourCc::from_bytes(*b"avc1") { + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"cmfc")); + } + } + value + if matches!( + value, + _ + if value == FourCc::from_bytes(*b"hvc1") + || value == FourCc::from_bytes(*b"hev1") + || value == FourCc::from_bytes(*b"dvh1") + || value == FourCc::from_bytes(*b"dvhe") + ) => + { + push_unique_brand(compatible_brands, value); + if value == FourCc::from_bytes(*b"dvh1") + || value == FourCc::from_bytes(*b"dvhe") + || carries_dolby_vision_config + { + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"dby1")); + } + if value != FourCc::from_bytes(*b"hev1") { + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"cmfc")); + } + } + value if value == FourCc::from_bytes(*b"vvc1") || value == FourCc::from_bytes(*b"vvi1") => { + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"vvc1")); + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"cmfc")); + } + value if value == FourCc::from_bytes(*b"av01") => { + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"av01")); + if carries_dolby_vision_config { + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"dby1")); + } + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"cmfc")); + } + value if value == FourCc::from_bytes(*b"vp08") => { + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"vp08")); + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"cmfc")); + } + value if value == FourCc::from_bytes(*b"vp09") => { + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"vp09")); + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"cmfc")); + } + value if value == FourCc::from_bytes(*b"vp10") => { + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"vp10")); + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"cmfc")); + } + value if value == FourCc::from_bytes(*b"iamf") => { + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"cmfc")); + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"iamf")); + } + _ => { + push_unique_brand(compatible_brands, FourCc::from_bytes(*b"cmfc")); + } + } + Ok(()) +} + +fn build_fragment_layouts( + file_config: &MuxFileConfig, + tracks: &[PreparedTrack<'_>], +) -> Result, MuxError> { + let mut fragments = Vec::new(); + let Some(sidx_track) = select_fragmented_sidx_track(tracks).ok() else { + return Ok(fragments); + }; + let fragment_count = tracks + .iter() + .map(|track| track.chunk_sample_counts.len()) + .max() + .unwrap_or(0); + let mut sample_indices = vec![0_usize; tracks.len()]; + for fragment_index in 0..fragment_count { + let mut runs = Vec::new(); + let mut all_samples = Vec::new(); + let mut sidx_samples = Vec::new(); + let mut payload_size = 0_u64; + for (track_index, track) in tracks.iter().enumerate() { + let Some(&samples_per_chunk) = track.chunk_sample_counts.get(fragment_index) else { + continue; + }; + let sample_count = usize::try_from(samples_per_chunk) + .map_err(|_| MuxError::LayoutOverflow("fragment sample count"))?; + let sample_index = sample_indices[track_index]; + let end_index = sample_index + .checked_add(sample_count) + .ok_or(MuxError::LayoutOverflow("fragment sample indexing"))?; + let fragment_samples = track + .samples + .get(sample_index..end_index) + .ok_or_else(|| MuxError::InvalidChunkPlan { + track_id: track.config.track_id(), + message: "fragment boundaries ran past the staged sample count".to_string(), + })? + .to_vec(); + let track_payload_size = fragment_samples.iter().try_fold(0_u64, |total, sample| { + total + .checked_add(sample.sample_size) + .ok_or(MuxError::LayoutOverflow("fragment payload size")) + })?; + if track.config.track_id() == sidx_track.config.track_id() { + sidx_samples = fragment_samples.clone(); + } + all_samples.extend(fragment_samples.iter().copied()); + let mut payload_offset = payload_size; + for fragment_run_samples in split_sample_description_runs(&fragment_samples) { + let run_payload_size = + fragment_run_samples + .iter() + .try_fold(0_u64, |total, sample| { + total + .checked_add(sample.sample_size) + .ok_or(MuxError::LayoutOverflow("fragment payload size")) + })?; + runs.push(FragmentTrackRun { + track, + samples: fragment_run_samples.to_vec(), + payload_offset, + }); + payload_offset = payload_offset + .checked_add(run_payload_size) + .ok_or(MuxError::LayoutOverflow("fragment payload size"))?; + } + payload_size = payload_size + .checked_add(track_payload_size) + .ok_or(MuxError::LayoutOverflow("fragment payload size"))?; + sample_indices[track_index] = end_index; + } + if runs.is_empty() { + continue; + } + let mdat_header = encode_header_only(FourCc::from_bytes(*b"mdat"), payload_size, "mdat")?; + let moof_bytes = build_fragment_moof_bytes( + &runs, + mdat_header.len(), + u32::try_from(fragment_index + 1) + .map_err(|_| MuxError::LayoutOverflow("fragment sequence number"))?, + )?; + let metadata_bytes = build_fragment_metadata_bytes(file_config, fragment_index)?; + fragments.push(FragmentLayout { + segment_type_bytes: Vec::new(), + metadata_bytes, + moof_bytes, + mdat_header, + samples: all_samples, + sidx_samples, + }); + } + for (track_index, track) in tracks.iter().enumerate() { + if sample_indices[track_index] != track.samples.len() { + return Err(MuxError::InvalidChunkPlan { + track_id: track.config.track_id(), + message: "fragment boundaries did not cover every staged sample".to_string(), + }); + } + } + Ok(fragments) +} + +fn split_sample_description_runs(samples: &[PreparedSample]) -> Vec<&[PreparedSample]> { + if samples.is_empty() { + return Vec::new(); + } + let mut runs = Vec::new(); + let mut start = 0_usize; + for index in 1..samples.len() { + if samples[index].sample_description_index != samples[index - 1].sample_description_index { + runs.push(&samples[start..index]); + start = index; + } + } + runs.push(&samples[start..]); + runs +} + +fn build_fragment_metadata_bytes( + file_config: &MuxFileConfig, + fragment_index: usize, +) -> Result>, MuxError> { + let fragment_index = u32::try_from(fragment_index) + .map_err(|_| MuxError::LayoutOverflow("fragment metadata index"))?; + let mut boxes = Vec::new(); + for message in file_config + .fragment_event_messages() + .iter() + .filter(|message| message.fragment_index() == fragment_index) + { + boxes.push(build_fragment_event_message_bytes(message)?); + } + for entry in file_config + .producer_reference_times() + .iter() + .filter(|entry| entry.fragment_index() == fragment_index) + { + boxes.push(build_producer_reference_time_bytes(entry)?); + } + Ok(boxes) +} + +fn build_fragment_event_message_bytes( + message: &super::MuxFragmentEventMessage, +) -> Result, MuxError> { + if message.timescale() == 0 { + return Err(MuxError::InvalidOutputLayout { + layout: "fragmented", + message: "fragment event message timescale must be greater than zero".to_string(), + }); + } + let mut emsg = Emsg::default(); + emsg.scheme_id_uri = message.scheme_id_uri().to_string(); + emsg.value = message.value().to_string(); + emsg.timescale = message.timescale(); + emsg.presentation_time_delta = message.presentation_time_delta(); + emsg.presentation_time = message.presentation_time(); + emsg.event_duration = message.event_duration(); + emsg.id = message.id(); + emsg.message_data = message.message_data().to_vec(); + match message.version() { + 0 | 1 => emsg.set_version(message.version()), + version => { + return Err(MuxError::InvalidOutputLayout { + layout: "fragmented", + message: format!("fragment event message version {version} is not supported"), + }); + } + } + encode_typed_box(&emsg, &[]) +} + +fn build_producer_reference_time_bytes( + entry: &super::MuxProducerReferenceTime, +) -> Result, MuxError> { + if entry.reference_track_id() == 0 { + return Err(MuxError::InvalidOutputLayout { + layout: "fragmented", + message: "producer reference time requires a nonzero reference track id".to_string(), + }); + } + if entry.flags() > 0x00ff_ffff { + return Err(MuxError::InvalidOutputLayout { + layout: "fragmented", + message: "producer reference time flags must fit in 24 bits".to_string(), + }); + } + let mut prft = Prft::default(); + prft.reference_track_id = entry.reference_track_id(); + prft.ntp_timestamp = entry.ntp_timestamp(); + match entry.version() { + 0 => { + prft.set_version(0); + prft.media_time_v0 = + u32::try_from(entry.media_time()).map_err(|_| MuxError::InvalidOutputLayout { + layout: "fragmented", + message: "producer reference time version 0 requires media time to fit in u32" + .to_string(), + })?; + } + 1 => { + prft.set_version(1); + prft.media_time_v1 = entry.media_time(); + } + version => { + return Err(MuxError::InvalidOutputLayout { + layout: "fragmented", + message: format!("producer reference time version {version} is not supported"), + }); + } + } + prft.set_flags(entry.flags()); + encode_typed_box(&prft, &[]) +} + +fn select_fragmented_sidx_track<'a>( + tracks: &'a [PreparedTrack<'a>], +) -> Result<&'a PreparedTrack<'a>, MuxError> { + let fragment_count = tracks + .iter() + .map(|track| track.chunk_sample_counts.len()) + .max() + .unwrap_or(0); + tracks + .iter() + .find(|track| { + track.config.kind() == MuxTrackKind::Video + && !track.samples.is_empty() + && track.chunk_sample_counts.len() == fragment_count + }) + .or_else(|| { + tracks.iter().find(|track| { + !track.samples.is_empty() && track.chunk_sample_counts.len() == fragment_count + }) + }) + .or_else(|| { + tracks.iter().find(|track| { + track.config.kind() == MuxTrackKind::Video && !track.samples.is_empty() + }) + }) + .or_else(|| tracks.iter().find(|track| !track.samples.is_empty())) + .ok_or(MuxError::InvalidOutputLayout { + layout: "fragmented", + message: "fragmented output requires at least one prepared track with samples" + .to_string(), + }) +} + +fn build_fragmented_moov_bytes( + file_config: &MuxFileConfig, + tracks: &[PreparedTrack<'_>], +) -> Result, MuxError> { + let fragmented_creation_time = current_isom_time()?; + let mvhd = build_fragmented_mvhd(file_config, tracks, fragmented_creation_time)?; + let mut children = vec![ + encode_typed_box(&mvhd, &[])?, + build_fragmented_meta_bytes()?, + ]; + for track in tracks { + children.push(build_fragmented_trak_bytes( + track, + fragmented_creation_time, + )?); + } + children.push(build_mvex_bytes(file_config.movie_timescale(), tracks)?); + encode_typed_box(&Moov, &children.concat()) +} + +fn build_fragmented_meta_bytes() -> Result, MuxError> { + let mut id32 = Id32::default(); + id32.language = "eng".to_string(); + id32.id3v2_data = build_fragmented_identity_id3_payload(); + + let children = [ + build_fragmented_identity_hdlr_bytes()?, + encode_typed_box(&id32, &[])?, + ] + .concat(); + encode_typed_box(&Meta::default(), &children) +} + +fn build_fragmented_identity_hdlr_bytes() -> Result, MuxError> { + let mut payload = Vec::with_capacity(24); + payload.extend_from_slice(&[0, 0, 0, 0]); + payload.extend_from_slice(&[0, 0, 0, 0]); + payload.extend_from_slice(FourCc::from_bytes(*b"ID32").as_bytes().as_ref()); + payload.extend_from_slice(&[0; 12]); + + let mut bytes = encode_header_only( + FourCc::from_bytes(*b"hdlr"), + u64::try_from(payload.len()) + .map_err(|_| MuxError::LayoutOverflow("fragmented meta hdlr"))?, + "fragmented meta hdlr", + )?; + bytes.extend_from_slice(&payload); + Ok(bytes) +} + +fn build_fragmented_identity_id3_payload() -> Vec { + let mut frame_payload = + Vec::with_capacity(FRAGMENTED_ID3_OWNER.len() + 1 + FRAGMENTED_ID3_VALUE.len()); + frame_payload.extend_from_slice(FRAGMENTED_ID3_OWNER.as_bytes()); + frame_payload.push(0); + frame_payload.extend_from_slice(FRAGMENTED_ID3_VALUE.as_bytes()); + + let mut priv_frame = Vec::with_capacity(10 + frame_payload.len()); + priv_frame.extend_from_slice(b"PRIV"); + priv_frame.extend_from_slice(&encode_synchsafe_u32(frame_payload.len() as u32)); + priv_frame.extend_from_slice(&[0, 0]); + priv_frame.extend_from_slice(&frame_payload); + + let mut id3 = Vec::with_capacity(10 + priv_frame.len()); + id3.extend_from_slice(b"ID3"); + id3.extend_from_slice(&[4, 0, 0]); + id3.extend_from_slice(&encode_synchsafe_u32(priv_frame.len() as u32)); + id3.extend_from_slice(&priv_frame); + id3 +} + +fn encode_synchsafe_u32(value: u32) -> [u8; 4] { + [ + ((value >> 21) & 0x7F) as u8, + ((value >> 14) & 0x7F) as u8, + ((value >> 7) & 0x7F) as u8, + (value & 0x7F) as u8, + ] +} + +fn build_fragmented_mvhd( + file_config: &MuxFileConfig, + tracks: &[PreparedTrack<'_>], + fragmented_creation_time: u32, +) -> Result { + let mut mvhd = build_mvhd(file_config, tracks, None)?; + mvhd.set_version(0); + mvhd.creation_time_v0 = fragmented_creation_time; + mvhd.modification_time_v0 = fragmented_creation_time; + mvhd.creation_time_v1 = 0; + mvhd.modification_time_v1 = 0; + mvhd.duration_v0 = 0; + mvhd.duration_v1 = 0; + Ok(mvhd) +} + +fn build_fragmented_trak_bytes( + track: &PreparedTrack<'_>, + fragmented_creation_time: u32, +) -> Result, MuxError> { + let tkhd = build_fragmented_tkhd(track, fragmented_creation_time)?; + let mdia = build_fragmented_mdia_bytes(track, fragmented_creation_time)?; + let mut children = vec![encode_typed_box(&tkhd, &[])?, mdia]; + if let Some(edts) = build_edts_bytes(track, 0)? { + children.push(edts); + } + encode_typed_box(&Trak, &children.concat()) +} + +fn build_fragmented_tkhd( + track: &PreparedTrack<'_>, + fragmented_creation_time: u32, +) -> Result { + let mut tkhd = build_tkhd_with_movie_timescale(track, track.config.timescale())?; + tkhd.set_flags(DEFAULT_FRAGMENTED_TKHD_FLAGS); + tkhd.alternate_group = 0; + tkhd.volume = match track.config.kind() { + MuxTrackKind::Audio => 0x0100, + MuxTrackKind::Video | MuxTrackKind::Text | MuxTrackKind::Subtitle => 0, + }; + tkhd.matrix = IDENTITY_MATRIX; + tkhd.set_version(0); + tkhd.creation_time_v0 = fragmented_creation_time; + tkhd.modification_time_v0 = fragmented_creation_time; + tkhd.creation_time_v1 = 0; + tkhd.modification_time_v1 = 0; + tkhd.duration_v0 = 0; + tkhd.duration_v1 = 0; + Ok(tkhd) +} + +fn build_edts_bytes( + track: &PreparedTrack<'_>, + segment_duration: u64, +) -> Result>, MuxError> { + let Some(edit_media_time) = track.edit_media_time else { + return Ok(None); + }; + let mut elst = Elst::default(); + elst.entry_count = 1; + if edit_media_time > u64::try_from(i32::MAX).unwrap_or(u64::MAX) + || segment_duration > u64::from(u32::MAX) + { + elst.set_version(1); + elst.entries.push(ElstEntry { + segment_duration_v1: segment_duration, + media_time_v1: i64::try_from(edit_media_time) + .map_err(|_| MuxError::LayoutOverflow("fragmented edit-list media time"))?, + media_rate_integer: 1, + ..ElstEntry::default() + }); + } else { + elst.entries.push(ElstEntry { + segment_duration_v0: u32::try_from(segment_duration) + .map_err(|_| MuxError::LayoutOverflow("edit-list segment duration"))?, + media_time_v0: i32::try_from(edit_media_time) + .map_err(|_| MuxError::LayoutOverflow("fragmented edit-list media time"))?, + media_rate_integer: 1, + ..ElstEntry::default() + }); + } + Ok(Some(encode_typed_box( + &Edts, + &encode_typed_box(&elst, &[])?, + )?)) +} + +fn build_fragmented_mdia_bytes( + track: &PreparedTrack<'_>, + fragmented_creation_time: u32, +) -> Result, MuxError> { + let mdhd = build_fragmented_mdhd(track, fragmented_creation_time)?; + let hdlr = build_hdlr(track); + let minf = build_fragmented_minf_bytes(track)?; + let children = [ + encode_typed_box(&mdhd, &[])?, + encode_typed_box(&hdlr, &[])?, + minf, + ] + .concat(); + encode_typed_box(&Mdia, &children) +} + +fn build_fragmented_mdhd( + track: &PreparedTrack<'_>, + fragmented_creation_time: u32, +) -> Result { + let mut mdhd = build_mdhd_base(track)?; + mdhd.set_version(0); + mdhd.creation_time_v0 = fragmented_creation_time; + mdhd.modification_time_v0 = fragmented_creation_time; + mdhd.creation_time_v1 = 0; + mdhd.modification_time_v1 = 0; + mdhd.duration_v0 = 0; + mdhd.duration_v1 = 0; + Ok(mdhd) +} + +fn build_fragmented_minf_bytes(track: &PreparedTrack<'_>) -> Result, MuxError> { + let media_header = match track.config.kind() { + MuxTrackKind::Audio => encode_typed_box(&Smhd::default(), &[])?, + MuxTrackKind::Video => { + let mut vmhd = Vmhd::default(); + vmhd.set_flags(VMHD_DEFAULT_FLAGS); + encode_typed_box(&vmhd, &[])? + } + MuxTrackKind::Text => encode_typed_box(&Nmhd::default(), &[])?, + MuxTrackKind::Subtitle => { + build_subtitle_media_header_bytes(track.config.sample_entry_box())? + } + }; + let dinf = build_dinf_bytes()?; + let stbl = build_fragmented_stbl_bytes(track)?; + encode_typed_box(&Minf, &[dinf, stbl, media_header].concat()) +} + +fn build_fragmented_stbl_bytes(track: &PreparedTrack<'_>) -> Result, MuxError> { + let stsd = build_fragmented_stsd_bytes(track)?; + let mut stts = Stts::default(); + stts.entry_count = 0; + let mut stsc = Stsc::default(); + stsc.entry_count = 0; + let mut stsz = Stsz::default(); + stsz.sample_size = 0; + stsz.sample_count = 0; + let mut stco = Stco::default(); + stco.entry_count = 0; + let mut children = vec![ + stsd, + encode_typed_box(&stts, &[])?, + encode_typed_box(&stsc, &[])?, + encode_typed_box(&stsz, &[])?, + encode_typed_box(&stco, &[])?, + ]; + if fragmented_track_emits_roll_description(track) + && let Some(sample_roll_distance) = track.config.sample_roll_distance() + { + children.push(encode_typed_box( + &build_roll_sgpd(sample_roll_distance), + &[], + )?); + } + encode_typed_box(&Stbl, &children.concat()) +} + +fn build_fragmented_stsd_bytes(track: &PreparedTrack<'_>) -> Result, MuxError> { + let mut stsd = Stsd::default(); + stsd.entry_count = u32::try_from(track.config.sample_entry_boxes().len()) + .map_err(|_| MuxError::LayoutOverflow("stsd entry_count"))?; + let mut sample_entry_boxes = Vec::new(); + for sample_entry_box in track.config.sample_entry_boxes() { + sample_entry_boxes.extend(canonicalize_fragmented_sample_entry_box(sample_entry_box)?); + } + encode_typed_box(&stsd, &sample_entry_boxes) +} + +fn build_mvex_bytes( + movie_timescale: u32, + tracks: &[PreparedTrack<'_>], +) -> Result, MuxError> { + let mut children = Vec::new(); + let mut fragment_duration = 0_u64; + for track in tracks { + fragment_duration = + fragment_duration.max(fragmented_mehd_duration(movie_timescale, track)?); + } + let mut mehd = Mehd::default(); + if fragment_duration > u64::from(u32::MAX) { + mehd.set_version(1); + mehd.fragment_duration_v1 = fragment_duration; + } else { + mehd.fragment_duration_v0 = u32::try_from(fragment_duration) + .map_err(|_| MuxError::LayoutOverflow("fragmented mehd duration"))?; + } + children.push(encode_typed_box(&mehd, &[])?); + for track in tracks { + let mut trex = Trex::default(); + trex.track_id = track.config.track_id(); + trex.default_sample_description_index = track + .samples + .first() + .map(|sample| sample.sample_description_index) + .unwrap_or(1); + trex.default_sample_duration = track + .samples + .first() + .map(|sample| sample.duration_media) + .unwrap_or(0); + trex.default_sample_size = 0; + trex.default_sample_flags = 0; + children.push(encode_typed_box(&trex, &[])?); + } + encode_typed_box(&Mvex, &children.concat()) +} + +fn build_fragment_moof_bytes( + runs: &[FragmentTrackRun<'_>], + mdat_header_size: usize, + sequence_number: u32, +) -> Result, MuxError> { + let mut mfhd = Mfhd::default(); + mfhd.sequence_number = sequence_number; + let provisional_trafs = runs + .iter() + .map(|run| build_traf_bytes(run.track, &run.samples, 0)) + .collect::, _>>()?; + let provisional_moof = encode_typed_box( + &Moof, + &[encode_typed_box(&mfhd, &[])?, provisional_trafs.concat()].concat(), + )?; + let moof_and_mdat_header_size = provisional_moof + .len() + .checked_add(mdat_header_size) + .ok_or(MuxError::LayoutOverflow("fragment data offset"))?; + let trafs = runs + .iter() + .map(|run| { + let data_offset = u64::try_from(moof_and_mdat_header_size) + .map_err(|_| MuxError::LayoutOverflow("fragment data offset"))? + .checked_add(run.payload_offset) + .ok_or(MuxError::LayoutOverflow("fragment data offset"))?; + build_traf_bytes( + run.track, + &run.samples, + i32::try_from(data_offset) + .map_err(|_| MuxError::LayoutOverflow("fragment data offset"))?, + ) + }) + .collect::, _>>()?; + let moof = encode_typed_box( + &Moof, + &[encode_typed_box(&mfhd, &[])?, trafs.concat()].concat(), + )?; + if moof.len() != provisional_moof.len() { + return Err(MuxError::LayoutOverflow( + "fragment moof size changed after data-offset resolution", + )); + } + Ok(moof) +} + +fn build_traf_bytes( + track: &PreparedTrack<'_>, + samples: &[PreparedSample], + data_offset: i32, +) -> Result, MuxError> { + let mut tfhd = Tfhd::default(); + tfhd.track_id = track.config.track_id(); + tfhd.sample_description_index = common_sample_description_index(track, samples)?; + tfhd.set_flags(TFHD_DEFAULT_BASE_IS_MOOF | TFHD_SAMPLE_DESCRIPTION_INDEX_PRESENT); + + if let Some(default_duration) = + all_equal_u32(samples.iter().map(|sample| sample.duration_media)) + { + tfhd.set_flags(tfhd.flags() | TFHD_DEFAULT_SAMPLE_DURATION_PRESENT); + tfhd.default_sample_duration = default_duration; + } + if let Some(default_size) = all_equal_u32( + samples + .iter() + .map(|sample| u32::try_from(sample.sample_size).unwrap_or(u32::MAX)), + ) { + tfhd.set_flags(tfhd.flags() | TFHD_DEFAULT_SAMPLE_SIZE_PRESENT); + tfhd.default_sample_size = default_size; + } + let force_first_only_flags = matches!( + track.config.sync_sample_table_mode, + super::SyncSampleTableMode::ForceFirstOnly + ); + let first_sync_sample_index = force_first_only_flags + .then(|| samples.iter().position(|sample| sample.is_sync_sample)) + .flatten(); + if let Some(default_flags) = + all_equal_u32(samples.iter().enumerate().map(|(sample_index, sample)| { + sample_flags(sample, sample_index, first_sync_sample_index) + })) + { + tfhd.set_flags(tfhd.flags() | TFHD_DEFAULT_SAMPLE_FLAGS_PRESENT); + tfhd.default_sample_flags = default_flags; + } + + let mut tfdt = Tfdt::default(); + let base_decode_time = samples + .first() + .map(|sample| sample.decode_time_media) + .unwrap_or(0); + if base_decode_time > u64::from(u32::MAX) { + tfdt.set_version(1); + tfdt.base_media_decode_time_v1 = base_decode_time; + } else { + tfdt.base_media_decode_time_v0 = u32::try_from(base_decode_time) + .map_err(|_| MuxError::LayoutOverflow("tfdt decode time"))?; + } + + let trun = build_trun(track, samples, data_offset)?; + let mut children = vec![ + encode_typed_box(&tfhd, &[])?, + encode_typed_box(&tfdt, &[])?, + encode_typed_box(&trun, &[])?, + ]; + if fragmented_track_emits_roll_assignment(track) { + children.push(encode_typed_box( + &build_roll_sbgp( + u32::try_from(samples.len()) + .map_err(|_| MuxError::LayoutOverflow("fragment roll sample count"))?, + ), + &[], + )?); + } + encode_typed_box(&Traf, &children.concat()) +} + +fn common_sample_description_index( + track: &PreparedTrack<'_>, + samples: &[PreparedSample], +) -> Result { + let Some(first) = samples.first() else { + return Ok(1); + }; + if first.sample_description_index == 0 { + return Err(MuxError::InvalidOutputLayout { + layout: "fragmented", + message: format!( + "track {} uses sample description index 0", + track.config.track_id() + ), + }); + } + let sample_entry_count = u32::try_from(track.config.sample_entry_boxes().len()) + .map_err(|_| MuxError::LayoutOverflow("stsd entry_count"))?; + if first.sample_description_index > sample_entry_count { + return Err(MuxError::InvalidOutputLayout { + layout: "fragmented", + message: format!( + "track {} uses sample description index {} with only {} sample entries", + track.config.track_id(), + first.sample_description_index, + sample_entry_count + ), + }); + } + if samples + .iter() + .all(|sample| sample.sample_description_index == first.sample_description_index) + { + return Ok(first.sample_description_index); + } + Err(MuxError::InvalidOutputLayout { + layout: "fragmented", + message: format!( + "track {} has mixed sample descriptions inside one fragment run", + track.config.track_id() + ), + }) +} + +fn build_trun( + track: &PreparedTrack<'_>, + samples: &[PreparedSample], + data_offset: i32, +) -> Result { + let mut trun = Trun::default(); + trun.sample_count = + u32::try_from(samples.len()).map_err(|_| MuxError::LayoutOverflow("trun sample count"))?; + trun.data_offset = data_offset; + trun.set_flags(TRUN_DATA_OFFSET_PRESENT); + let first_sync_sample_index = matches!( + track.config.sync_sample_table_mode, + super::SyncSampleTableMode::ForceFirstOnly + ) + .then(|| samples.iter().position(|sample| sample.is_sync_sample)) + .flatten(); + if !samples + .iter() + .all(|sample| sample.composition_offset_media == 0) + { + trun.set_flags(trun.flags() | TRUN_SAMPLE_COMPOSITION_TIME_OFFSET_PRESENT); + if samples + .iter() + .any(|sample| sample.composition_offset_media < 0) + { + trun.set_version(1); + } + } + if all_equal_u32(samples.iter().map(|sample| sample.duration_media)).is_none() { + trun.set_flags(trun.flags() | TRUN_SAMPLE_DURATION_PRESENT); + } + if all_equal_u32( + samples + .iter() + .map(|sample| u32::try_from(sample.sample_size).unwrap_or(u32::MAX)), + ) + .is_none() + { + trun.set_flags(trun.flags() | TRUN_SAMPLE_SIZE_PRESENT); + } + if all_equal_u32( + samples.iter().enumerate().map(|(sample_index, sample)| { + sample_flags(sample, sample_index, first_sync_sample_index) + }), + ) + .is_none() + { + trun.set_flags(trun.flags() | TRUN_SAMPLE_FLAGS_PRESENT); + } + trun.entries = samples + .iter() + .enumerate() + .map(|(sample_index, sample)| { + Ok(TrunEntry { + sample_duration: sample.duration_media, + sample_size: u32::try_from(sample.sample_size) + .map_err(|_| MuxError::LayoutOverflow("trun sample size"))?, + sample_flags: sample_flags(sample, sample_index, first_sync_sample_index), + sample_composition_time_offset_v0: u32::try_from(sample.composition_offset_media) + .unwrap_or(0), + sample_composition_time_offset_v1: sample.composition_offset_media, + }) + }) + .collect::, MuxError>>()?; + Ok(trun) +} + +fn build_sidx_bytes( + file_config: &MuxFileConfig, + track: &PreparedTrack<'_>, + fragments: &[FragmentLayout], + single_sidx_reference: bool, +) -> Result, MuxError> { + if track_uses_direct_iamf_flat_timing(track) + || (sample_entry_matches(track.sample_entry_box, &[b"iamf"]) + && track + .samples + .iter() + .any(|sample| sample.duration_movie == u32::MAX)) + { + return Ok(Vec::new()); + } + let mut sidx = Sidx::default(); + sidx.reference_id = track.config.track_id(); + sidx.timescale = file_config.movie_timescale(); + let presentation_trim = sidx_presentation_trim(track, file_config)?; + let built_references = if let Some(reference_group_fragment_counts) = + track.fragmented_reference_group_fragment_counts.as_deref() + { + build_grouped_sidx_references( + track, + fragments, + reference_group_fragment_counts, + presentation_trim, + true, + )? + } else if single_sidx_reference { + vec![build_sidx_reference( + fragments.iter(), + presentation_trim, + true, + )?] + } else { + fragments + .iter() + .enumerate() + .map(|(index, fragment)| { + build_sidx_reference( + std::iter::once(fragment), + if index == 0 { presentation_trim } else { 0 }, + true, + ) + }) + .collect::, MuxError>>()? + }; + let earliest_presentation_time = built_references + .first() + .map(|reference| reference.earliest_presentation_time) + .unwrap_or(0); + if earliest_presentation_time > u64::from(u32::MAX) { + sidx.set_version(1); + sidx.earliest_presentation_time_v1 = earliest_presentation_time; + sidx.first_offset_v1 = 0; + } else { + sidx.earliest_presentation_time_v0 = u32::try_from(earliest_presentation_time) + .map_err(|_| MuxError::LayoutOverflow("sidx earliest presentation time"))?; + sidx.first_offset_v0 = 0; + } + let built_references = limit_sidx_references(built_references); + sidx.references = built_references + .into_iter() + .map(|reference| reference.reference) + .collect(); + sidx.reference_count = u16::try_from(sidx.references.len()) + .map_err(|_| MuxError::LayoutOverflow("sidx reference count"))?; + encode_typed_box(&sidx, &[]) +} + +fn build_fragment_segment_index_bytes( + file_config: &MuxFileConfig, + track: &PreparedTrack<'_>, + fragments: &[FragmentLayout], +) -> Result>, MuxError> { + if track_uses_direct_iamf_flat_timing(track) + || (sample_entry_matches(track.sample_entry_box, &[b"iamf"]) + && track + .samples + .iter() + .any(|sample| sample.duration_movie == u32::MAX)) + { + return Ok(vec![Vec::new(); fragments.len()]); + } + + let presentation_trim = sidx_presentation_trim(track, file_config)?; + fragments + .iter() + .enumerate() + .map(|(index, fragment)| { + let built_reference = build_sidx_reference( + std::iter::once(fragment), + if index == 0 { presentation_trim } else { 0 }, + false, + )?; + let mut sidx = Sidx::default(); + sidx.reference_id = track.config.track_id(); + sidx.timescale = file_config.movie_timescale(); + if built_reference.earliest_presentation_time > u64::from(u32::MAX) { + sidx.set_version(1); + sidx.earliest_presentation_time_v1 = built_reference.earliest_presentation_time; + sidx.first_offset_v1 = 0; + } else { + sidx.earliest_presentation_time_v0 = + u32::try_from(built_reference.earliest_presentation_time) + .map_err(|_| MuxError::LayoutOverflow("sidx earliest presentation time"))?; + sidx.first_offset_v0 = 0; + } + sidx.references = vec![built_reference.reference]; + sidx.reference_count = 1; + encode_typed_box(&sidx, &[]) + }) + .collect() +} + +fn limit_sidx_references(mut references: Vec) -> Vec { + if references.len() > MAX_SIDX_REFERENCES { + references.truncate(MAX_SIDX_REFERENCES); + } + references +} + +fn sidx_presentation_trim( + track: &PreparedTrack<'_>, + file_config: &MuxFileConfig, +) -> Result { + track + .edit_media_time + .map(|media_time| { + scale_track_time_to_movie( + track.config.track_id(), + i64::try_from(media_time) + .map_err(|_| MuxError::LayoutOverflow("sidx edit-list trim"))?, + track.config.timescale(), + file_config.movie_timescale(), + ) + .and_then(|value| { + u64::try_from(value).map_err(|_| MuxError::LayoutOverflow("sidx edit-list trim")) + }) + }) + .transpose()? + .map_or(Ok(0), Ok) +} + +fn build_grouped_sidx_references( + track: &PreparedTrack<'_>, + fragments: &[FragmentLayout], + reference_group_fragment_counts: &[u32], + presentation_trim: u64, + include_segment_type: bool, +) -> Result, MuxError> { + let mut references = Vec::with_capacity(reference_group_fragment_counts.len()); + let mut fragment_index = 0_usize; + for (group_index, &group_fragment_count) in reference_group_fragment_counts.iter().enumerate() { + let fragment_count = usize::try_from(group_fragment_count) + .map_err(|_| MuxError::LayoutOverflow("sidx grouped fragment count"))?; + let next_fragment_index = fragment_index + .checked_add(fragment_count) + .ok_or(MuxError::LayoutOverflow("sidx grouped fragment indexing"))?; + let fragment_group = fragments + .get(fragment_index..next_fragment_index) + .ok_or_else(|| MuxError::InvalidChunkPlan { + track_id: track.config.track_id(), + message: "fragment reference groups ran past the planned fragment count" + .to_string(), + })?; + references.push(build_sidx_reference( + fragment_group.iter(), + if group_index == 0 { + presentation_trim + } else { + 0 + }, + include_segment_type, + )?); + fragment_index = next_fragment_index; + } + if fragment_index != fragments.len() { + return Err(MuxError::InvalidChunkPlan { + track_id: track.config.track_id(), + message: "fragment reference groups did not cover every planned fragment".to_string(), + }); + } + Ok(references) +} + +fn build_sidx_reference<'a, I>( + fragments: I, + presentation_trim: u64, + include_segment_type: bool, +) -> Result +where + I: IntoIterator, +{ + let mut referenced_size = 0_usize; + let mut subsegment_duration = 0_u64; + let mut starts_with_sap = false; + let mut saw_any_sample = false; + let mut earliest_presentation_time = None::; + let mut first_sap_time = None::; + let presentation_trim_i128 = i128::from(presentation_trim); + + for fragment in fragments { + if !saw_any_sample && !fragment.sidx_samples.is_empty() { + starts_with_sap = fragment + .sidx_samples + .first() + .map(|sample| sample.is_sync_sample) + .unwrap_or(false); + saw_any_sample = true; + } + if include_segment_type { + referenced_size = referenced_size + .checked_add(fragment.segment_type_bytes.len()) + .ok_or(MuxError::LayoutOverflow("sidx referenced size"))?; + } + referenced_size = fragment + .metadata_bytes + .iter() + .try_fold(referenced_size, |total, bytes| { + total.checked_add(bytes.len()) + }) + .and_then(|size| size.checked_add(fragment.moof_bytes.len())) + .and_then(|size| size.checked_add(fragment.mdat_header.len())) + .ok_or(MuxError::LayoutOverflow("sidx referenced size"))?; + let payload_size = fragment.samples.iter().try_fold(0_usize, |total, sample| { + total + .checked_add( + usize::try_from(sample.sample_size) + .map_err(|_| MuxError::LayoutOverflow("sidx referenced size"))?, + ) + .ok_or(MuxError::LayoutOverflow("sidx referenced size")) + })?; + referenced_size = referenced_size + .checked_add(payload_size) + .ok_or(MuxError::LayoutOverflow("sidx referenced size"))?; + for sample in &fragment.sidx_samples { + let presentation_start = i128::from(sample.decode_time_movie) + .saturating_add(i128::from(sample.composition_offset_movie)) + .saturating_sub(presentation_trim_i128); + let presentation_end = + presentation_start.saturating_add(i128::from(sample.duration_movie)); + if presentation_start < 0 { + if presentation_end > 0 { + let clipped_duration = u64::try_from(presentation_end) + .map_err(|_| MuxError::LayoutOverflow("sidx subsegment duration"))?; + subsegment_duration = subsegment_duration + .checked_add(clipped_duration) + .ok_or(MuxError::LayoutOverflow("sidx subsegment duration"))?; + earliest_presentation_time = Some(0); + if sample.is_sync_sample && first_sap_time.is_none() { + first_sap_time = Some(0); + } + } + } else { + let normalized_presentation_start = u64::try_from(presentation_start) + .map_err(|_| MuxError::LayoutOverflow("sidx presentation start"))?; + subsegment_duration = subsegment_duration + .checked_add(u64::from(sample.duration_movie)) + .ok_or(MuxError::LayoutOverflow("sidx subsegment duration"))?; + earliest_presentation_time = Some( + earliest_presentation_time.map_or(normalized_presentation_start, |current| { + current.min(normalized_presentation_start) + }), + ); + if sample.is_sync_sample && first_sap_time.is_none() { + first_sap_time = Some(normalized_presentation_start); + } + } + } + } + let earliest_presentation_time = earliest_presentation_time.unwrap_or(0); + let sap_delta_time = first_sap_time + .map(|first_sap_time| { + u32::try_from(first_sap_time.saturating_sub(earliest_presentation_time)) + .map_err(|_| MuxError::LayoutOverflow("sidx SAP delta time")) + }) + .transpose()? + .unwrap_or(0); + + Ok(BuiltSidxReference { + reference: SidxReference { + reference_type: false, + referenced_size: u32::try_from(referenced_size) + .map_err(|_| MuxError::LayoutOverflow("sidx referenced size"))?, + subsegment_duration: u32::try_from(subsegment_duration) + .map_err(|_| MuxError::LayoutOverflow("sidx subsegment duration"))?, + starts_with_sap, + sap_type: if first_sap_time.is_some() { 1 } else { 0 }, + sap_delta_time, + }, + earliest_presentation_time, + }) +} + +fn build_ftyp_bytes( + file_config: &MuxFileConfig, + tracks: &[PreparedTrack<'_>], +) -> Result, MuxError> { + let (major_brand, minor_version, compatible_brands) = + if file_config.auto_flat_profile() && !file_config.keep_flat_authority_brands() { + infer_auto_flat_ftyp_profile(tracks) + } else { + ( + file_config.major_brand(), + file_config.minor_version(), + file_config.compatible_brands().to_vec(), + ) + }; + let ftyp = Ftyp { + major_brand, + minor_version, + compatible_brands, + }; + encode_typed_box(&ftyp, &[]) +} + +fn infer_auto_flat_ftyp_profile(tracks: &[PreparedTrack<'_>]) -> (FourCc, u32, Vec) { + let imported_authority_tracks = tracks.iter().all(track_uses_imported_authority_headers); + let has_iamf = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"iamf"])); + let has_qcp = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"sqcp", b"sevc", b"ssmv"])); + let has_av1 = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"av01"])); + let has_hevc = tracks.iter().any(|track| { + sample_entry_matches( + track.sample_entry_box, + &[b"hvc1", b"hev1", b"dvh1", b"dvhe"], + ) + }); + let has_prores = tracks.iter().any(|track| { + sample_entry_matches( + track.sample_entry_box, + &[b"apco", b"apcn", b"apch", b"apcs", b"ap4x", b"ap4h"], + ) + }); + let has_avs3 = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"avs3"])); + let has_vvc = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"vvc1", b"vvi1"])); + let has_avc = tracks.iter().any(|track| { + sample_entry_matches( + track.sample_entry_box, + &[b"avc1", b"avc2", b"avc3", b"avc4"], + ) + }); + let has_h263 = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"s263"])); + + if has_iamf { + if imported_authority_tracks { + let mut brands = vec![FourCc::from_bytes(*b"isom")]; + if has_avc { + brands.push(FourCc::from_bytes(*b"avc1")); + } + if has_av1 { + brands.push(FourCc::from_bytes(*b"av01")); + } + brands.push(FourCc::from_bytes(*b"iamf")); + return (FourCc::from_bytes(*b"isom"), 1, brands); + } + return ( + FourCc::from_bytes(*b"mp42"), + 0, + vec![ + FourCc::from_bytes(*b"isom"), + FourCc::from_bytes(*b"mp42"), + FourCc::from_bytes(*b"iso6"), + FourCc::from_bytes(*b"iamf"), + ], + ); + } + if has_qcp { + let mut brands = vec![FourCc::from_bytes(*b"isom")]; + if has_avc { + brands.push(FourCc::from_bytes(*b"avc1")); + } + brands.push(FourCc::from_bytes(*b"3g2a")); + return (FourCc::from_bytes(*b"3g2a"), 65_536, brands); + } + if has_prores { + return ( + FourCc::from_bytes(*b"qt "), + 0x200, + vec![FourCc::from_bytes(*b"qt ")], + ); + } + if has_av1 { + let mut brands = Vec::new(); + if has_avc { + brands.push(FourCc::from_bytes(*b"avc1")); + } + brands.push(FourCc::from_bytes(*b"iso4")); + brands.push(FourCc::from_bytes(*b"av01")); + return (FourCc::from_bytes(*b"iso4"), 1, brands); + } + if has_hevc { + let mut brands = Vec::new(); + if has_avc { + brands.push(FourCc::from_bytes(*b"avc1")); + } + brands.push(FourCc::from_bytes(*b"iso4")); + return (FourCc::from_bytes(*b"iso4"), 1, brands); + } + if has_avs3 { + let mut brands = Vec::new(); + if has_avc { + brands.push(FourCc::from_bytes(*b"avc1")); + } + brands.push(FourCc::from_bytes(*b"iso4")); + brands.push(FourCc::from_bytes(*b"cav3")); + return (FourCc::from_bytes(*b"iso4"), 1, brands); + } + if has_vvc { + let mut brands = Vec::new(); + if has_avc { + brands.push(FourCc::from_bytes(*b"avc1")); + } + brands.push(FourCc::from_bytes(*b"iso4")); + return (FourCc::from_bytes(*b"iso4"), 1, brands); + } + if has_h263 { + return ( + FourCc::from_bytes(*b"isom"), + 1, + vec![ + FourCc::from_bytes(*b"isom"), + FourCc::from_bytes(*b"3gg6"), + FourCc::from_bytes(*b"3gg5"), + ], + ); + } + if has_avc { + return ( + FourCc::from_bytes(*b"isom"), + 1, + vec![FourCc::from_bytes(*b"isom"), FourCc::from_bytes(*b"avc1")], + ); + } + ( + FourCc::from_bytes(*b"isom"), + 1, + vec![FourCc::from_bytes(*b"isom")], + ) +} + +fn sample_entry_matches(sample_entry_box: &[u8], names: &[&[u8; 4]]) -> bool { + sample_entry_box + .get(4..8) + .map(|box_type| { + names + .iter() + .any(|candidate| box_type == candidate.as_slice()) + }) + .unwrap_or(false) +} + +fn sample_entry_esds_oti_matches( + sample_entry_box: &[u8], + sample_entry_types: &[&[u8; 4]], + object_type_indication: u8, +) -> Result { + if !sample_entry_matches(sample_entry_box, sample_entry_types) { + return Ok(false); + } + let sample_entry_type = sample_entry_box_type(sample_entry_box)?; + let child_boxes = match sample_entry_type { + value if value == FourCc::from_bytes(*b"mp4a") => { + decode_audio_sample_entry_parts(sample_entry_box)?.1 + } + value if value == FourCc::from_bytes(*b"mp4v") => { + decode_visual_sample_entry_parts(sample_entry_box)?.1 + } + _ => return Ok(false), + }; + for child_box in child_boxes { + if sample_entry_box_type(&child_box)? != FourCc::from_bytes(*b"esds") { + continue; + } + let esds = decode_typed_box::(&child_box)?; + for descriptor in esds.descriptors { + if let Some(decoder_config) = descriptor.decoder_config_descriptor + && decoder_config.object_type_indication == object_type_indication + { + return Ok(true); + } + } + } + Ok(false) +} + +fn mp4a_sample_entry_oti_matches( + sample_entry_box: &[u8], + object_type_indication: u8, +) -> Result { + sample_entry_esds_oti_matches(sample_entry_box, &[b"mp4a"], object_type_indication) +} + +fn sample_entry_mp4a_object_type_indication( + sample_entry_box: &[u8], +) -> Result, MuxError> { + if !sample_entry_matches(sample_entry_box, &[b"mp4a"]) { + return Ok(None); + } + let child_boxes = decode_audio_sample_entry_parts(sample_entry_box)?.1; + for child_box in child_boxes { + if sample_entry_box_type(&child_box)? != FourCc::from_bytes(*b"esds") { + continue; + } + let esds = decode_typed_box::(&child_box)?; + for descriptor in esds.descriptors { + if let Some(decoder_config) = descriptor.decoder_config_descriptor { + return Ok(Some(decoder_config.object_type_indication)); + } + } + } + Ok(None) +} + +fn mp4a_sample_entry_audio_profile_level_indication( + sample_entry_box: &[u8], +) -> Result, MuxError> { + if !sample_entry_matches(sample_entry_box, &[b"mp4a"]) { + return Ok(None); + } + let (sample_entry, child_boxes, _) = decode_audio_sample_entry_parts(sample_entry_box)?; + for child_box in child_boxes { + if sample_entry_box_type(&child_box)? != FourCc::from_bytes(*b"esds") { + continue; + } + let esds = decode_typed_box::(&child_box)?; + if let Some(profile) = + detect_aac_profile(&esds).map_err(|_| MuxError::LayoutOverflow("mp4a esds decode"))? + { + let sample_rate = sample_entry.sample_rate >> 16; + return Ok(Some(match profile.audio_object_type { + 42 => 0x0f, + 29 => 0x2c, + 5 => { + if sample_rate <= 24_000 { + 0x28 + } else { + 0x2c + } + } + 2 if (sample_entry.sample_rate >> 16) == 24_000 => 0x28, + _ => 0x29, + })); + } + } + Ok(None) +} + +fn ec3_sample_entry_data_rate(sample_entry_box: &[u8]) -> Result, MuxError> { + if !sample_entry_matches(sample_entry_box, &[b"ec-3"]) { + return Ok(None); + } + let Ok((_, child_boxes, _)) = decode_audio_sample_entry_parts(sample_entry_box) else { + return Ok(None); + }; + for child_box in child_boxes { + if sample_entry_box_type(&child_box)? != FourCc::from_bytes(*b"dec3") { + continue; + } + return Ok(Some(decode_typed_box::(&child_box)?.data_rate)); + } + Ok(None) +} + +fn fragmented_audio_average_bitrate(track: &PreparedTrack<'_>) -> Option { + let summed_duration = track.samples.iter().try_fold(0_u64, |duration, sample| { + duration.checked_add(u64::from(sample.duration_movie)) + })?; + if summed_duration == 0 { + return None; + } + let total_sample_size = track + .samples + .iter() + .try_fold(0_u64, |size, sample| size.checked_add(sample.sample_size))?; + total_sample_size + .checked_mul(8)? + .checked_mul(u64::from(track.config.timescale()))? + .checked_div(summed_duration) +} + +fn fragmented_ec3_mehd_trims_one_tick( + sample_entry_box: &[u8], + sample_rate: u32, + sample_count: usize, +) -> Result { + if sample_rate != 48_000 { + return Ok(false); + } + if sample_count.is_multiple_of(2) { + return Ok(false); + } + Ok(ec3_sample_entry_data_rate(sample_entry_box)? != Some(640)) +} + +fn fragmented_mp4a_mehd_trims_one_tick(track: &PreparedTrack<'_>) -> Result { + if !track.samples.len().is_multiple_of(2) + || track.samples.last().map(|sample| sample.duration_movie) != Some(1_024) + { + return Ok(false); + } + let average_bitrate = fragmented_audio_average_bitrate(track); + if track_uses_imported_authority_headers(track) + && average_bitrate.is_some_and(|bitrate| bitrate > 0 && bitrate <= 8_000) + { + return Ok(false); + } + if sample_entry_audio_sample_rate_int(track.sample_entry_box) == Some(44_100) + && mp4a_sample_entry_audio_profile_level_indication(track.sample_entry_box)? == Some(0x29) + && average_bitrate.is_some_and(|bitrate| (170_000..=210_000).contains(&bitrate)) + { + return Ok(false); + } + Ok(true) +} + +fn sample_entry_mp4v_object_type_indication( + sample_entry_box: &[u8], +) -> Result, MuxError> { + if !sample_entry_matches(sample_entry_box, &[b"mp4v"]) { + return Ok(None); + } + let child_boxes = decode_visual_sample_entry_parts(sample_entry_box)?.1; + for child_box in child_boxes { + if sample_entry_box_type(&child_box)? != FourCc::from_bytes(*b"esds") { + continue; + } + let esds = decode_typed_box::(&child_box)?; + for descriptor in esds.descriptors { + if let Some(decoder_config) = descriptor.decoder_config_descriptor { + return Ok(Some(decoder_config.object_type_indication)); + } + } + } + Ok(None) +} + +fn prepare_tracks<'a>( + file_config: &MuxFileConfig, + track_configs: &'a [MuxTrackConfig], + plan: &'a MuxPlan, + use_override_decode_times: bool, +) -> Result>, MuxError> { + let mut config_by_track_id = BTreeMap::::new(); + for config in track_configs { + if config.timescale() == 0 { + return Err(MuxError::InvalidTrackTimescale { + track_id: config.track_id(), + }); + } + validate_language(config)?; + validate_sample_entry_box(config)?; + match config_by_track_id.entry(config.track_id()) { + Entry::Vacant(slot) => { + slot.insert(config); + } + Entry::Occupied(_) => { + return Err(MuxError::DuplicateTrackId { + track_id: config.track_id(), + }); + } + } + } + + let mut samples_by_track = BTreeMap::>::new(); + for item in plan.planned_items() { + samples_by_track + .entry(item.staged().track_id()) + .or_default() + .push(item); + } + + for track_id in samples_by_track.keys().copied() { + if !config_by_track_id.contains_key(&track_id) { + return Err(MuxError::MissingTrackId { track_id }); + } + } + + let mut prepared_tracks = Vec::with_capacity(track_configs.len()); + for config in track_configs { + let samples = samples_by_track + .remove(&config.track_id()) + .unwrap_or_default(); + prepared_tracks.push(prepare_track( + file_config, + plan, + config, + samples, + use_override_decode_times, + )?); + } + + Ok(prepared_tracks) +} + +fn prepare_track<'a>( + file_config: &MuxFileConfig, + plan: &'a MuxPlan, + config: &'a MuxTrackConfig, + samples: Vec<&'a super::MuxPlannedMediaItem>, + use_override_decode_times: bool, +) -> Result, MuxError> { + let mut previous_decode_time = None::; + let mut prepared_samples = Vec::with_capacity(samples.len()); + let mut max_decode_end_media = 0_u64; + let mut max_presentation_end_media = 0_u64; + let timing_override = config.flat_timing_override(); + if let Some(override_value) = timing_override { + if override_value.sample_durations.len() != samples.len() { + return Err(MuxError::InvalidOutputLayout { + layout: "flat", + message: format!( + "track {} authored a timing override with {} sample durations for {} samples", + config.track_id(), + override_value.sample_durations.len(), + samples.len(), + ), + }); + } + if override_value.composition_offsets.len() != samples.len() { + return Err(MuxError::InvalidOutputLayout { + layout: "flat", + message: format!( + "track {} authored a timing override with {} composition offsets for {} samples", + config.track_id(), + override_value.composition_offsets.len(), + samples.len(), + ), + }); + } + } + let mut overridden_decode_time_media = config.fragmented_decode_time_offset().unwrap_or(0); + + for (sample_index, sample) in samples.into_iter().enumerate() { + let staged = sample.staged(); + let sample_description_index = staged.sample_description_index(); + let sample_entry_count = u32::try_from(config.sample_entry_boxes().len()) + .map_err(|_| MuxError::LayoutOverflow("stsd entry_count"))?; + if sample_description_index == 0 || sample_description_index > sample_entry_count { + return Err(MuxError::InvalidOutputLayout { + layout: "mp4", + message: format!( + "track {} uses sample description index {} with {} sample entries", + config.track_id(), + sample_description_index, + sample_entry_count + ), + }); + } + if let Some(previous_decode_time) = previous_decode_time + && staged.decode_time() < previous_decode_time + { + return Err(MuxError::NonMonotonicTrackDecodeTime { + track_id: config.track_id(), + previous_decode_time, + next_decode_time: staged.decode_time(), + }); + } + previous_decode_time = Some(staged.decode_time()); + + let ( + duration_media, + composition_offset_media, + decode_time_media, + _decode_end_media, + duration_decode_end_media, + duration_presentation_end_media, + ) = if let Some(override_value) = timing_override { + let duration_media = u64::from(override_value.sample_durations[sample_index]); + let composition_offset_media = override_value.composition_offsets[sample_index]; + let duration_decode_time_media = overridden_decode_time_media; + let duration_decode_end_media = duration_decode_time_media + .checked_add(duration_media) + .ok_or(MuxError::LayoutOverflow("track decode end"))?; + overridden_decode_time_media = duration_decode_end_media; + let decode_time_media = if use_override_decode_times { + duration_decode_time_media + } else { + scale_movie_time_to_track( + config.track_id(), + staged.decode_time(), + file_config.movie_timescale(), + config.timescale(), + )? + }; + let decode_end_media = decode_time_media + .checked_add(duration_media) + .ok_or(MuxError::LayoutOverflow("track decode end"))?; + let duration_presentation_end_media = i128::from(duration_decode_time_media) + .saturating_add(i128::from(composition_offset_media)) + .saturating_add(i128::from(duration_media)); + ( + duration_media, + composition_offset_media, + decode_time_media, + decode_end_media, + duration_decode_end_media, + duration_presentation_end_media, + ) + } else { + let duration_media = scale_movie_time_to_track( + config.track_id(), + u64::from(staged.duration()), + file_config.movie_timescale(), + config.timescale(), + )?; + let composition_offset_media = scale_movie_offset_to_track( + config.track_id(), + i64::from(staged.composition_time_offset()), + file_config.movie_timescale(), + config.timescale(), + )?; + let decode_time_media = scale_movie_time_to_track( + config.track_id(), + staged.decode_time(), + file_config.movie_timescale(), + config.timescale(), + )?; + let decode_end_movie = staged + .decode_time() + .checked_add(u64::from(staged.duration())) + .ok_or(MuxError::LayoutOverflow("track decode end"))?; + let decode_end_media = scale_movie_time_to_track( + config.track_id(), + decode_end_movie, + file_config.movie_timescale(), + config.timescale(), + )?; + ( + duration_media, + composition_offset_media, + decode_time_media, + decode_end_media, + decode_end_media, + i128::from(decode_time_media) + .saturating_add(i128::from(composition_offset_media)) + .saturating_add(i128::from(duration_media)), + ) + }; + max_decode_end_media = max_decode_end_media.max(duration_decode_end_media); + if duration_presentation_end_media > 0 { + max_presentation_end_media = max_presentation_end_media.max( + u64::try_from(duration_presentation_end_media) + .map_err(|_| MuxError::LayoutOverflow("presentation end time"))?, + ); + } + prepared_samples.push(PreparedSample { + source_index: staged.source_index(), + source_data_offset: staged.data_offset(), + decode_time_movie: staged.decode_time(), + decode_time_media, + output_offset: sample.output_offset(), + sample_size: u64::from(staged.data_size()), + duration_movie: staged.duration(), + duration_media: u32::try_from(duration_media) + .map_err(|_| MuxError::LayoutOverflow("sample duration"))?, + composition_offset_movie: staged.composition_time_offset(), + composition_offset_media, + is_sync_sample: staged.is_sync_sample(), + sample_description_index, + }); + } + + let media_duration = max_decode_end_media + .max(max_presentation_end_media) + .saturating_sub(config.fragmented_decode_time_offset().unwrap_or(0)); + let presentation_duration_media = config + .edit_media_time() + .map_or(media_duration, |edit_media_time| { + media_duration.saturating_sub(edit_media_time) + }); + Ok(PreparedTrack { + config, + sample_entry_box: config.sample_entry_box(), + samples: prepared_samples, + chunk_sample_counts: if previous_decode_time.is_some() { + plan.chunk_sample_counts(config.track_id())?.to_vec() + } else { + Vec::new() + }, + fragmented_reference_group_fragment_counts: config + .fragmented_reference_group_fragment_counts() + .map(|counts| counts.to_vec()), + media_duration, + presentation_duration_media, + edit_media_time: config.edit_media_time(), + flat_timing_override: timing_override, + }) +} + +fn build_moov_bytes( + file_config: &MuxFileConfig, + tracks: &[PreparedTrack<'_>], + ftyp_size: u64, + mdat_header_size: u64, + mdat_data_start: u64, +) -> Result, MuxError> { + let auto_flat_creation_time = auto_flat_creation_time(file_config)?; + let mvhd = build_mvhd(file_config, tracks, auto_flat_creation_time)?; + let mut children = Vec::new(); + children.extend_from_slice(&encode_typed_box(&mvhd, &[])?); + if let Some(iods_bytes) = build_flat_iods_bytes(file_config, tracks)? { + children.extend_from_slice(&iods_bytes); + } + for track in tracks { + children.extend_from_slice(&build_trak_bytes( + file_config, + track, + ftyp_size, + mdat_header_size, + mdat_data_start, + auto_flat_creation_time, + )?); + } + if let Some(udta_bytes) = build_flat_udta_bytes(file_config, tracks)? { + children.extend_from_slice(&udta_bytes); + } + encode_typed_box(&Moov, &children) +} + +fn build_flat_iods_bytes( + file_config: &MuxFileConfig, + tracks: &[PreparedTrack<'_>], +) -> Result>, MuxError> { + if let Some(preserved_iods_bytes) = file_config.preserved_flat_iods_bytes() { + return Ok(Some(preserved_iods_bytes.to_vec())); + } + if !file_config.auto_flat_profile() { + return Ok(None); + } + + let has_audio = tracks.iter().any(|track| track.config.kind().is_audio()); + let has_mp4a = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"mp4a"])); + let has_vorbis_mp4a = tracks + .iter() + .any(|track| mp4a_sample_entry_oti_matches(track.sample_entry_box, 0xDD).unwrap_or(false)); + let has_voice_mp4a = tracks + .iter() + .any(|track| mp4a_sample_entry_oti_matches(track.sample_entry_box, 0xE1).unwrap_or(false)); + let first_mp4a_oti = tracks + .iter() + .find_map(|track| { + sample_entry_mp4a_object_type_indication(track.sample_entry_box).transpose() + }) + .transpose()?; + let first_mp4a_audio_profile_level_indication = tracks + .iter() + .find_map(|track| { + mp4a_sample_entry_audio_profile_level_indication(track.sample_entry_box).transpose() + }) + .transpose()?; + let first_flat_audio_profile_level_indication = tracks + .iter() + .find_map(|track| track.config.flat_audio_profile_level_indication()); + let has_opus = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"Opus"])); + let has_speex = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"spex"])); + let has_voice_3gpp_audio = tracks.iter().any(|track| { + sample_entry_matches( + track.sample_entry_box, + &[b"samr", b"sawb", b"sqcp", b"sevc", b"ssmv"], + ) + }); + let has_visual_track = tracks.iter().any(|track| track.config.kind().is_video()); + let has_timed_text_sample_entry = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"tx3g", b"wvtt", b"stpp"])); + let has_mhm = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"mhm1", b"mhm2"])); + let has_iamf = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"iamf"])); + let has_dts = tracks.iter().any(|track| { + sample_entry_matches( + track.sample_entry_box, + &[ + b"dtsc", b"dtse", b"dtsh", b"dtsl", b"dtsm", b"dts-", b"dtsx", b"dtsy", + ], + ) + }); + let has_mp4s = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"mp4s"])); + let has_mp4v = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"mp4v"])); + let first_mp4v_oti = tracks + .iter() + .find_map(|track| { + sample_entry_mp4v_object_type_indication(track.sample_entry_box).transpose() + }) + .transpose()?; + let first_mp4v_profile_level = tracks + .iter() + .find_map(|track| { + sample_entry_mp4v_visual_profile_level(track.sample_entry_box).transpose() + }) + .transpose()?; + let has_mpeg2_mp4v = matches!(first_mp4v_oti, Some(0x60..=0x65)); + let has_theora_mp4v = tracks.iter().any(|track| { + sample_entry_esds_oti_matches(track.sample_entry_box, &[b"mp4v"], 0xDF).unwrap_or(false) + }); + let has_other_iods_codec = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"mp4v", b"mp4s", b"spex"])); + let has_non_mp4a_audio = has_audio + && tracks.iter().any(|track| { + track.config.kind().is_audio() + && !sample_entry_matches(track.sample_entry_box, &[b"mp4a"]) + }); + let imported_authority_tracks = tracks.iter().all(track_uses_imported_authority_headers); + let has_avc = tracks.iter().any(|track| { + sample_entry_matches( + track.sample_entry_box, + &[b"avc1", b"avc2", b"avc3", b"avc4"], + ) + }); + let has_vvc = tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"vvc1", b"vvi1"])); + let has_imported_authority_mhm1_only = imported_authority_tracks + && has_mhm + && !has_avc + && !has_vvc + && !has_mp4a + && !has_other_iods_codec + && !has_mp4s + && !has_iamf; + let has_imported_authority_mha1_only = imported_authority_tracks + && tracks + .iter() + .any(|track| sample_entry_matches(track.sample_entry_box, &[b"mha1", b"mha2"])) + && !has_avc + && !has_vvc + && !has_mp4a + && !has_other_iods_codec + && !has_mp4s + && !has_iamf; + let has_imported_authority_opus_only = imported_authority_tracks + && has_opus + && !has_avc + && !has_vvc + && !has_mp4a + && !has_other_iods_codec + && !has_mp4s + && !has_iamf; + let has_imported_authority_vorbis_only = imported_authority_tracks + && has_vorbis_mp4a + && !has_avc + && !has_vvc + && !has_other_iods_codec + && !has_mp4s + && !has_iamf + && !has_mhm + && !has_opus; + let has_imported_authority_voice_mp4a_only = imported_authority_tracks + && has_voice_mp4a + && !has_avc + && !has_vvc + && !has_other_iods_codec + && !has_mp4s + && !has_iamf + && !has_mhm + && !has_opus; + let has_imported_authority_direct_voice_only = imported_authority_tracks + && has_voice_3gpp_audio + && !has_avc + && !has_vvc + && !has_mp4a + && !has_other_iods_codec + && !has_mp4s + && !has_iamf + && !has_mhm + && !has_opus; + let has_flat_iods_omitted_speex_only = tracks.iter().any(|track| track.config.omit_flat_iods()) + && has_speex + && !has_visual_track + && !has_avc + && !has_vvc + && !has_mp4a + && !has_mp4v + && !has_mp4s + && !has_iamf + && !has_mhm + && !has_opus; + let has_transport_clocked_mhm1 = tracks.iter().any(|track| { + sample_entry_matches(track.sample_entry_box, &[b"mhm1", b"mhm2"]) + && sample_entry_audio_sample_rate_int(track.sample_entry_box) + .is_some_and(|sample_rate| sample_rate != track.config.timescale()) + }); + let has_direct_vvc_only = !imported_authority_tracks + && has_vvc + && !has_audio + && !has_avc + && !has_other_iods_codec + && !has_mp4s + && !has_mhm + && !has_iamf; + if has_imported_authority_mhm1_only { + return Ok(None); + } + if has_imported_authority_mha1_only { + return Ok(None); + } + if has_imported_authority_opus_only { + return Ok(None); + } + if has_imported_authority_vorbis_only { + return Ok(None); + } + if has_imported_authority_voice_mp4a_only { + return Ok(None); + } + if has_imported_authority_direct_voice_only { + return Ok(None); + } + if has_flat_iods_omitted_speex_only { + return Ok(None); + } + if has_iamf + && !imported_authority_tracks + && !has_avc + && !has_vvc + && !has_mp4a + && !has_other_iods_codec + && !has_mp4s + && !has_mhm + { + return Ok(None); + } + if has_transport_clocked_mhm1 + && !has_avc + && !has_vvc + && !has_mp4a + && !has_other_iods_codec + && !has_mp4s + { + return Ok(None); + } + if has_direct_vvc_only { + return Ok(None); + } + if !(has_mp4a + || has_avc + || has_vvc + || has_opus + || has_other_iods_codec + || has_mp4s + || has_mhm + || has_iamf + || (has_audio && file_config.allow_audio_only_iods())) + { + return Ok(None); + } + + let descriptor = Descriptor::from_initial_object_descriptor(InitialObjectDescriptor { + object_descriptor_id: 1, + include_inline_profile_level_flag: false, + od_profile_level_indication: 0xff, + scene_profile_level_indication: 0xff, + audio_profile_level_indication: if has_mhm && has_avc { + 0xfe + } else if has_mhm { + first_flat_audio_profile_level_indication.unwrap_or(0x0c) + } else if has_vorbis_mp4a { + 0x10 + } else if has_mp4a { + if first_mp4a_oti == Some(0x40) { + first_mp4a_audio_profile_level_indication.unwrap_or(0x29) + } else { + 0xfe + } + } else if has_iamf + || ((has_dts || has_audio) && !has_avc && file_config.allow_audio_only_iods()) + || (has_voice_3gpp_audio && has_visual_track) + || has_opus + || (has_speex && !has_visual_track) + { + 0xfe + } else { + 0xff + }, + visual_profile_level_indication: if has_mpeg2_mp4v { + if imported_authority_tracks { + 0xfe + } else { + 0x0c + } + } else if let Some(profile_level_indication) = first_mp4v_profile_level { + profile_level_indication + } else if first_mp4v_oti == Some(0x6a) { + 0x6a + } else if has_theora_mp4v { + 0xfe + } else if has_mp4v { + 0x01 + } else if has_vvc && !has_avc && has_mp4a { + 0xff + } else if (has_vvc && !has_avc) || (has_avc && !has_audio && imported_authority_tracks) { + 0xfe + } else if has_avc && has_mp4a && imported_authority_tracks { + 0xff + } else if has_avc && has_mp4a { + 0x7f + } else if has_avc && (has_non_mp4a_audio || has_timed_text_sample_entry) { + 0x15 + } else if has_avc { + 0x7f + } else { + 0xff + }, + graphics_profile_level_indication: 0xff, + ..InitialObjectDescriptor::default() + }) + .map_err(|error| MuxError::InvalidOutputLayout { + layout: "flat", + message: format!("failed to build iods descriptor: {error}"), + })?; + + let mut iods = Iods::default(); + iods.descriptor = Some(descriptor); + Ok(Some(encode_typed_box(&iods, &[])?)) +} + +fn sample_entry_mp4v_visual_profile_level(sample_entry_box: &[u8]) -> Result, MuxError> { + if !sample_entry_matches(sample_entry_box, &[b"mp4v"]) { + return Ok(None); + } + let child_boxes = decode_visual_sample_entry_parts(sample_entry_box)?.1; + for child_box in child_boxes { + if sample_entry_box_type(&child_box)? != FourCc::from_bytes(*b"esds") { + continue; + } + let esds = decode_typed_box::(&child_box)?; + if let Some(decoder_specific_info) = esds.decoder_specific_info() { + return Ok(super::demux::mp4v_profile_level_indication( + decoder_specific_info, + )); + } + } + Ok(None) +} + +fn build_flat_udta_bytes( + file_config: &MuxFileConfig, + tracks: &[PreparedTrack<'_>], +) -> Result>, MuxError> { + if let Some(preserved_udta_bytes) = file_config.preserved_flat_udta_bytes() { + return Ok(Some(preserved_udta_bytes.to_vec())); + } + if !file_config.auto_flat_profile() { + return Ok(None); + } + let tool_metadata = if let Some(encoding_metadata) = file_config.flat_source_encoding_metadata() + { + Some(encoding_metadata) + } else if file_config.emit_default_flat_tool_metadata() { + Some(FLAT_TOOL_METADATA_VALUE) + } else { + None + }; + let encoder_metadata = file_config.flat_source_encoder_metadata(); + if tool_metadata.is_none() && encoder_metadata.is_none() { + return Ok(None); + } + + let mut metadata_handler = Hdlr::default(); + metadata_handler.handler_type = FourCc::from_bytes(*b"mdir"); + metadata_handler.name.clear(); + + let mut ilst_children = Vec::new(); + if let Some(tool_metadata) = tool_metadata + && !tool_metadata.is_empty() + { + let mut encoding_tool_item = IlstMetaContainer::default(); + encoding_tool_item.set_box_type(FourCc::from_bytes([0xA9, b't', b'o', b'o'])); + + let encoding_tool_data = Data { + data_type: DATA_TYPE_STRING_UTF8, + data_lang: 0, + data: tool_metadata.as_bytes().to_vec(), + }; + let encoding_tool_data_bytes = encode_typed_box(&encoding_tool_data, &[])?; + ilst_children.extend_from_slice(&encode_typed_box( + &encoding_tool_item, + &encoding_tool_data_bytes, + )?); + } + if let Some(encoder_metadata) = encoder_metadata + && !encoder_metadata.is_empty() + { + let mut encoder_item = IlstMetaContainer::default(); + encoder_item.set_box_type(FourCc::from_bytes([0xA9, b'e', b'n', b'c'])); + + let encoder_data = Data { + data_type: DATA_TYPE_STRING_UTF8, + data_lang: 0, + data: encoder_metadata.as_bytes().to_vec(), + }; + let encoder_data_bytes = encode_typed_box(&encoder_data, &[])?; + ilst_children.extend_from_slice(&encode_typed_box(&encoder_item, &encoder_data_bytes)?); + } + if ilst_children.is_empty() { + return Ok(None); + } + + let ilst_bytes = encode_typed_box(&Ilst, &ilst_children)?; + let meta_children = [encode_typed_box(&metadata_handler, &[])?, ilst_bytes].concat(); + let meta_bytes = if uses_quicktime_flat_metadata_container(tracks) { + encode_raw_box(FourCc::from_bytes(*b"meta"), &meta_children)? + } else { + encode_typed_box(&Meta::default(), &meta_children)? + }; + Ok(Some(encode_typed_box(&Udta, &meta_bytes)?)) +} + +fn uses_quicktime_flat_metadata_container(tracks: &[PreparedTrack<'_>]) -> bool { + infer_auto_flat_ftyp_profile(tracks).0 == FourCc::from_bytes(*b"qt ") +} + +fn build_free_padding_bytes(file_config: &MuxFileConfig) -> Result, MuxError> { + let _ = file_config; + encode_raw_box( + FourCc::from_bytes(*b"free"), + &[0_u8; DEFAULT_FREE_PADDING_SIZE], + ) +} + +fn current_isom_time() -> Result { + let unix_seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| MuxError::LayoutOverflow("current MP4 time"))? + .as_secs(); + let isom_seconds = unix_seconds + .checked_add(ISOM_UNIX_EPOCH_OFFSET) + .ok_or(MuxError::LayoutOverflow("current MP4 time"))?; + u32::try_from(isom_seconds).map_err(|_| MuxError::LayoutOverflow("current MP4 time")) +} + +fn auto_flat_creation_time(file_config: &MuxFileConfig) -> Result, MuxError> { + if file_config.auto_flat_profile() { + Ok(Some(current_isom_time()?)) + } else { + Ok(None) + } +} + +fn build_mvhd( + file_config: &MuxFileConfig, + tracks: &[PreparedTrack<'_>], + auto_flat_creation_time: Option, +) -> Result { + let movie_timescale = flat_movie_header_timescale(file_config); + let movie_duration = tracks + .iter() + .map(|track| flat_movie_duration(track, movie_timescale)) + .max() + .unwrap_or(0); + let next_track_id = tracks + .iter() + .map(|track| track.config.track_id()) + .max() + .unwrap_or(0) + .checked_add(1) + .ok_or(MuxError::LayoutOverflow("next_track_id"))?; + + let mut mvhd = Mvhd::default(); + mvhd.timescale = movie_timescale; + if movie_duration > u64::from(u32::MAX) + && !tracks.iter().all(track_uses_direct_iamf_flat_timing) + { + mvhd.set_version(1); + mvhd.duration_v1 = movie_duration; + } else { + mvhd.duration_v0 = movie_duration as u32; + } + mvhd.rate = 0x0001_0000; + mvhd.volume = 0x0100; + mvhd.matrix = IDENTITY_MATRIX; + mvhd.next_track_id = next_track_id; + if let Some(auto_flat_creation_time) = auto_flat_creation_time { + let modification_time = file_config + .flat_source_movie_modification_time() + .unwrap_or(u64::from(auto_flat_creation_time)); + apply_flat_movie_header_times( + &mut mvhd, + file_config.flat_source_movie_creation_time(), + modification_time, + )?; + } + Ok(mvhd) +} + +fn flat_movie_header_timescale(file_config: &MuxFileConfig) -> u32 { + if file_config.auto_flat_profile() && !file_config.preserve_auto_flat_movie_timescale() { + AUTO_FLAT_MOVIE_TIMESCALE + } else { + file_config.movie_timescale() + } +} + +fn flat_movie_duration(track: &PreparedTrack<'_>, movie_timescale: u32) -> u64 { + let presentation_duration_media = track + .flat_timing_override + .map(|override_value| override_value.presentation_duration) + .unwrap_or(track.presentation_duration_media); + if movie_timescale == track.config.timescale() { + return presentation_duration_media; + } + let scaled_duration = presentation_duration_media.saturating_mul(u64::from(movie_timescale)); + if track_uses_imported_authority_headers(track) { + let divisor = u64::from(track.config.timescale()); + scaled_duration + .saturating_add(divisor / 2) + .checked_div(divisor) + .unwrap_or(0) + } else { + scaled_duration / u64::from(track.config.timescale()) + } +} + +fn build_trak_bytes( + file_config: &MuxFileConfig, + track: &PreparedTrack<'_>, + ftyp_size: u64, + mdat_header_size: u64, + mdat_data_start: u64, + auto_flat_creation_time: Option, +) -> Result, MuxError> { + let tkhd = build_tkhd(file_config, track, auto_flat_creation_time)?; + let mdia = build_mdia_bytes( + file_config, + track, + ftyp_size, + mdat_header_size, + mdat_data_start, + auto_flat_creation_time, + )?; + let mut children = vec![encode_typed_box(&tkhd, &[])?]; + let mut preserved_edts = Vec::new(); + let mut preserved_tref = Vec::new(); + let mut preserved_other_boxes = Vec::new(); + for child_box in track.config.preserved_flat_trak_boxes().iter().cloned() { + match encoded_box_type(&child_box).ok() { + Some(value) if value == FourCc::from_bytes(*b"edts") => preserved_edts.push(child_box), + Some(value) if value == FourCc::from_bytes(*b"tref") => preserved_tref.push(child_box), + _ => preserved_other_boxes.push(child_box), + } + } + if !preserved_edts.is_empty() { + children.extend(preserved_edts); + } else if let Some(edts) = build_edts_bytes( + track, + flat_movie_duration(track, flat_movie_header_timescale(file_config)), + )? { + children.push(edts); + } + children.extend(preserved_tref); + children.push(mdia); + children.extend(preserved_other_boxes); + encode_typed_box(&Trak, &children.concat()) +} + +fn build_tkhd( + file_config: &MuxFileConfig, + track: &PreparedTrack<'_>, + auto_flat_creation_time: Option, +) -> Result { + let mut tkhd = + build_tkhd_with_movie_timescale(track, flat_movie_header_timescale(file_config))?; + if let Some(auto_flat_creation_time) = auto_flat_creation_time { + let modification_time = if track.config.kind() == MuxTrackKind::Video + && track_uses_imported_authority_headers(track) + { + track + .config + .flat_source_track_modification_time() + .filter(|modification_time| *modification_time != 0) + .unwrap_or(u64::from(auto_flat_creation_time)) + } else { + u64::from(auto_flat_creation_time) + }; + apply_flat_track_header_times( + &mut tkhd, + track.config.flat_source_track_creation_time(), + modification_time, + )?; + } + Ok(tkhd) +} + +fn build_tkhd_with_movie_timescale( + track: &PreparedTrack<'_>, + movie_timescale: u32, +) -> Result { + let mut tkhd = Tkhd::default(); + tkhd.set_flags(track.config.tkhd_flags()); + tkhd.track_id = track.config.track_id(); + let movie_duration = flat_movie_duration(track, movie_timescale); + if movie_duration > u64::from(u32::MAX) && !track_uses_direct_iamf_flat_timing(track) { + tkhd.set_version(1); + tkhd.duration_v1 = movie_duration; + } else { + tkhd.duration_v0 = movie_duration as u32; + } + tkhd.layer = 0; + tkhd.alternate_group = track.config.alternate_group(); + tkhd.volume = track.config.volume(); + tkhd.matrix = track.config.matrix(); + tkhd.width = track + .config + .track_width_fixed_16_16() + .unwrap_or_else(|| u32::from(track.config.track_width()) << 16); + tkhd.height = track + .config + .track_height_fixed_16_16() + .unwrap_or_else(|| u32::from(track.config.track_height()) << 16); + Ok(tkhd) +} + +fn build_mdia_bytes( + file_config: &MuxFileConfig, + track: &PreparedTrack<'_>, + ftyp_size: u64, + mdat_header_size: u64, + mdat_data_start: u64, + auto_flat_creation_time: Option, +) -> Result, MuxError> { + let mdhd = build_mdhd(track, auto_flat_creation_time)?; + let hdlr = build_hdlr(track); + let minf = build_minf_bytes( + file_config, + track, + ftyp_size, + mdat_header_size, + mdat_data_start, + )?; + let children = [ + encode_typed_box(&mdhd, &[])?, + encode_typed_box(&hdlr, &[])?, + minf, + ] + .concat(); + encode_typed_box(&Mdia, &children) +} + +fn build_mdhd_base(track: &PreparedTrack<'_>) -> Result { + let mut mdhd = Mdhd::default(); + mdhd.timescale = track.config.timescale(); + let media_duration = track + .flat_timing_override + .map(|override_value| override_value.media_duration) + .unwrap_or(track.media_duration); + if media_duration > u64::from(u32::MAX) { + mdhd.set_version(1); + mdhd.duration_v1 = media_duration; + } else { + mdhd.duration_v0 = + u32::try_from(media_duration).map_err(|_| MuxError::LayoutOverflow("mdhd duration"))?; + } + mdhd.language = encode_iso639_2_language(track.config)?; + Ok(mdhd) +} + +fn build_mdhd( + track: &PreparedTrack<'_>, + auto_flat_creation_time: Option, +) -> Result { + let mut mdhd = build_mdhd_base(track)?; + if let Some(auto_flat_creation_time) = auto_flat_creation_time { + let modification_time = if track.config.kind() == MuxTrackKind::Video + && track_uses_imported_authority_headers(track) + { + track + .config + .flat_source_media_modification_time() + .unwrap_or(u64::from(auto_flat_creation_time)) + } else { + u64::from(auto_flat_creation_time) + }; + apply_flat_media_header_times( + &mut mdhd, + track.config.flat_source_media_creation_time(), + modification_time, + )?; + } + Ok(mdhd) +} + +fn apply_flat_track_header_times( + tkhd: &mut Tkhd, + source_creation_time: Option, + modification_time: u64, +) -> Result<(), MuxError> { + let creation_time = source_creation_time.unwrap_or(modification_time); + if tkhd.version() == 1 + || creation_time > u64::from(u32::MAX) + || modification_time > u64::from(u32::MAX) + { + tkhd.set_version(1); + tkhd.creation_time_v1 = creation_time; + tkhd.modification_time_v1 = modification_time; + } else { + tkhd.creation_time_v0 = u32::try_from(creation_time) + .map_err(|_| MuxError::LayoutOverflow("tkhd creation time"))?; + tkhd.modification_time_v0 = u32::try_from(modification_time) + .map_err(|_| MuxError::LayoutOverflow("tkhd modification time"))?; + } + Ok(()) +} + +fn apply_flat_movie_header_times( + mvhd: &mut Mvhd, + source_creation_time: Option, + modification_time: u64, +) -> Result<(), MuxError> { + let creation_time = source_creation_time.unwrap_or(modification_time); + if mvhd.version() == 1 + || creation_time > u64::from(u32::MAX) + || modification_time > u64::from(u32::MAX) + { + mvhd.set_version(1); + mvhd.creation_time_v1 = creation_time; + mvhd.modification_time_v1 = modification_time; + } else { + mvhd.creation_time_v0 = u32::try_from(creation_time) + .map_err(|_| MuxError::LayoutOverflow("mvhd creation time"))?; + mvhd.modification_time_v0 = u32::try_from(modification_time) + .map_err(|_| MuxError::LayoutOverflow("mvhd modification time"))?; + } + Ok(()) +} + +fn apply_flat_media_header_times( + mdhd: &mut Mdhd, + source_creation_time: Option, + modification_time: u64, +) -> Result<(), MuxError> { + let creation_time = source_creation_time.unwrap_or(modification_time); + if mdhd.version() == 1 + || creation_time > u64::from(u32::MAX) + || modification_time > u64::from(u32::MAX) + { + mdhd.set_version(1); + mdhd.creation_time_v1 = creation_time; + mdhd.modification_time_v1 = modification_time; + } else { + mdhd.creation_time_v0 = u32::try_from(creation_time) + .map_err(|_| MuxError::LayoutOverflow("mdhd creation time"))?; + mdhd.modification_time_v0 = u32::try_from(modification_time) + .map_err(|_| MuxError::LayoutOverflow("mdhd modification time"))?; + } + Ok(()) +} + +fn build_hdlr(track: &PreparedTrack<'_>) -> Hdlr { + let mut hdlr = Hdlr::default(); + hdlr.handler_type = match track.config.kind() { + MuxTrackKind::Audio => FourCc::from_bytes(*b"soun"), + MuxTrackKind::Video => video_handler_type(track.config.sample_entry_box()), + MuxTrackKind::Text => FourCc::from_bytes(*b"text"), + MuxTrackKind::Subtitle => subtitle_handler_type(track.config.sample_entry_box()), + }; + hdlr.name = track.config.handler_name().to_string(); + hdlr +} + +fn video_handler_type(sample_entry_box: &[u8]) -> FourCc { + if sample_entry_matches(sample_entry_box, &[b"mp4s"]) { + SDSM + } else { + FourCc::from_bytes(*b"vide") + } +} + +fn subtitle_handler_type(sample_entry_box: &[u8]) -> FourCc { + if sample_entry_box.len() >= 8 + && FourCc::from_bytes([ + sample_entry_box[4], + sample_entry_box[5], + sample_entry_box[6], + sample_entry_box[7], + ]) == FourCc::from_bytes(*b"mp4s") + { + FourCc::from_bytes(*b"subp") + } else { + FourCc::from_bytes(*b"subt") + } +} + +fn fragmented_mehd_duration( + movie_timescale: u32, + track: &PreparedTrack<'_>, +) -> Result { + let sample_entry_type = sample_entry_box_type(track.sample_entry_box)?; + if sample_entry_type == FourCc::from_bytes(*b"vp08") { + let mut duration = track.samples.iter().try_fold(0_u64, |duration, sample| { + duration + .checked_add(u64::from(sample.duration_movie)) + .ok_or(MuxError::LayoutOverflow("fragmented mehd duration")) + }); + if track.config.timescale() == 30_000 { + duration = duration.map(|value| value.saturating_sub(1)); + } + return duration; + } + if track.config.kind() == MuxTrackKind::Audio { + let summed_sample_duration = track.samples.iter().try_fold(0_u64, |duration, sample| { + duration + .checked_add(u64::from(sample.duration_movie)) + .ok_or(MuxError::LayoutOverflow("fragmented mehd duration")) + })?; + let should_trim_one_tick = if sample_entry_type == FourCc::from_bytes(*b"ec-3") { + fragmented_ec3_mehd_trims_one_tick( + track.sample_entry_box, + track.config.timescale(), + track.samples.len(), + )? + } else if sample_entry_type == FourCc::from_bytes(*b"mp4a") { + fragmented_mp4a_mehd_trims_one_tick(track)? + } else { + false + }; + return Ok(if should_trim_one_tick { + summed_sample_duration.saturating_sub(1) + } else { + summed_sample_duration + }); + } + let media_duration = if track.config.kind() == MuxTrackKind::Audio { + track.media_duration + } else { + track.presentation_duration_media + }; + let mut duration = scale_track_time_to_movie( + track.config.track_id(), + i64::try_from(media_duration) + .map_err(|_| MuxError::LayoutOverflow("fragmented mehd duration"))?, + track.config.timescale(), + movie_timescale, + ) + .and_then(|value| { + u64::try_from(value).map_err(|_| MuxError::LayoutOverflow("fragmented mehd duration")) + })?; + if fragmented_track_uses_trimmed_non_square_avc_pasp(track)? { + duration = duration.saturating_sub(1); + } + Ok(duration) +} + +fn build_minf_bytes( + file_config: &MuxFileConfig, + track: &PreparedTrack<'_>, + ftyp_size: u64, + mdat_header_size: u64, + mdat_data_start: u64, +) -> Result, MuxError> { + let media_header = match track.config.kind() { + MuxTrackKind::Audio => { + let smhd = Smhd::default(); + encode_typed_box(&smhd, &[])? + } + MuxTrackKind::Video => { + let mut vmhd = Vmhd::default(); + vmhd.set_flags(VMHD_DEFAULT_FLAGS); + encode_typed_box(&vmhd, &[])? + } + MuxTrackKind::Text => { + let nmhd = Nmhd::default(); + encode_typed_box(&nmhd, &[])? + } + MuxTrackKind::Subtitle => { + build_subtitle_media_header_bytes(track.config.sample_entry_box())? + } + }; + let dinf = build_dinf_bytes()?; + let stbl = build_stbl_bytes( + file_config, + track, + ftyp_size, + mdat_header_size, + mdat_data_start, + )?; + encode_typed_box(&Minf, &[media_header, dinf, stbl].concat()) +} + +fn build_dinf_bytes() -> Result, MuxError> { + let mut url = Url::default(); + url.set_flags(0x0000_0001); + let mut dref = Dref::default(); + dref.entry_count = 1; + let dref_children = encode_typed_box(&url, &[])?; + let dref_bytes = encode_typed_box(&dref, &dref_children)?; + encode_typed_box(&Dinf, &dref_bytes) +} + +fn build_subtitle_media_header_bytes(sample_entry_box: &[u8]) -> Result, MuxError> { + if sample_entry_matches(sample_entry_box, &[b"mp4s"]) { + return encode_typed_box(&Nmhd::default(), &[]); + } + encode_typed_box(&Sthd::default(), &[]) +} + +fn build_stbl_bytes( + _file_config: &MuxFileConfig, + track: &PreparedTrack<'_>, + _ftyp_size: u64, + _mdat_header_size: u64, + mdat_data_start: u64, +) -> Result, MuxError> { + let stsd = build_stsd_bytes(track)?; + let stts = build_stts(track)?; + let stsc = preserved_flat_stsc_or_built(track)?; + let stsz = build_stsz(track)?; + let chunk_offsets = build_chunk_offsets(track, mdat_data_start)?; + let mut has_preserved_sbgp = false; + let mut has_preserved_sgpd = false; + let mut preserved_cslg_boxes = Vec::new(); + let mut preserved_other_boxes = Vec::new(); + for box_bytes in track.config.preserved_flat_stbl_boxes().iter().cloned() { + match box_bytes + .get(4..8) + .and_then(|box_type| box_type.try_into().ok()) + .map(FourCc::from_bytes) + { + Some(CSLG) => preserved_cslg_boxes.push(box_bytes), + Some(SBGP) => { + has_preserved_sbgp = true; + preserved_other_boxes.push(box_bytes); + } + Some(SGPD) => { + has_preserved_sgpd = true; + preserved_other_boxes.push(box_bytes); + } + _ => preserved_other_boxes.push(box_bytes), + } + } + let mut children = vec![stsd, encode_typed_box(&stts, &[])?]; + if let Some(ctts) = build_ctts(track)? { + children.push(encode_typed_box(&ctts, &[])?); + } + children.extend(preserved_cslg_boxes); + if let Some(stss) = build_stss(track)? { + children.push(encode_typed_box(&stss, &[])?); + } + children.push(encode_typed_box(&stsc, &[])?); + children.push(encode_typed_box(&stsz, &[])?); + if chunk_offsets + .iter() + .all(|offset| *offset <= u64::from(u32::MAX)) + { + children.push(encode_typed_box(&build_stco(&chunk_offsets)?, &[])?); + } else { + children.push(encode_typed_box(&build_co64(&chunk_offsets)?, &[])?); + } + if let Some(sample_roll_distance) = track.config.sample_roll_distance() { + if !has_preserved_sgpd { + children.push(encode_typed_box( + &build_roll_sgpd(sample_roll_distance), + &[], + )?); + } + if track.config.emit_roll_sbgp() && !has_preserved_sbgp { + children.push(encode_typed_box( + &build_roll_sbgp( + u32::try_from(track.samples.len()) + .map_err(|_| MuxError::LayoutOverflow("roll sample count"))?, + ), + &[], + )?); + } + } + children.extend(preserved_other_boxes); + + encode_typed_box(&Stbl, &children.concat()) +} + +fn preserved_flat_stsc_or_built(track: &PreparedTrack<'_>) -> Result { + if let Some(stsc) = track.config.flat_stsc_override() + && stsc_matches_chunk_sample_counts( + stsc, + &track.chunk_sample_counts, + track.config.sample_entry_boxes().len(), + ) + { + return Ok(stsc.clone()); + } + build_stsc(track) +} + +fn stsc_matches_chunk_sample_counts( + stsc: &Stsc, + chunk_sample_counts: &[u32], + sample_entry_count: usize, +) -> bool { + let mut expanded = Vec::with_capacity(chunk_sample_counts.len()); + for (index, entry) in stsc.entries.iter().enumerate() { + if entry.first_chunk == 0 + || entry.sample_description_index == 0 + || usize::try_from(entry.sample_description_index) + .ok() + .is_none_or(|index| index > sample_entry_count) + { + return false; + } + let next_first_chunk = stsc + .entries + .get(index + 1) + .map(|next| next.first_chunk) + .unwrap_or_else(|| { + u32::try_from(chunk_sample_counts.len()) + .unwrap_or(u32::MAX) + .saturating_add(1) + }); + if next_first_chunk <= entry.first_chunk { + return false; + } + for _ in entry.first_chunk..next_first_chunk { + expanded.push(entry.samples_per_chunk); + } + } + expanded == chunk_sample_counts +} + +fn build_stsd_bytes(track: &PreparedTrack<'_>) -> Result, MuxError> { + let mut stsd = Stsd::default(); + stsd.entry_count = u32::try_from(track.config.sample_entry_boxes().len()) + .map_err(|_| MuxError::LayoutOverflow("stsd entry_count"))?; + encode_typed_box(&stsd, &track.config.sample_entry_boxes().concat()) +} + +fn build_stts(track: &PreparedTrack<'_>) -> Result { + let entries = if let Some(override_value) = track.flat_timing_override { + encode_stts_runs( + track.config.stts_run_encoding_mode(), + override_value.sample_durations.iter().copied(), + ) + } else { + encode_stts_runs( + track.config.stts_run_encoding_mode(), + track.samples.iter().map(|sample| sample.duration_media), + ) + }; + let mut stts = Stts::default(); + stts.entry_count = + u32::try_from(entries.len()).map_err(|_| MuxError::LayoutOverflow("stts entry_count"))?; + stts.entries = entries + .into_iter() + .map(|(sample_count, sample_delta)| SttsEntry { + sample_count, + sample_delta, + }) + .collect(); + Ok(stts) +} + +fn encode_stts_runs(encoding_mode: super::SttsRunEncodingMode, values: I) -> Vec<(u32, u32)> +where + I: IntoIterator, +{ + match encoding_mode { + super::SttsRunEncodingMode::CollapseIdentical => run_length_encode_u32(values), + super::SttsRunEncodingMode::PreservePerSample => { + values.into_iter().map(|value| (1, value)).collect() + } + } +} + +fn build_ctts(track: &PreparedTrack<'_>) -> Result, MuxError> { + if track + .samples + .iter() + .all(|sample| sample.composition_offset_media == 0) + { + return Ok(None); + } + + let use_version_one = track + .samples + .iter() + .any(|sample| sample.composition_offset_media < 0); + let runs = run_length_encode_i32( + track + .samples + .iter() + .map(|sample| sample.composition_offset_media), + ); + let mut ctts = Ctts::default(); + if use_version_one { + ctts.set_version(1); + } + ctts.entry_count = + u32::try_from(runs.len()).map_err(|_| MuxError::LayoutOverflow("ctts entry_count"))?; + ctts.entries = runs + .into_iter() + .map(|(sample_count, sample_offset)| CttsEntry { + sample_count, + sample_offset_v0: u32::try_from(sample_offset).unwrap_or(0), + sample_offset_v1: sample_offset, + }) + .collect(); + Ok(Some(ctts)) +} + +fn build_stsc(track: &PreparedTrack<'_>) -> Result { + let encoded_runs = build_stsc_runs(track)?; + let mut stsc = Stsc::default(); + stsc.entry_count = u32::try_from(encoded_runs.len()) + .map_err(|_| MuxError::LayoutOverflow("stsc entry_count"))?; + let mut first_chunk = 1_u32; + stsc.entries = Vec::with_capacity(encoded_runs.len()); + for (chunk_run_length, samples_per_chunk, sample_description_index) in encoded_runs { + stsc.entries.push(StscEntry { + first_chunk, + samples_per_chunk, + sample_description_index, + }); + first_chunk = first_chunk + .checked_add(chunk_run_length) + .ok_or(MuxError::LayoutOverflow("stsc first_chunk"))?; + } + Ok(stsc) +} + +fn build_stsc_runs(track: &PreparedTrack<'_>) -> Result, MuxError> { + let chunk_mappings = flat_chunk_sample_description_mappings(track)?; + let mut encoded_runs = run_length_encode_stsc_mappings(chunk_mappings); + if track.config.stsc_run_encoding_mode() == super::StscRunEncodingMode::PreserveTerminalBoundary + && track.chunk_sample_counts.len() > 1 + && let Some((run_length, samples_per_chunk, sample_description_index)) = + encoded_runs.last().copied() + && run_length > 1 + { + encoded_runs.pop(); + encoded_runs.push((run_length - 1, samples_per_chunk, sample_description_index)); + encoded_runs.push((1, samples_per_chunk, sample_description_index)); + } + Ok(encoded_runs) +} + +fn flat_chunk_sample_description_mappings( + track: &PreparedTrack<'_>, +) -> Result, MuxError> { + let mut mappings = Vec::with_capacity(track.chunk_sample_counts.len()); + let mut sample_index = 0_usize; + for &samples_per_chunk in &track.chunk_sample_counts { + let chunk_len = usize::try_from(samples_per_chunk) + .map_err(|_| MuxError::LayoutOverflow("stsc sample count"))?; + let chunk_end = sample_index + .checked_add(chunk_len) + .ok_or(MuxError::LayoutOverflow("stsc sample indexing"))?; + let chunk_samples = track.samples.get(sample_index..chunk_end).ok_or_else(|| { + MuxError::InvalidChunkPlan { + track_id: track.config.track_id(), + message: "chunk boundaries ran past the staged sample count".to_string(), + } + })?; + let sample_description_index = chunk_samples + .first() + .map(|sample| sample.sample_description_index) + .unwrap_or(1); + if sample_description_index == 0 + || usize::try_from(sample_description_index) + .ok() + .is_none_or(|index| index > track.config.sample_entry_boxes().len()) + { + return Err(MuxError::InvalidOutputLayout { + layout: "flat", + message: format!( + "track {} uses sample description index {} with {} sample entries", + track.config.track_id(), + sample_description_index, + track.config.sample_entry_boxes().len() + ), + }); + } + if !chunk_samples + .iter() + .all(|sample| sample.sample_description_index == sample_description_index) + { + return Err(MuxError::InvalidOutputLayout { + layout: "flat", + message: format!( + "track {} has mixed sample descriptions inside one chunk", + track.config.track_id() + ), + }); + } + mappings.push((samples_per_chunk, sample_description_index)); + sample_index = chunk_end; + } + Ok(mappings) +} + +fn run_length_encode_stsc_mappings(values: I) -> Vec<(u32, u32, u32)> +where + I: IntoIterator, +{ + let mut runs = Vec::new(); + for (samples_per_chunk, sample_description_index) in values { + if let Some((run_length, previous_samples_per_chunk, previous_description_index)) = + runs.last_mut() + && *previous_samples_per_chunk == samples_per_chunk + && *previous_description_index == sample_description_index + { + *run_length += 1; + continue; + } + runs.push((1, samples_per_chunk, sample_description_index)); + } + runs +} + +fn build_stsz(track: &PreparedTrack<'_>) -> Result { + let mut stsz = Stsz::default(); + stsz.sample_count = + u32::try_from(track.samples.len()).map_err(|_| MuxError::LayoutOverflow("sample_count"))?; + if let Some(sample_size) = all_equal_u64(track.samples.iter().map(|sample| sample.sample_size)) + { + stsz.sample_size = + u32::try_from(sample_size).map_err(|_| MuxError::LayoutOverflow("sample size"))?; + } else { + stsz.sample_size = 0; + stsz.entry_size = track + .samples + .iter() + .map(|sample| sample.sample_size) + .collect(); + } + Ok(stsz) +} + +fn build_chunk_offsets( + track: &PreparedTrack<'_>, + mdat_data_start: u64, +) -> Result, MuxError> { + let mut chunk_offsets = Vec::with_capacity(track.chunk_sample_counts.len()); + let mut sample_index = 0_usize; + for &samples_per_chunk in &track.chunk_sample_counts { + let sample = track + .samples + .get(sample_index) + .ok_or_else(|| MuxError::InvalidChunkPlan { + track_id: track.config.track_id(), + message: "chunk boundaries ran past the staged sample count".to_string(), + })?; + chunk_offsets.push( + mdat_data_start + .checked_add(sample.output_offset) + .ok_or(MuxError::LayoutOverflow("chunk offset"))?, + ); + sample_index = sample_index + .checked_add( + usize::try_from(samples_per_chunk) + .map_err(|_| MuxError::LayoutOverflow("chunk sample-count conversion"))?, + ) + .ok_or(MuxError::LayoutOverflow("chunk sample indexing"))?; + } + Ok(chunk_offsets) +} + +fn build_stco(chunk_offsets: &[u64]) -> Result { + let mut stco = Stco::default(); + stco.entry_count = + u32::try_from(chunk_offsets.len()).map_err(|_| MuxError::LayoutOverflow("chunk_count"))?; + for &offset in chunk_offsets { + let _ = u32::try_from(offset).map_err(|_| MuxError::LayoutOverflow("chunk offset"))?; + } + stco.chunk_offset = chunk_offsets.to_vec(); + Ok(stco) +} + +fn build_co64(chunk_offsets: &[u64]) -> Result { + let mut co64 = Co64::default(); + co64.entry_count = + u32::try_from(chunk_offsets.len()).map_err(|_| MuxError::LayoutOverflow("chunk_count"))?; + co64.chunk_offset = chunk_offsets.to_vec(); + Ok(co64) +} + +fn build_stss(track: &PreparedTrack<'_>) -> Result, MuxError> { + if matches!( + track.config.sync_sample_table_mode, + super::SyncSampleTableMode::ForceEmpty + ) { + return Ok(Some(Stss::default())); + } + + if track.samples.iter().all(|sample| sample.is_sync_sample) + && !matches!( + track.config.sync_sample_table_mode, + super::SyncSampleTableMode::ForceFirstOnly + ) + { + return Ok(None); + } + + let mut stss = Stss::default(); + stss.sample_number = match track.config.sync_sample_table_mode { + super::SyncSampleTableMode::ForceFirstOnly => track + .samples + .iter() + .enumerate() + .find_map(|(index, sample)| { + sample + .is_sync_sample + .then_some(u64::try_from(index + 1).ok()) + .flatten() + }) + .into_iter() + .collect(), + _ => track + .samples + .iter() + .enumerate() + .filter_map(|(index, sample)| { + sample + .is_sync_sample + .then_some(u64::try_from(index + 1).ok()) + .flatten() + }) + .collect(), + }; + stss.entry_count = u32::try_from(stss.sample_number.len()) + .map_err(|_| MuxError::LayoutOverflow("stss entry_count"))?; + Ok(Some(stss)) +} + +fn track_uses_imported_authority_headers(track: &PreparedTrack<'_>) -> bool { + track.config.flat_source_track_creation_time().is_some() + || track.config.flat_source_media_creation_time().is_some() +} + +fn track_uses_direct_iamf_flat_timing(track: &PreparedTrack<'_>) -> bool { + sample_entry_matches(track.sample_entry_box, &[b"iamf"]) + && !track_uses_imported_authority_headers(track) + && track.flat_timing_override.is_some() +} + +pub(super) fn build_visual_random_access_sgpd() -> Sgpd { + let mut sgpd = Sgpd::default(); + sgpd.set_version(1); + sgpd.grouping_type = FourCc::from_bytes(*b"rap "); + sgpd.default_length = 1; + sgpd.entry_count = 1; + sgpd.visual_random_access_entries = vec![VisualRandomAccessEntry { + num_leading_samples_known: false, + num_leading_samples: 0, + }]; + sgpd +} + +pub(super) fn build_visual_random_access_sbgp(entries: Vec) -> Result { + let mut sbgp = Sbgp::default(); + sbgp.grouping_type = u32::from_be_bytes(*b"rap "); + sbgp.entry_count = u32::try_from(entries.len()) + .map_err(|_| MuxError::LayoutOverflow("rap sbgp entry_count"))?; + sbgp.entries = entries; + Ok(sbgp) +} + +fn build_roll_sgpd(sample_roll_distance: i16) -> Sgpd { + let mut sgpd = Sgpd::default(); + sgpd.set_version(1); + sgpd.grouping_type = FourCc::from_bytes(*b"roll"); + sgpd.default_length = 2; + sgpd.entry_count = 1; + sgpd.roll_distances = vec![sample_roll_distance]; + sgpd +} + +fn build_roll_sbgp(sample_count: u32) -> Sbgp { + let mut sbgp = Sbgp::default(); + sbgp.grouping_type = u32::from_be_bytes(*b"roll"); + sbgp.entry_count = 1; + sbgp.entries = vec![SbgpEntry { + sample_count, + group_description_index: 1, + }]; + sbgp +} + +pub(super) fn encode_typed_box(box_value: &B, children: &[u8]) -> Result, MuxError> +where + B: CodecBox, +{ + encode_typed_box_as(box_value.box_type(), box_value, children) +} + +fn encode_typed_box_as( + box_type: FourCc, + box_value: &B, + children: &[u8], +) -> Result, MuxError> +where + B: CodecBox, +{ + let mut payload = Vec::new(); + marshal(&mut payload, box_value, None)?; + payload.extend_from_slice(children); + encode_raw_box(box_type, &payload) +} + +pub(super) fn encode_raw_box(box_type: FourCc, payload: &[u8]) -> Result, MuxError> { + let mut cursor = Cursor::new(Vec::new()); + let payload_size = + u64::try_from(payload.len()).map_err(|_| MuxError::LayoutOverflow("box payload"))?; + let header = BoxInfo::new(box_type, BoxInfo::new(box_type, 8).size() + payload_size); + let written = header.write(&mut cursor)?; + if written.payload_size()? != payload_size { + return Err(MuxError::LayoutOverflow("box header normalization")); + } + cursor.get_mut().extend_from_slice(payload); + Ok(cursor.into_inner()) +} + +fn encode_header_only( + box_type: FourCc, + payload_size: u64, + field_name: &'static str, +) -> Result, MuxError> { + let mut cursor = Cursor::new(Vec::new()); + let header = BoxInfo::new( + box_type, + BoxInfo::new(box_type, 8) + .size() + .checked_add(payload_size) + .ok_or(MuxError::LayoutOverflow(field_name))?, + ); + header.write(&mut cursor)?; + Ok(cursor.into_inner()) +} + +fn validate_sample_entry_box(config: &MuxTrackConfig) -> Result<(), MuxError> { + if config.sample_entry_boxes().is_empty() { + return Err(MuxError::InvalidSampleEntryBox { + track_id: config.track_id(), + message: "expected at least one encoded sample-entry box".to_string(), + }); + } + for sample_entry_box in config.sample_entry_boxes() { + let mut cursor = Cursor::new(sample_entry_box); + let info = BoxInfo::read(&mut cursor).map_err(|error| MuxError::InvalidSampleEntryBox { + track_id: config.track_id(), + message: error.to_string(), + })?; + let end = usize::try_from(info.size()).map_err(|_| MuxError::InvalidSampleEntryBox { + track_id: config.track_id(), + message: "box size is too large".to_string(), + })?; + if info.extend_to_eof() || end != sample_entry_box.len() { + return Err(MuxError::InvalidSampleEntryBox { + track_id: config.track_id(), + message: "expected complete encoded sample-entry boxes".to_string(), + }); + } + } + Ok(()) +} + +fn validate_language(config: &MuxTrackConfig) -> Result<(), MuxError> { + let language = config.language(); + if language.iter().all(|byte| byte.is_ascii_lowercase()) { + return Ok(()); + } + Err(MuxError::InvalidTrackLanguage { + track_id: config.track_id(), + language: String::from_utf8_lossy(&language).into_owned(), + }) +} + +fn encode_iso639_2_language(config: &MuxTrackConfig) -> Result<[u8; 3], MuxError> { + let language = config.language(); + if !language.iter().all(|byte| byte.is_ascii_lowercase()) { + return Err(MuxError::InvalidTrackLanguage { + track_id: config.track_id(), + language: String::from_utf8_lossy(&language).into_owned(), + }); + } + Ok([language[0] - b'`', language[1] - b'`', language[2] - b'`']) +} + +fn scale_movie_time_to_track( + track_id: u32, + value: u64, + movie_timescale: u32, + track_timescale: u32, +) -> Result { + if track_timescale == 0 { + return Err(MuxError::InvalidTrackTimescale { track_id }); + } + if movie_timescale == track_timescale { + return Ok(value); + } + let scaled = value + .checked_mul(u64::from(track_timescale)) + .ok_or(MuxError::LayoutOverflow("track time scaling"))?; + if scaled % u64::from(movie_timescale) != 0 { + return Err(MuxError::InvalidTrackTimescale { track_id }); + } + Ok(scaled / u64::from(movie_timescale)) +} + +fn scale_track_time_to_movie( + track_id: u32, + value: i64, + track_timescale: u32, + movie_timescale: u32, +) -> Result { + if track_timescale == 0 || movie_timescale == 0 { + return Err(MuxError::InvalidTrackTimescale { track_id }); + } + let sign = value.signum(); + let magnitude = value.unsigned_abs(); + let scaled = magnitude + .checked_mul(u64::from(movie_timescale)) + .ok_or(MuxError::LayoutOverflow("movie time scaling"))?; + if scaled % u64::from(track_timescale) != 0 { + return Err(MuxError::InvalidTrackTimescale { track_id }); + } + i64::try_from(scaled / u64::from(track_timescale)) + .map(|normalized| normalized * sign) + .map_err(|_| MuxError::LayoutOverflow("movie time scaling")) +} + +fn scale_movie_offset_to_track( + track_id: u32, + value: i64, + movie_timescale: u32, + track_timescale: u32, +) -> Result { + if value == 0 { + return Ok(0); + } + + let sign = value.signum(); + let magnitude = + u64::try_from(value.abs()).map_err(|_| MuxError::LayoutOverflow("composition offset"))?; + let scaled = + scale_movie_time_to_track(track_id, magnitude, movie_timescale, track_timescale)? as i64; + let signed = scaled + .checked_mul(sign) + .ok_or(MuxError::LayoutOverflow("composition offset"))?; + i32::try_from(signed).map_err(|_| MuxError::LayoutOverflow("composition offset")) +} + +fn run_length_encode_u32(values: I) -> Vec<(u32, u32)> +where + I: IntoIterator, +{ + let mut runs = Vec::new(); + for value in values { + match runs.last_mut() { + Some((sample_count, last_value)) if *last_value == value => { + *sample_count += 1; + } + _ => runs.push((1, value)), + } + } + runs +} + +fn run_length_encode_i32(values: I) -> Vec<(u32, i32)> +where + I: IntoIterator, +{ + let mut runs = Vec::new(); + for value in values { + match runs.last_mut() { + Some((sample_count, last_value)) if *last_value == value => { + *sample_count += 1; + } + _ => runs.push((1, value)), + } + } + runs +} + +fn canonicalize_fragmented_sample_entry_box(sample_entry_box: &[u8]) -> Result, MuxError> { + let sample_entry_type = sample_entry_box_type(sample_entry_box)?; + match sample_entry_type { + value + if value == FourCc::from_bytes(*b"avc1") + || value == FourCc::from_bytes(*b"avc2") + || value == FourCc::from_bytes(*b"avc3") + || value == FourCc::from_bytes(*b"avc4") => + { + canonicalize_fragmented_visual_sample_entry_box(sample_entry_box, "AVC Coding", &[]) + } + value if value == FourCc::from_bytes(*b"hvc1") || value == FourCc::from_bytes(*b"hev1") => { + canonicalize_fragmented_hevc_sample_entry_box( + sample_entry_box, + "HEVC Coding", + &[FourCc::from_bytes(*b"fiel")], + ) + } + value if value == FourCc::from_bytes(*b"dvh1") || value == FourCc::from_bytes(*b"dvhe") => { + canonicalize_fragmented_hevc_sample_entry_box( + sample_entry_box, + "DOVI Coding", + &[FourCc::from_bytes(*b"fiel")], + ) + } + value if value == FourCc::from_bytes(*b"vvc1") || value == FourCc::from_bytes(*b"vvi1") => { + canonicalize_fragmented_visual_sample_entry_box( + sample_entry_box, + "VVC Coding", + &[FourCc::from_bytes(*b"fiel")], + ) + } + value if value == FourCc::from_bytes(*b"av01") => { + if sample_entry_carries_child_type( + sample_entry_box, + &[FourCc::from_bytes(*b"dvcC"), FourCc::from_bytes(*b"dvvC")], + ) { + canonicalize_fragmented_av1_extended_sample_entry_box(sample_entry_box) + } else { + canonicalize_fragmented_visual_sample_entry_box( + sample_entry_box, + "AOM Coding", + &[FourCc::from_bytes(*b"fiel"), FourCc::from_bytes(*b"pasp")], + ) + } + } + value + if value == FourCc::from_bytes(*b"vp08") + || value == FourCc::from_bytes(*b"vp09") + || value == FourCc::from_bytes(*b"vp10") => + { + canonicalize_fragmented_visual_sample_entry_box( + sample_entry_box, + "VPC Coding", + &[ + FourCc::from_bytes(*b"fiel"), + FourCc::from_bytes(*b"pasp"), + FourCc::from_bytes(*b"btrt"), + ], + ) + } + value if value == FourCc::from_bytes(*b"mp4a") => { + canonicalize_fragmented_audio_sample_entry_box(sample_entry_box, true, &[]) + } + value if value == FourCc::from_bytes(*b"alac") => { + canonicalize_fragmented_audio_sample_entry_box( + sample_entry_box, + false, + &[FourCc::from_bytes(*b"btrt")], + ) + } + value if value == FourCc::from_bytes(*b"fLaC") => { + let mut stripped_children = vec![FourCc::from_bytes(*b"btrt")]; + if sample_entry_audio_sample_rate_int(sample_entry_box) == Some(1_000) { + stripped_children.push(FourCc::from_bytes(*b"dfLa")); + } + canonicalize_fragmented_audio_sample_entry_box( + sample_entry_box, + false, + &stripped_children, + ) + } + value + if value == FourCc::from_bytes(*b"ac-3") + || value == FourCc::from_bytes(*b"ec-3") + || value == FourCc::from_bytes(*b"ac-4") + || value == FourCc::from_bytes(*b"Opus") => + { + canonicalize_fragmented_audio_sample_entry_box( + sample_entry_box, + false, + &[FourCc::from_bytes(*b"btrt")], + ) + } + value + if value == FourCc::from_bytes(*b"dtsc") + || value == FourCc::from_bytes(*b"dtse") + || value == FourCc::from_bytes(*b"dtsh") + || value == FourCc::from_bytes(*b"dtsl") + || value == FourCc::from_bytes(*b"dtsm") + || value == FourCc::from_bytes(*b"dts-") + || value == FourCc::from_bytes(*b"dtsx") + || value == FourCc::from_bytes(*b"dtsy") => + { + canonicalize_fragmented_audio_sample_entry_box( + sample_entry_box, + false, + &[FourCc::from_bytes(*b"btrt")], + ) + } + value + if value == FourCc::from_bytes(*b"mha1") + || value == FourCc::from_bytes(*b"mha2") + || value == FourCc::from_bytes(*b"mhm1") + || value == FourCc::from_bytes(*b"mhm2") => + { + canonicalize_fragmented_audio_sample_entry_box( + sample_entry_box, + false, + &[FourCc::from_bytes(*b"btrt")], + ) + } + _ => Ok(sample_entry_box.to_vec()), + } +} + +fn canonicalize_fragmented_av1_extended_sample_entry_box( + sample_entry_box: &[u8], +) -> Result, MuxError> { + let (mut sample_entry, child_boxes, trailing_bytes) = + decode_visual_sample_entry_parts(sample_entry_box)?; + sample_entry.compressorname = encode_compressor_name("AOM Coding"); + + let stripped_children = [ + FourCc::from_bytes(*b"fiel"), + FourCc::from_bytes(*b"pasp"), + FourCc::from_bytes(*b"btrt"), + FourCc::from_bytes(*b"clli"), + FourCc::from_bytes(*b"mdcv"), + ]; + let mut normalized_children = Vec::with_capacity(child_boxes.len()); + for child_box in child_boxes { + if stripped_children.contains(&sample_entry_box_type(&child_box)?) { + continue; + } + normalized_children.push(child_box); + } + let normalized_children = reorder_sample_entry_children_by_type_preference( + &normalized_children, + &[ + FourCc::from_bytes(*b"av1C"), + FourCc::from_bytes(*b"dvcC"), + FourCc::from_bytes(*b"dvvC"), + FourCc::from_bytes(*b"colr"), + ], + ); + + let mut child_payload = normalized_children.concat(); + child_payload.extend_from_slice(&trailing_bytes); + encode_typed_box(&sample_entry, &child_payload) +} + +fn reorder_sample_entry_children_by_type_preference( + child_boxes: &[Vec], + preferred_order: &[FourCc], +) -> Vec> { + let mut ordered = Vec::with_capacity(child_boxes.len()); + let mut used = vec![false; child_boxes.len()]; + for preferred_type in preferred_order { + for (index, child_box) in child_boxes.iter().enumerate() { + if used[index] || sample_entry_box_type(child_box).ok() != Some(*preferred_type) { + continue; + } + ordered.push(child_box.clone()); + used[index] = true; + } + } + for (index, child_box) in child_boxes.iter().enumerate() { + if !used[index] { + ordered.push(child_box.clone()); + } + } + ordered +} + +fn canonicalize_fragmented_visual_sample_entry_box( + sample_entry_box: &[u8], + compressor_name: &str, + stripped_children: &[FourCc], +) -> Result, MuxError> { + let (mut sample_entry, child_boxes, trailing_bytes) = + decode_visual_sample_entry_parts(sample_entry_box)?; + sample_entry.compressorname = encode_compressor_name(compressor_name); + + let mut normalized_children = Vec::with_capacity(child_boxes.len()); + for child_box in child_boxes { + if stripped_children.contains(&sample_entry_box_type(&child_box)?) { + continue; + } + normalized_children.push(child_box); + } + + let mut child_payload = normalized_children.concat(); + child_payload.extend_from_slice(&trailing_bytes); + encode_typed_box(&sample_entry, &child_payload) +} + +fn canonicalize_fragmented_hevc_sample_entry_box( + sample_entry_box: &[u8], + compressor_name: &str, + stripped_children: &[FourCc], +) -> Result, MuxError> { + let (mut sample_entry, child_boxes, trailing_bytes) = + decode_visual_sample_entry_parts(sample_entry_box)?; + sample_entry.compressorname = encode_compressor_name(compressor_name); + + let mut normalized_children = Vec::with_capacity(child_boxes.len()); + for child_box in child_boxes { + let child_type = sample_entry_box_type(&child_box)?; + if stripped_children.contains(&child_type) { + continue; + } + if child_type == FourCc::from_bytes(*b"pasp") && is_square_pasp_box(&child_box)? { + continue; + } + normalized_children.push(child_box); + } + + let mut child_payload = normalized_children.concat(); + child_payload.extend_from_slice(&trailing_bytes); + encode_typed_box(&sample_entry, &child_payload) +} + +fn canonicalize_fragmented_audio_sample_entry_box( + sample_entry_box: &[u8], + normalize_esds: bool, + stripped_children: &[FourCc], +) -> Result, MuxError> { + let (sample_entry, child_boxes, trailing_bytes) = + decode_audio_sample_entry_parts(sample_entry_box)?; + let sample_entry_type = sample_entry.sample_entry.box_type; + let normalized_sample_rate = if sample_entry_type == FourCc::from_bytes(*b"mp4a") { + fragmented_mp4a_sample_entry_sample_rate(sample_entry_box)? + } else { + sample_entry.sample_rate + }; + let normalized_channel_count = if sample_entry_type == FourCc::from_bytes(*b"ec-3") { + 2 + } else { + sample_entry.channel_count + }; + let normalized_sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: sample_entry_type, + data_reference_index: 1, + }, + entry_version: sample_entry.entry_version, + channel_count: normalized_channel_count, + sample_size: sample_entry.sample_size, + pre_defined: sample_entry.pre_defined, + sample_rate: normalized_sample_rate, + quicktime_data: sample_entry.quicktime_data.clone(), + }; + let mut normalized_children = Vec::with_capacity(child_boxes.len()); + for child_box in child_boxes { + let child_type = sample_entry_box_type(&child_box)?; + if stripped_children.contains(&child_type) { + continue; + } + if normalize_esds && child_type == FourCc::from_bytes(*b"esds") { + normalized_children.push(canonicalize_fragmented_esds_box(&child_box)?); + } else { + normalized_children.push(child_box); + } + } + + let mut child_payload = normalized_children.concat(); + child_payload.extend_from_slice(&trailing_bytes); + encode_typed_box(&normalized_sample_entry, &child_payload) +} + +fn fragmented_mp4a_sample_entry_sample_rate(sample_entry_box: &[u8]) -> Result { + let (sample_entry, child_boxes, _) = decode_audio_sample_entry_parts(sample_entry_box)?; + for child_box in child_boxes { + if sample_entry_box_type(&child_box)? != FourCc::from_bytes(*b"esds") { + continue; + } + let esds = decode_typed_box::(&child_box)?; + if let Ok(Some(sample_rate)) = detect_aac_effective_sample_rate(&esds) { + return Ok(sample_rate << 16); + } + } + Ok(sample_entry.sample_rate) +} + +pub(crate) fn append_audio_sample_entry_btrt( + sample_entry_box: &[u8], + btrt: &Btrt, +) -> Result, MuxError> { + let (sample_entry, child_boxes, trailing_bytes) = + decode_audio_sample_entry_parts(sample_entry_box)?; + if child_boxes.iter().any(|child_box| { + sample_entry_box_type(child_box).ok() == Some(FourCc::from_bytes(*b"btrt")) + }) { + return Ok(sample_entry_box.to_vec()); + } + + let mut child_payload = child_boxes.concat(); + child_payload.extend_from_slice(&encode_typed_box(btrt, &[])?); + child_payload.extend_from_slice(&trailing_bytes); + encode_typed_box(&sample_entry, &child_payload) +} + +pub(crate) fn replace_audio_sample_entry_btrt( + sample_entry_box: &[u8], + btrt: &Btrt, +) -> Result, MuxError> { + let stripped = strip_audio_sample_entry_immediate_children( + sample_entry_box, + &[FourCc::from_bytes(*b"btrt")], + )?; + append_audio_sample_entry_btrt(&stripped, btrt) +} + +pub(crate) fn append_audio_sample_entry_child_box( + sample_entry_box: &[u8], + child_box: &[u8], +) -> Result, MuxError> { + let (sample_entry, child_boxes, trailing_bytes) = + decode_audio_sample_entry_parts(sample_entry_box)?; + let mut child_payload = child_boxes.concat(); + child_payload.extend_from_slice(child_box); + child_payload.extend_from_slice(&trailing_bytes); + encode_typed_box(&sample_entry, &child_payload) +} + +pub(crate) fn audio_sample_entry_vendor_code( + sample_entry_box: &[u8], +) -> Result, MuxError> { + let sample_entry = decode_audio_sample_entry(sample_entry_box)?; + if sample_entry_box.len() < 24 { + return Err(MuxError::UnsupportedTrackImport { + spec: "".to_string(), + message: "audio sample entry is truncated before the vendor field".to_string(), + }); + } + if sample_entry.entry_version != 0 { + return Ok(None); + } + let sample_entry_type = sample_entry.sample_entry.box_type; + if sample_entry_type != FourCc::from_bytes(*b"ipcm") + && sample_entry_type != FourCc::from_bytes(*b"fpcm") + && sample_entry_type != FourCc::from_bytes(*b"spex") + { + return Ok(None); + } + Ok(Some(sample_entry_box[20..24].try_into().unwrap())) +} + +pub(crate) fn replace_audio_sample_entry_vendor_code( + sample_entry_box: &[u8], + vendor_code: [u8; 4], +) -> Result, MuxError> { + let Some(_) = audio_sample_entry_vendor_code(sample_entry_box)? else { + return Ok(sample_entry_box.to_vec()); + }; + let mut replaced = sample_entry_box.to_vec(); + replaced[20..24].copy_from_slice(&vendor_code); + Ok(replaced) +} + +pub(crate) fn append_visual_sample_entry_btrt( + sample_entry_box: &[u8], + btrt: &Btrt, +) -> Result, MuxError> { + let (sample_entry, child_boxes, trailing_bytes) = + decode_visual_sample_entry_parts(sample_entry_box)?; + if child_boxes.iter().any(|child_box| { + sample_entry_box_type(child_box).ok() == Some(FourCc::from_bytes(*b"btrt")) + }) { + return Ok(sample_entry_box.to_vec()); + } + + let mut child_payload = child_boxes.concat(); + child_payload.extend_from_slice(&encode_typed_box(btrt, &[])?); + child_payload.extend_from_slice(&trailing_bytes); + encode_typed_box(&sample_entry, &child_payload) +} + +pub(crate) fn replace_visual_sample_entry_btrt( + sample_entry_box: &[u8], + btrt: &Btrt, +) -> Result, MuxError> { + let stripped = strip_visual_sample_entry_immediate_children( + sample_entry_box, + &[FourCc::from_bytes(*b"btrt")], + )?; + append_visual_sample_entry_btrt(&stripped, btrt) +} + +pub(crate) fn replace_visual_sample_entry_compressorname( + sample_entry_box: &[u8], + compressorname: [u8; 32], +) -> Result, MuxError> { + let (mut sample_entry, child_boxes, trailing_bytes) = + decode_visual_sample_entry_parts(sample_entry_box)?; + sample_entry.compressorname = compressorname; + let mut child_payload = child_boxes.concat(); + child_payload.extend_from_slice(&trailing_bytes); + encode_typed_box(&sample_entry, &child_payload) +} + +pub(crate) fn audio_sample_entry_immediate_children( + sample_entry_box: &[u8], +) -> Result>, MuxError> { + let (_, child_boxes, _) = decode_audio_sample_entry_parts(sample_entry_box)?; + Ok(child_boxes) +} + +pub(crate) fn visual_sample_entry_immediate_children( + sample_entry_box: &[u8], +) -> Result>, MuxError> { + let (_, child_boxes, _) = decode_visual_sample_entry_parts(sample_entry_box)?; + Ok(child_boxes) +} + +pub(crate) fn fragmented_visual_tkhd_dimensions_fixed_16_16( + sample_entry_box: &[u8], +) -> Result, MuxError> { + let (sample_entry, child_boxes, _) = decode_visual_sample_entry_parts(sample_entry_box)?; + let Some(pasp_box) = child_boxes.iter().find(|child_box| { + sample_entry_box_type(child_box).ok() == Some(FourCc::from_bytes(*b"pasp")) + }) else { + return Ok(None); + }; + let pasp = decode_typed_box::(pasp_box)?; + if pasp.h_spacing == 0 || pasp.v_spacing == 0 || (pasp.h_spacing == 1 && pasp.v_spacing == 1) { + return Ok(None); + } + let numerator = + u128::from(sample_entry.width) * u128::from(pasp.h_spacing) * u128::from(1_u32 << 16); + let divisor = u128::from(pasp.v_spacing); + let width_fixed_16_16 = numerator + .saturating_add(divisor / 2) + .checked_div(divisor) + .unwrap_or(0); + Ok(Some(( + u32::try_from(width_fixed_16_16) + .map_err(|_| MuxError::LayoutOverflow("fragmented visual tkhd width"))?, + u32::from(sample_entry.height) << 16, + ))) +} + +fn is_square_pasp_box(child_box: &[u8]) -> Result { + let pasp = decode_typed_box::(child_box)?; + Ok(pasp.h_spacing == 1 && pasp.v_spacing == 1) +} + +pub(crate) fn replace_visual_sample_entry_immediate_children( + sample_entry_box: &[u8], + replacement_children: &[Vec], +) -> Result, MuxError> { + let (sample_entry, _, trailing_bytes) = decode_visual_sample_entry_parts(sample_entry_box)?; + let mut child_payload = replacement_children.concat(); + child_payload.extend_from_slice(&trailing_bytes); + encode_typed_box(&sample_entry, &child_payload) +} + +pub(crate) fn replace_audio_sample_entry_immediate_children( + sample_entry_box: &[u8], + replacement_children: &[Vec], +) -> Result, MuxError> { + let (sample_entry, _, trailing_bytes) = decode_audio_sample_entry_parts(sample_entry_box)?; + let mut child_payload = replacement_children.concat(); + child_payload.extend_from_slice(&trailing_bytes); + encode_typed_box(&sample_entry, &child_payload) +} + +pub(crate) fn replace_audio_sample_entry_immediate_children_without_trailing_bytes( + sample_entry_box: &[u8], + replacement_children: &[Vec], +) -> Result, MuxError> { + let (sample_entry, _, _) = decode_audio_sample_entry_parts(sample_entry_box)?; + let child_payload = replacement_children.concat(); + encode_typed_box(&sample_entry, &child_payload) +} + +pub(crate) fn strip_audio_sample_entry_immediate_children( + sample_entry_box: &[u8], + stripped_children: &[FourCc], +) -> Result, MuxError> { + canonicalize_fragmented_audio_sample_entry_box(sample_entry_box, false, stripped_children) +} + +pub(crate) fn strip_visual_sample_entry_immediate_children( + sample_entry_box: &[u8], + stripped_children: &[FourCc], +) -> Result, MuxError> { + let (sample_entry, child_boxes, trailing_bytes) = + decode_visual_sample_entry_parts(sample_entry_box)?; + let mut normalized_children = Vec::with_capacity(child_boxes.len()); + for child_box in child_boxes { + if stripped_children.contains(&sample_entry_box_type(&child_box)?) { + continue; + } + normalized_children.push(child_box); + } + + let mut child_payload = normalized_children.concat(); + child_payload.extend_from_slice(&trailing_bytes); + encode_typed_box(&sample_entry, &child_payload) +} + +fn canonicalize_fragmented_esds_box(esds_box: &[u8]) -> Result, MuxError> { + let mut esds = decode_typed_box::(esds_box)?; + for descriptor in &mut esds.descriptors { + if descriptor.tag == ES_DESCRIPTOR_TAG + && let Some(es_descriptor) = descriptor.es_descriptor.as_mut() + { + es_descriptor.es_id = 0; + } + } + esds.normalize_descriptor_sizes_for_mux() + .map_err(|_| MuxError::LayoutOverflow("fragmented esds normalization"))?; + encode_typed_box(&esds, &[]) +} + +pub(super) fn decode_visual_sample_entry_parts( + sample_entry_box: &[u8], +) -> Result, MuxError> { + let mut cursor = Cursor::new(sample_entry_box); + let info = BoxInfo::read(&mut cursor) + .map_err(|_| MuxError::LayoutOverflow("visual sample-entry header"))?; + let mut sample_entry = VisualSampleEntry::default(); + sample_entry.sample_entry.box_type = info.box_type(); + unmarshal( + &mut cursor, + info.payload_size() + .map_err(|_| MuxError::LayoutOverflow("visual sample-entry payload"))?, + &mut sample_entry, + None, + ) + .map_err(|_| MuxError::LayoutOverflow("visual sample-entry decode"))?; + split_box_children_and_trailing(sample_entry_box, cursor.position()) + .map(|(children, trailing)| (sample_entry, children, trailing)) +} + +pub(super) fn decode_audio_sample_entry_parts( + sample_entry_box: &[u8], +) -> Result, MuxError> { + let mut cursor = Cursor::new(sample_entry_box); + let info = BoxInfo::read(&mut cursor) + .map_err(|_| MuxError::LayoutOverflow("audio sample-entry header"))?; + let mut sample_entry = AudioSampleEntry::default(); + sample_entry.sample_entry.box_type = info.box_type(); + unmarshal( + &mut cursor, + info.payload_size() + .map_err(|_| MuxError::LayoutOverflow("audio sample-entry payload"))?, + &mut sample_entry, + None, + ) + .map_err(|_| MuxError::LayoutOverflow("audio sample-entry decode"))?; + split_box_children_and_trailing(sample_entry_box, cursor.position()) + .map(|(children, trailing)| (sample_entry, children, trailing)) +} + +pub(crate) fn decode_audio_sample_entry( + sample_entry_box: &[u8], +) -> Result { + let (sample_entry, _, _) = decode_audio_sample_entry_parts(sample_entry_box)?; + Ok(sample_entry) +} + +fn sample_entry_audio_sample_rate_int(sample_entry_box: &[u8]) -> Option { + let (sample_entry, _, _) = decode_audio_sample_entry_parts(sample_entry_box).ok()?; + Some(u32::from(sample_entry.sample_rate_int())) +} + +pub(crate) fn decode_typed_box(encoded_box: &[u8]) -> Result +where + B: CodecBox + Default, +{ + let mut cursor = Cursor::new(encoded_box); + let info = + BoxInfo::read(&mut cursor).map_err(|_| MuxError::LayoutOverflow("typed box header"))?; + let mut decoded = B::default(); + unmarshal( + &mut cursor, + info.payload_size() + .map_err(|_| MuxError::LayoutOverflow("typed box payload"))?, + &mut decoded, + None, + ) + .map_err(|_| MuxError::LayoutOverflow("typed box decode"))?; + Ok(decoded) +} + +fn split_box_children_and_trailing( + sample_entry_box: &[u8], + child_start: u64, +) -> Result<(SampleEntryChildBoxes, SampleEntryTrailingBytes), MuxError> { + let child_start = usize::try_from(child_start) + .map_err(|_| MuxError::LayoutOverflow("sample-entry child offset"))?; + let remaining = sample_entry_box + .get(child_start..) + .ok_or(MuxError::LayoutOverflow("sample-entry child offset"))?; + let child_bytes_len = split_box_children_with_optional_trailing_bytes(remaining); + let child_boxes = split_immediate_box_bytes(&remaining[..child_bytes_len])?; + Ok((child_boxes, remaining[child_bytes_len..].to_vec())) +} + +fn split_immediate_box_bytes(bytes: &[u8]) -> Result>, MuxError> { + let mut cursor = Cursor::new(bytes); + let mut child_boxes = Vec::new(); + while cursor.position() < bytes.len() as u64 { + let start = cursor.position(); + let info = + BoxInfo::read(&mut cursor).map_err(|_| MuxError::LayoutOverflow("child box header"))?; + let end = usize::try_from( + start + .checked_add(info.size()) + .ok_or(MuxError::LayoutOverflow("child box size"))?, + ) + .map_err(|_| MuxError::LayoutOverflow("child box size"))?; + child_boxes.push(bytes[start as usize..end].to_vec()); + cursor.set_position(end as u64); + } + Ok(child_boxes) +} + +fn encode_compressor_name(name: &str) -> [u8; 32] { + let mut encoded = [0_u8; 32]; + let visible = name.as_bytes(); + let visible_len = visible.len().min(31); + encoded[0] = u8::try_from(visible_len).unwrap_or(31); + encoded[1..1 + visible_len].copy_from_slice(&visible[..visible_len]); + encoded +} + +fn sample_entry_box_type(sample_entry_box: &[u8]) -> Result { + let mut cursor = Cursor::new(sample_entry_box); + let info = BoxInfo::read(&mut cursor) + .map_err(|_| MuxError::LayoutOverflow("sample-entry box header"))?; + Ok(info.box_type()) +} + +fn encoded_box_type(box_bytes: &[u8]) -> Result { + let mut cursor = Cursor::new(box_bytes); + let info = BoxInfo::read(&mut cursor).map_err(|_| MuxError::LayoutOverflow("box header"))?; + Ok(info.box_type()) +} + +pub(crate) fn replace_opaque_text_sample_entry_btrt( + sample_entry_box: &[u8], + btrt: &Btrt, +) -> Result, MuxError> { + let box_type = sample_entry_box_type(sample_entry_box)?; + if box_type != FourCc::from_bytes(*b"text") && box_type != FourCc::from_bytes(*b"tx3g") { + return Ok(sample_entry_box.to_vec()); + } + if sample_entry_box.len() < 16 { + return Ok(sample_entry_box.to_vec()); + } + let payload = &sample_entry_box[8..]; + let Some(inline_child_start) = find_opaque_text_sample_entry_inline_child_start(payload) else { + let mut payload = payload.to_vec(); + payload.extend_from_slice(&encode_typed_box(btrt, &[])?); + return encode_raw_box(box_type, &payload); + }; + + let payload_prefix = &payload[..inline_child_start]; + let inline_suffix = &payload[inline_child_start..]; + let child_payload_len = split_box_children_with_optional_trailing_bytes(inline_suffix); + let mut cursor = Cursor::new(&inline_suffix[..child_payload_len]); + let mut normalized_inline_boxes = Vec::new(); + + while usize::try_from(cursor.position()).unwrap_or(usize::MAX) < child_payload_len { + let start = usize::try_from(cursor.position()) + .map_err(|_| MuxError::LayoutOverflow("opaque text child start"))?; + let info = BoxInfo::read(&mut cursor) + .map_err(|_| MuxError::LayoutOverflow("opaque text child header"))?; + let end = start + .checked_add( + usize::try_from(info.size()) + .map_err(|_| MuxError::LayoutOverflow("opaque text child size"))?, + ) + .ok_or(MuxError::LayoutOverflow("opaque text child end"))?; + if end > child_payload_len { + return Err(MuxError::LayoutOverflow("opaque text child bounds")); + } + cursor.set_position( + u64::try_from(end).map_err(|_| MuxError::LayoutOverflow("opaque text child seek"))?, + ); + if info.box_type() == FourCc::from_bytes(*b"btrt") { + continue; + } + normalized_inline_boxes.extend_from_slice(&inline_suffix[start..end]); + } + + let mut payload = payload_prefix.to_vec(); + payload.extend_from_slice(&normalized_inline_boxes); + payload.extend_from_slice(&encode_typed_box(btrt, &[])?); + payload.extend_from_slice(&inline_suffix[child_payload_len..]); + encode_raw_box(box_type, &payload) +} + +fn find_opaque_text_sample_entry_inline_child_start(payload: &[u8]) -> Option { + if payload.len() <= 8 { + return None; + } + + let opaque_payload = &payload[8..]; + for child_offset in 0..=opaque_payload.len().saturating_sub(8) { + let suffix = &opaque_payload[child_offset..]; + let child_payload_len = split_box_children_with_optional_trailing_bytes(suffix); + if child_payload_len == 0 { + continue; + } + let Ok(first_child_type) = encoded_box_type(&suffix[..child_payload_len]) else { + continue; + }; + if first_child_type == FourCc::from_bytes(*b"ftab") + || first_child_type == FourCc::from_bytes(*b"btrt") + { + return Some(8 + child_offset); + } + } + + None +} + +fn copy_fragment_payloads( + sources: &mut [R], + writer: &mut W, + fragment: &FragmentLayout, +) -> Result<(), MuxError> +where + R: Read + Seek, + W: Write, +{ + let mut buffer = [0_u8; 16 * 1024]; + for sample in &fragment.samples { + let source = sources + .get_mut(sample.source_index) + .ok_or(MuxError::LayoutOverflow("fragment source index"))?; + source.seek(std::io::SeekFrom::Start(sample.source_data_offset))?; + let mut remaining = sample.sample_size; + while remaining > 0 { + let chunk_len = usize::try_from(remaining.min(buffer.len() as u64)) + .map_err(|_| MuxError::LayoutOverflow("fragment copy chunk"))?; + source.read_exact(&mut buffer[..chunk_len])?; + writer.write_all(&buffer[..chunk_len])?; + remaining -= u64::try_from(chunk_len) + .map_err(|_| MuxError::LayoutOverflow("fragment copy chunk"))?; + } + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn copy_fragment_payloads_async( + sources: &mut [R], + writer: &mut W, + fragment: &FragmentLayout, +) -> Result<(), MuxError> +where + R: AsyncReadSeek, + W: AsyncWrite + Unpin, +{ + let mut buffer = vec![0_u8; 16 * 1024]; + for sample in &fragment.samples { + let source = sources + .get_mut(sample.source_index) + .ok_or(MuxError::LayoutOverflow("fragment source index"))?; + source + .seek(std::io::SeekFrom::Start(sample.source_data_offset)) + .await?; + let mut remaining = sample.sample_size; + while remaining > 0 { + let chunk_len = usize::try_from(remaining.min(buffer.len() as u64)) + .map_err(|_| MuxError::LayoutOverflow("fragment copy chunk"))?; + source.read_exact(&mut buffer[..chunk_len]).await?; + writer.write_all(&buffer[..chunk_len]).await?; + remaining -= u64::try_from(chunk_len) + .map_err(|_| MuxError::LayoutOverflow("fragment copy chunk"))?; + } + } + Ok(()) +} + +fn sample_flags( + sample: &PreparedSample, + sample_index: usize, + first_sync_sample_index: Option, +) -> u32 { + let is_sync_sample = first_sync_sample_index + .map_or(sample.is_sync_sample, |first_sync_index| { + sample.is_sync_sample && sample_index == first_sync_index + }); + if is_sync_sample { + 0 + } else { + NON_KEY_SAMPLE_FLAGS + } +} + +fn fragmented_track_emits_roll_description(track: &PreparedTrack<'_>) -> bool { + let Some(sample_roll_distance) = track.config.sample_roll_distance() else { + return false; + }; + if !sample_entry_matches(track.sample_entry_box, &[b"Opus"]) { + return true; + } + sample_roll_distance < 0 +} + +fn fragmented_track_emits_roll_assignment(track: &PreparedTrack<'_>) -> bool { + if !track.config.emit_roll_sbgp() { + return false; + } + if !sample_entry_matches(track.sample_entry_box, &[b"Opus"]) { + return true; + } + track + .config + .sample_roll_distance() + .is_some_and(|sample_roll_distance| sample_roll_distance < 0) +} + +fn fragmented_track_uses_trimmed_non_square_avc_pasp( + track: &PreparedTrack<'_>, +) -> Result { + if track.config.kind() != MuxTrackKind::Video + || track.config.edit_media_time().is_none() + || !sample_entry_matches(track.sample_entry_box, &[b"avc1"]) + { + return Ok(false); + } + let child_boxes = visual_sample_entry_immediate_children(track.sample_entry_box)?; + for child_box in child_boxes { + if sample_entry_box_type(&child_box)? != FourCc::from_bytes(*b"pasp") { + continue; + } + let pasp = decode_typed_box::(&child_box)?; + return Ok(pasp.h_spacing != 0 && pasp.h_spacing != pasp.v_spacing); + } + Ok(false) +} + +fn sample_entry_carries_child_type(sample_entry_box: &[u8], child_types: &[FourCc]) -> bool { + visual_sample_entry_immediate_children(sample_entry_box).is_ok_and(|child_boxes| { + child_boxes.iter().any(|child_box| { + sample_entry_box_type(child_box) + .ok() + .is_some_and(|child_type| child_types.contains(&child_type)) + }) + }) +} + +fn all_equal_u32(mut values: I) -> Option +where + I: Iterator, +{ + let first = values.next()?; + values.all(|value| value == first).then_some(first) +} + +fn all_equal_u64(mut values: I) -> Option +where + I: Iterator, +{ + let first = values.next()?; + values.all(|value| value == first).then_some(first) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::boxes::iso14496_12::AVCDecoderConfiguration; + use crate::mux::StscRunEncodingMode; + use crate::mux::{ + FlatTimingOverride, MuxInterleavePolicy, MuxStagedMediaItem, + plan_staged_media_items_with_chunk_sample_counts, + }; + + fn test_prepared_sample( + decode_time_movie: u64, + duration_movie: u32, + is_sync_sample: bool, + ) -> PreparedSample { + test_prepared_sample_with_size(decode_time_movie, duration_movie, 0, is_sync_sample) + } + + fn test_prepared_sample_with_size( + decode_time_movie: u64, + duration_movie: u32, + sample_size: u64, + is_sync_sample: bool, + ) -> PreparedSample { + PreparedSample { + source_index: 0, + source_data_offset: 0, + decode_time_movie, + decode_time_media: 0, + output_offset: 0, + sample_size, + duration_movie, + duration_media: 0, + composition_offset_movie: 0, + composition_offset_media: 0, + is_sync_sample, + sample_description_index: 1, + } + } + + #[test] + fn build_fragmented_tkhd_uses_default_reference_flags() { + let config = MuxTrackConfig::new_audio(1, 48_000, Vec::new()).with_tkhd_flags(0x000f); + let track = PreparedTrack { + config: &config, + sample_entry_box: &[], + samples: Vec::new(), + chunk_sample_counts: Vec::new(), + fragmented_reference_group_fragment_counts: None, + media_duration: 0, + presentation_duration_media: 0, + edit_media_time: None, + flat_timing_override: None, + }; + + let tkhd = build_fragmented_tkhd(&track, 123).expect("fragmented tkhd"); + + assert_eq!(tkhd.flags(), DEFAULT_FRAGMENTED_TKHD_FLAGS); + } + + #[test] + fn build_fragmented_tkhd_resets_audio_volume_and_matrix() { + let config = MuxTrackConfig::new_audio(1, 48_000, Vec::new()) + .with_volume(0) + .with_matrix([0; 9]) + .with_alternate_group(7); + let track = PreparedTrack { + config: &config, + sample_entry_box: &[], + samples: Vec::new(), + chunk_sample_counts: Vec::new(), + fragmented_reference_group_fragment_counts: None, + media_duration: 0, + presentation_duration_media: 0, + edit_media_time: None, + flat_timing_override: None, + }; + + let tkhd = build_fragmented_tkhd(&track, 123).expect("fragmented tkhd"); + + assert_eq!(tkhd.alternate_group, 0); + assert_eq!(tkhd.volume, 0x0100); + assert_eq!(tkhd.matrix, IDENTITY_MATRIX); + } + + #[test] + fn build_sidx_reference_tracks_delayed_first_sap_after_trim() { + let mut samples = Vec::new(); + for sample_index in 0..26_u64 { + samples.push(test_prepared_sample( + sample_index * 1024, + 1024, + sample_index == 25, + )); + } + let fragment = FragmentLayout { + segment_type_bytes: Vec::new(), + metadata_bytes: Vec::new(), + moof_bytes: Vec::new(), + mdat_header: Vec::new(), + samples: samples.clone(), + sidx_samples: samples, + }; + + let built = + build_sidx_reference(std::iter::once(&fragment), 3_072, true).expect("sidx reference"); + + assert!(!built.reference.starts_with_sap); + assert_eq!(built.reference.sap_type, 1); + assert_eq!(built.reference.sap_delta_time, 22_528); + assert_eq!(built.earliest_presentation_time, 0); + assert_eq!(built.reference.subsegment_duration, 23_552); + } + + fn immediate_child_types(encoded_box: &[u8]) -> Vec { + let mut cursor = Cursor::new(encoded_box); + let parent = BoxInfo::read(&mut cursor).expect("parent header"); + let mut child_types = Vec::new(); + std::io::Seek::seek(&mut cursor, std::io::SeekFrom::Start(parent.header_size())).unwrap(); + while cursor.position() < parent.size() { + let child = BoxInfo::read(&mut cursor).expect("child header"); + child_types.push(child.box_type()); + child.seek_to_end(&mut cursor).expect("child seek"); + } + child_types + } + + #[test] + fn build_trak_bytes_places_preserved_tref_before_mdia() { + let sample_entry_box = + encode_raw_box(FourCc::from_bytes(*b"mp4a"), &[]).expect("sample entry"); + let tref_child = + encode_raw_box(FourCc::from_bytes(*b"sync"), &1_u32.to_be_bytes()).expect("tref child"); + let tref = encode_raw_box(FourCc::from_bytes(*b"tref"), &tref_child).expect("tref"); + let udta = encode_raw_box(FourCc::from_bytes(*b"udta"), b"user").expect("udta"); + let config = MuxTrackConfig::new_audio(1, 48_000, sample_entry_box.clone()) + .with_preserved_flat_trak_boxes(vec![tref, udta]); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample_with_size(0, 1_024, 4, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_024, + presentation_duration_media: 1_024, + edit_media_time: None, + flat_timing_override: None, + }; + + let trak = + build_trak_bytes(&MuxFileConfig::new(48_000), &track, 24, 8, 256, None).expect("trak"); + + assert_eq!( + immediate_child_types(&trak), + vec![ + FourCc::from_bytes(*b"tkhd"), + FourCc::from_bytes(*b"tref"), + FourCc::from_bytes(*b"mdia"), + FourCc::from_bytes(*b"udta"), + ] + ); + } + + fn test_visual_sample_entry_box(box_type: FourCc) -> Vec { + encode_typed_box( + &VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type, + data_reference_index: 1, + }, + width: 640, + height: 360, + ..VisualSampleEntry::default() + }, + &[], + ) + .expect("visual sample entry") + } + + fn test_basic_sample_entry_box(box_type: FourCc) -> Vec { + encode_raw_box(box_type, &[0, 0, 0, 0, 0, 0, 0, 1]).expect("sample entry") + } + + fn test_prepared_track<'a>( + config: &'a MuxTrackConfig, + sample_entry_box: &'a [u8], + duration: u32, + ) -> PreparedTrack<'a> { + PreparedTrack { + config, + sample_entry_box, + samples: vec![test_prepared_sample(0, duration, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: u64::from(duration), + presentation_duration_media: u64::from(duration), + edit_media_time: None, + flat_timing_override: None, + } + } + + #[test] + fn infer_auto_flat_ftyp_profile_keeps_avc_brand_with_hevc_video() { + let avc_entry = test_visual_sample_entry_box(FourCc::from_bytes(*b"avc1")); + let avc_config = MuxTrackConfig::new_video(1, 1_000, 640, 360, avc_entry.clone()); + let avc_track = test_prepared_track(&avc_config, &avc_entry, 1_000); + let hevc_entry = test_visual_sample_entry_box(FourCc::from_bytes(*b"hvc1")); + let hevc_config = MuxTrackConfig::new_video(2, 1_000, 640, 360, hevc_entry.clone()); + let hevc_track = test_prepared_track(&hevc_config, &hevc_entry, 1_000); + + let (major_brand, minor_version, compatible_brands) = + infer_auto_flat_ftyp_profile(&[avc_track, hevc_track]); + + assert_eq!(major_brand, FourCc::from_bytes(*b"iso4")); + assert_eq!(minor_version, 1); + assert_eq!( + compatible_brands, + vec![FourCc::from_bytes(*b"avc1"), FourCc::from_bytes(*b"iso4")] + ); + } + + #[test] + fn infer_auto_flat_ftyp_profile_keeps_avc_brand_with_avs3_video() { + let avc_entry = test_visual_sample_entry_box(FourCc::from_bytes(*b"avc1")); + let avc_config = MuxTrackConfig::new_video(1, 1_000, 640, 360, avc_entry.clone()); + let avc_track = test_prepared_track(&avc_config, &avc_entry, 1_000); + let avs3_entry = test_visual_sample_entry_box(FourCc::from_bytes(*b"avs3")); + let avs3_config = MuxTrackConfig::new_video(2, 1_000, 640, 360, avs3_entry.clone()); + let avs3_track = test_prepared_track(&avs3_config, &avs3_entry, 1_000); + + let (major_brand, minor_version, compatible_brands) = + infer_auto_flat_ftyp_profile(&[avc_track, avs3_track]); + + assert_eq!(major_brand, FourCc::from_bytes(*b"iso4")); + assert_eq!(minor_version, 1); + assert_eq!( + compatible_brands, + vec![ + FourCc::from_bytes(*b"avc1"), + FourCc::from_bytes(*b"iso4"), + FourCc::from_bytes(*b"cav3"), + ] + ); + } + + #[test] + fn build_flat_iods_bytes_treats_avc3_as_avc() { + let sample_entry_box = test_visual_sample_entry_box(FourCc::from_bytes(*b"avc3")); + let config = MuxTrackConfig::new_video(1, 1_000, 640, 360, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 1_000, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_000, + presentation_duration_media: 1_000, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(1_000).with_auto_flat_profile(true); + + let iods_bytes = build_flat_iods_bytes(&file_config, &[track]) + .expect("flat iods") + .expect("present iods"); + let iods = decode_typed_box::(&iods_bytes).expect("decode iods"); + let descriptor = iods + .initial_object_descriptor() + .expect("initial descriptor"); + + assert_eq!(descriptor.visual_profile_level_indication, 0x7f); + } + + #[test] + fn build_flat_iods_bytes_uses_simple_visual_profile_for_avc_plus_timed_text() { + let video_sample_entry_box = test_visual_sample_entry_box(FourCc::from_bytes(*b"avc1")); + let video_config = + MuxTrackConfig::new_video(1, 1_000, 640, 360, video_sample_entry_box.clone()); + let video_track = test_prepared_track(&video_config, &video_sample_entry_box, 1_000); + let text_sample_entry_box = test_basic_sample_entry_box(FourCc::from_bytes(*b"stpp")); + let text_config = + MuxTrackConfig::new_subtitle(2, 1_000, 640, 360, text_sample_entry_box.clone()); + let text_track = test_prepared_track(&text_config, &text_sample_entry_box, 1_000); + let file_config = MuxFileConfig::new(1_000).with_auto_flat_profile(true); + + let iods_bytes = build_flat_iods_bytes(&file_config, &[video_track, text_track]) + .expect("flat iods") + .expect("present iods"); + let iods = decode_typed_box::(&iods_bytes).expect("decode iods"); + let descriptor = iods + .initial_object_descriptor() + .expect("initial descriptor"); + + assert_eq!(descriptor.visual_profile_level_indication, 0x15); + } + + #[test] + fn build_flat_iods_bytes_keeps_avc_visual_profile_for_mp4s_subtitle() { + let video_sample_entry_box = test_visual_sample_entry_box(FourCc::from_bytes(*b"avc1")); + let video_config = + MuxTrackConfig::new_video(1, 1_000, 640, 360, video_sample_entry_box.clone()); + let video_track = test_prepared_track(&video_config, &video_sample_entry_box, 1_000); + let subtitle_sample_entry_box = test_basic_sample_entry_box(FourCc::from_bytes(*b"mp4s")); + let subtitle_config = + MuxTrackConfig::new_subtitle(2, 1_000, 640, 360, subtitle_sample_entry_box.clone()); + let subtitle_track = + test_prepared_track(&subtitle_config, &subtitle_sample_entry_box, 1_000); + let file_config = MuxFileConfig::new(1_000).with_auto_flat_profile(true); + + let iods_bytes = build_flat_iods_bytes(&file_config, &[video_track, subtitle_track]) + .expect("flat iods") + .expect("present iods"); + let iods = decode_typed_box::(&iods_bytes).expect("decode iods"); + let descriptor = iods + .initial_object_descriptor() + .expect("initial descriptor"); + + assert_eq!(descriptor.visual_profile_level_indication, 0x7f); + } + + #[test] + fn build_flat_iods_bytes_uses_unknown_visual_profile_for_imported_authority_avc_mp4a_tracks() { + let video_sample_entry_box = test_visual_sample_entry_box(FourCc::from_bytes(*b"avc1")); + let video_config = + MuxTrackConfig::new_video(1, 1_000, 640, 360, video_sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(1)) + .with_flat_source_media_creation_time(Some(1)); + let video_track = PreparedTrack { + config: &video_config, + sample_entry_box: &video_sample_entry_box, + samples: vec![test_prepared_sample(0, 1_000, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_000, + presentation_duration_media: 1_000, + edit_media_time: None, + flat_timing_override: None, + }; + let audio_sample_entry_box = encode_typed_box( + &AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..AudioSampleEntry::default() + }, + &[], + ) + .expect("mp4a sample entry"); + let audio_config = MuxTrackConfig::new_audio(2, 48_000, audio_sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(1)) + .with_flat_source_media_creation_time(Some(1)); + let audio_track = PreparedTrack { + config: &audio_config, + sample_entry_box: &audio_sample_entry_box, + samples: vec![test_prepared_sample(0, 1_024, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_024, + presentation_duration_media: 1_024, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(1_000).with_auto_flat_profile(true); + + let iods_bytes = build_flat_iods_bytes(&file_config, &[video_track, audio_track]) + .expect("flat iods") + .expect("present iods"); + let iods = decode_typed_box::(&iods_bytes).expect("decode iods"); + let descriptor = iods + .initial_object_descriptor() + .expect("initial descriptor"); + + assert_eq!(descriptor.visual_profile_level_indication, 0xff); + } + + #[test] + fn build_flat_iods_bytes_omits_direct_vvc1_tracks() { + let sample_entry_box = test_visual_sample_entry_box(FourCc::from_bytes(*b"vvc1")); + let config = MuxTrackConfig::new_video(1, 1_000, 640, 360, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 1_000, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_000, + presentation_duration_media: 1_000, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(1_000).with_auto_flat_profile(true); + + assert!( + build_flat_iods_bytes(&file_config, &[track]) + .expect("flat iods") + .is_none() + ); + } + + #[test] + fn build_flat_iods_bytes_uses_no_visual_profile_for_vvc_mp4a_tracks() { + let video_sample_entry_box = test_visual_sample_entry_box(FourCc::from_bytes(*b"vvc1")); + let video_config = + MuxTrackConfig::new_video(1, 90_000, 640, 360, video_sample_entry_box.clone()); + let video_track = test_prepared_track(&video_config, &video_sample_entry_box, 0); + let audio_sample_entry_box = encode_typed_box( + &AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..AudioSampleEntry::default() + }, + &[], + ) + .expect("mp4a sample entry"); + let audio_config = MuxTrackConfig::new_audio(2, 48_000, audio_sample_entry_box.clone()); + let audio_track = test_prepared_track(&audio_config, &audio_sample_entry_box, 1_024); + let file_config = MuxFileConfig::new(1_000).with_auto_flat_profile(true); + + let iods_bytes = build_flat_iods_bytes(&file_config, &[video_track, audio_track]) + .expect("flat iods") + .expect("present iods"); + let iods = decode_typed_box::(&iods_bytes).expect("decode iods"); + let descriptor = iods + .initial_object_descriptor() + .expect("initial descriptor"); + + assert_eq!(descriptor.visual_profile_level_indication, 0xff); + } + + #[test] + fn build_flat_iods_bytes_omits_imported_authority_vorbis_only_tracks() { + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..AudioSampleEntry::default() + }; + let mut esds = Esds::default(); + esds.descriptors = vec![Descriptor { + tag: crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some(crate::boxes::iso14496_14::DecoderConfigDescriptor { + object_type_indication: 0xDD, + stream_type: 5, + reserved: true, + ..crate::boxes::iso14496_14::DecoderConfigDescriptor::default() + }), + ..Descriptor::default() + }]; + let sample_entry_box = + encode_typed_box(&sample_entry, &encode_typed_box(&esds, &[]).expect("esds")) + .expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(1, 48_000, sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(1)) + .with_flat_source_media_creation_time(Some(1)); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 1_024, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_024, + presentation_duration_media: 1_024, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(48_000).with_auto_flat_profile(true); + + assert!( + build_flat_iods_bytes(&file_config, &[track]) + .expect("flat iods") + .is_none() + ); + } + + #[test] + fn build_flat_iods_bytes_omits_imported_authority_voice_mp4a_only_tracks() { + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 8_000 << 16, + ..AudioSampleEntry::default() + }; + let mut esds = Esds::default(); + esds.descriptors = vec![Descriptor { + tag: crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some(crate::boxes::iso14496_14::DecoderConfigDescriptor { + object_type_indication: 0xE1, + stream_type: 5, + reserved: true, + ..crate::boxes::iso14496_14::DecoderConfigDescriptor::default() + }), + ..Descriptor::default() + }]; + let sample_entry_box = + encode_typed_box(&sample_entry, &encode_typed_box(&esds, &[]).expect("esds")) + .expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(1, 8_000, sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(1)) + .with_flat_source_media_creation_time(Some(1)); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 160, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 160, + presentation_duration_media: 160, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(8_000) + .with_auto_flat_profile(true) + .with_allow_audio_only_iods(true); + + assert!( + build_flat_iods_bytes(&file_config, &[track]) + .expect("flat iods") + .is_none() + ); + } + + #[test] + fn build_flat_iods_bytes_omits_imported_authority_direct_voice_only_tracks() { + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"sqcp"), + data_reference_index: 1, + }, + channel_count: 1, + sample_size: 16, + sample_rate: 8_000 << 16, + ..AudioSampleEntry::default() + }; + let sample_entry_box = encode_typed_box(&sample_entry, &[]).expect("sqcp sample entry"); + let config = MuxTrackConfig::new_audio(1, 8_000, sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(1)) + .with_flat_source_media_creation_time(Some(1)); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 160, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 160, + presentation_duration_media: 160, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(8_000) + .with_auto_flat_profile(true) + .with_allow_audio_only_iods(true); + + assert!( + build_flat_iods_bytes(&file_config, &[track]) + .expect("flat iods") + .is_none() + ); + } + + #[test] + fn build_flat_iods_bytes_omits_imported_authority_speex_only_tracks() { + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"spex"), + data_reference_index: 1, + }, + channel_count: 1, + sample_size: 16, + sample_rate: 16_000 << 16, + ..AudioSampleEntry::default() + }; + let sample_entry_box = encode_typed_box(&sample_entry, &[]).expect("spex sample entry"); + let config = MuxTrackConfig::new_audio(1, 16_000, sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(1)) + .with_flat_source_media_creation_time(Some(1)) + .with_omit_flat_iods(true); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 320, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 320, + presentation_duration_media: 320, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(16_000) + .with_auto_flat_profile(true) + .with_allow_audio_only_iods(true); + + assert!( + build_flat_iods_bytes(&file_config, &[track]) + .expect("flat iods") + .is_none() + ); + } + + #[test] + fn build_flat_iods_bytes_authors_direct_speex_only_tracks() { + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"spex"), + data_reference_index: 1, + }, + channel_count: 1, + sample_size: 16, + sample_rate: 16_000 << 16, + ..AudioSampleEntry::default() + }; + let sample_entry_box = encode_typed_box(&sample_entry, &[]).expect("spex sample entry"); + let config = MuxTrackConfig::new_audio(1, 16_000, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 320, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 320, + presentation_duration_media: 320, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(16_000) + .with_auto_flat_profile(true) + .with_allow_audio_only_iods(true); + + let iods_bytes = build_flat_iods_bytes(&file_config, &[track]) + .expect("flat iods") + .expect("present iods"); + let iods = decode_typed_box::(&iods_bytes).expect("decode iods"); + let descriptor = iods + .initial_object_descriptor() + .expect("initial descriptor"); + assert_eq!(descriptor.audio_profile_level_indication, 0xfe); + assert_eq!(descriptor.visual_profile_level_indication, 0xff); + } + + #[test] + fn build_flat_udta_bytes_keeps_tool_metadata_for_imported_authority_speex_only_tracks() { + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"spex"), + data_reference_index: 1, + }, + channel_count: 1, + sample_size: 16, + sample_rate: 16_000 << 16, + ..AudioSampleEntry::default() + }; + let sample_entry_box = encode_typed_box(&sample_entry, &[]).expect("spex sample entry"); + let config = MuxTrackConfig::new_audio(1, 16_000, sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(1)) + .with_flat_source_media_creation_time(Some(1)); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 320, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 320, + presentation_duration_media: 320, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(16_000).with_auto_flat_profile(true); + + assert!( + build_flat_udta_bytes(&file_config, &[track]) + .expect("flat udta") + .is_some() + ); + } + + #[test] + fn build_flat_iods_bytes_uses_he_aac_v2_audio_profile_level() { + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..AudioSampleEntry::default() + }; + let mut esds = Esds::default(); + esds.descriptors = vec![ + Descriptor { + tag: crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some( + crate::boxes::iso14496_14::DecoderConfigDescriptor { + object_type_indication: 0x40, + stream_type: 5, + reserved: true, + ..crate::boxes::iso14496_14::DecoderConfigDescriptor::default() + }, + ), + ..Descriptor::default() + }, + Descriptor { + tag: crate::boxes::iso14496_14::DECODER_SPECIFIC_INFO_TAG, + size: 9, + data: vec![0x10, 0x02, 0xb7, 0x2f, 0xc0, 0x00, 0x00, 0x2a, 0x44], + ..Descriptor::default() + }, + ]; + let sample_entry_box = + encode_typed_box(&sample_entry, &encode_typed_box(&esds, &[]).expect("esds")) + .expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(1, 48_000, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 1_024, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_024, + presentation_duration_media: 1_024, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(48_000).with_auto_flat_profile(true); + + let iods_bytes = build_flat_iods_bytes(&file_config, &[track]) + .expect("flat iods") + .expect("present iods"); + let iods = decode_typed_box::(&iods_bytes).expect("decode iods"); + let descriptor = iods + .initial_object_descriptor() + .expect("initial descriptor"); + + assert_eq!(descriptor.audio_profile_level_indication, 0x2c); + } + + #[test] + fn build_flat_iods_bytes_uses_xhe_aac_audio_profile_level() { + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..AudioSampleEntry::default() + }; + let mut esds = Esds::default(); + esds.descriptors = vec![ + Descriptor { + tag: crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some( + crate::boxes::iso14496_14::DecoderConfigDescriptor { + object_type_indication: 0x40, + stream_type: 5, + reserved: true, + ..crate::boxes::iso14496_14::DecoderConfigDescriptor::default() + }, + ), + ..Descriptor::default() + }, + Descriptor { + tag: crate::boxes::iso14496_14::DECODER_SPECIFIC_INFO_TAG, + size: 3, + data: vec![0xF9, 0x46, 0x40], + ..Descriptor::default() + }, + ]; + let sample_entry_box = + encode_typed_box(&sample_entry, &encode_typed_box(&esds, &[]).expect("esds")) + .expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(1, 48_000, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 2_048, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 2_048, + presentation_duration_media: 2_048, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(48_000).with_auto_flat_profile(true); + + let iods_bytes = build_flat_iods_bytes(&file_config, &[track]) + .expect("flat iods") + .expect("present iods"); + let iods = decode_typed_box::(&iods_bytes).expect("decode iods"); + let descriptor = iods + .initial_object_descriptor() + .expect("initial descriptor"); + + assert_eq!(descriptor.audio_profile_level_indication, 0x0f); + } + + #[test] + fn build_flat_iods_bytes_uses_he_aac_audio_profile_level_for_low_rate_sbr() { + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 24_000 << 16, + ..AudioSampleEntry::default() + }; + let mut esds = Esds::default(); + esds.descriptors = vec![ + Descriptor { + tag: crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some( + crate::boxes::iso14496_14::DecoderConfigDescriptor { + object_type_indication: 0x40, + stream_type: 5, + reserved: true, + ..crate::boxes::iso14496_14::DecoderConfigDescriptor::default() + }, + ), + ..Descriptor::default() + }, + Descriptor { + tag: crate::boxes::iso14496_14::DECODER_SPECIFIC_INFO_TAG, + size: 4, + data: vec![0x2b, 0x92, 0x08, 0x00], + ..Descriptor::default() + }, + ]; + let sample_entry_box = + encode_typed_box(&sample_entry, &encode_typed_box(&esds, &[]).expect("esds")) + .expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(1, 24_000, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 1_024, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_024, + presentation_duration_media: 1_024, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(24_000).with_auto_flat_profile(true); + + let iods_bytes = build_flat_iods_bytes(&file_config, &[track]) + .expect("flat iods") + .expect("present iods"); + let iods = decode_typed_box::(&iods_bytes).expect("decode iods"); + let descriptor = iods + .initial_object_descriptor() + .expect("initial descriptor"); + + assert_eq!(descriptor.audio_profile_level_indication, 0x28); + } + + #[test] + fn build_flat_iods_bytes_uses_unknown_audio_profile_for_imported_authority_mp3_mp4a() { + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..AudioSampleEntry::default() + }; + let mut esds = Esds::default(); + esds.descriptors = vec![Descriptor { + tag: crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some(crate::boxes::iso14496_14::DecoderConfigDescriptor { + object_type_indication: 0x6b, + stream_type: 5, + reserved: true, + ..crate::boxes::iso14496_14::DecoderConfigDescriptor::default() + }), + ..Descriptor::default() + }]; + let sample_entry_box = + encode_typed_box(&sample_entry, &encode_typed_box(&esds, &[]).expect("esds")) + .expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(1, 48_000, sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(1)) + .with_flat_source_media_creation_time(Some(1)); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 1_152, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_152, + presentation_duration_media: 1_152, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(48_000) + .with_auto_flat_profile(true) + .with_allow_audio_only_iods(true); + + let iods_bytes = build_flat_iods_bytes(&file_config, &[track]) + .expect("flat iods") + .expect("present iods"); + let iods = decode_typed_box::(&iods_bytes).expect("decode iods"); + let descriptor = iods + .initial_object_descriptor() + .expect("initial descriptor"); + + assert_eq!(descriptor.audio_profile_level_indication, 0xfe); + } + + #[test] + fn build_flat_iods_bytes_uses_configured_mhm1_audio_profile_level() { + let btrt_bytes = encode_typed_box(&Btrt::default(), &[]).expect("btrt"); + let sample_entry_box = encode_typed_box( + &AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"mhm1"), + data_reference_index: 1, + }, + sample_size: 16, + sample_rate: 48_000 << 16, + ..AudioSampleEntry::default() + }, + &btrt_bytes, + ) + .expect("mhm1 sample entry"); + let config = MuxTrackConfig::new_audio(1, 48_000, sample_entry_box.clone()) + .with_flat_audio_profile_level_indication(0x0e); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 1_024, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_024, + presentation_duration_media: 1_024, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(48_000).with_auto_flat_profile(true); + + let iods_bytes = build_flat_iods_bytes(&file_config, &[track]) + .expect("flat iods") + .expect("present iods"); + let iods = decode_typed_box::(&iods_bytes).expect("decode iods"); + let descriptor = iods + .initial_object_descriptor() + .expect("initial descriptor"); + + assert_eq!(descriptor.audio_profile_level_indication, 0x0e); + } + + #[test] + fn build_flat_iods_bytes_uses_unknown_visual_profile_for_imported_authority_mpeg2_mp4v() { + let mut esds = Esds::default(); + esds.descriptors = vec![Descriptor { + tag: crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some(crate::boxes::iso14496_14::DecoderConfigDescriptor { + object_type_indication: 0x60, + stream_type: 4, + reserved: true, + ..crate::boxes::iso14496_14::DecoderConfigDescriptor::default() + }), + ..Descriptor::default() + }]; + let sample_entry_box = encode_typed_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"mp4v"), + data_reference_index: 1, + }, + width: 640, + height: 360, + ..VisualSampleEntry::default() + }, + &encode_typed_box(&esds, &[]).expect("esds"), + ) + .expect("mp4v sample entry"); + let config = MuxTrackConfig::new_video(1, 1_000, 640, 360, sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(1)) + .with_flat_source_media_creation_time(Some(1)); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 1_000, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_000, + presentation_duration_media: 1_000, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(1_000).with_auto_flat_profile(true); + + let iods_bytes = build_flat_iods_bytes(&file_config, &[track]) + .expect("flat iods") + .expect("present iods"); + let iods = decode_typed_box::(&iods_bytes).expect("decode iods"); + let descriptor = iods + .initial_object_descriptor() + .expect("initial descriptor"); + + assert_eq!(descriptor.visual_profile_level_indication, 0xfe); + } + + #[test] + fn fragmented_visual_tkhd_dimensions_fixed_16_16_preserves_non_square_pasp_width() { + let avcc = encode_typed_box( + &AVCDecoderConfiguration { + configuration_version: 1, + profile: 100, + profile_compatibility: 0, + level: 31, + length_size_minus_one: 3, + ..AVCDecoderConfiguration::default() + }, + &[], + ) + .expect("avcc"); + let pasp = encode_typed_box( + &Pasp { + h_spacing: 8, + v_spacing: 9, + }, + &[], + ) + .expect("pasp"); + let sample_entry = encode_typed_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"avc1"), + data_reference_index: 1, + }, + width: 706, + height: 472, + ..VisualSampleEntry::default() + }, + &[avcc, pasp].concat(), + ) + .expect("sample entry"); + + let dimensions = + fragmented_visual_tkhd_dimensions_fixed_16_16(&sample_entry).expect("dimensions"); + + assert_eq!(dimensions, Some((41_127_481, 30_932_992))); + } + + #[test] + fn build_mdhd_preserves_imported_authority_video_media_modification_time() { + let sample_entry_box = test_visual_sample_entry_box(FourCc::from_bytes(*b"avc1")); + let config = MuxTrackConfig::new_video(1, 1_000, 640, 360, sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(0)) + .with_flat_source_media_creation_time(Some(0)) + .with_flat_source_media_modification_time(Some(0)); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 1_000, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_000, + presentation_duration_media: 1_000, + edit_media_time: None, + flat_timing_override: None, + }; + + let mdhd = build_mdhd(&track, Some(123)).expect("mdhd"); + + assert_eq!(mdhd.creation_time(), 0); + assert_eq!(mdhd.modification_time(), 0); + } + + #[test] + fn build_tkhd_uses_generated_modification_time_for_imported_authority_video_when_source_value_is_zero() + { + let sample_entry_box = test_visual_sample_entry_box(FourCc::from_bytes(*b"avc1")); + let config = MuxTrackConfig::new_video(1, 1_000, 640, 360, sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(0)) + .with_flat_source_track_modification_time(Some(0)) + .with_flat_source_media_creation_time(Some(0)); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 1_000, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_000, + presentation_duration_media: 1_000, + edit_media_time: None, + flat_timing_override: None, + }; + let file_config = MuxFileConfig::new(1_000).with_auto_flat_profile(true); + + let tkhd = build_tkhd(&file_config, &track, Some(123)).expect("tkhd"); + + assert_eq!(tkhd.creation_time(), 0); + assert_eq!(tkhd.modification_time(), 123); + } + + #[test] + fn build_mdhd_keeps_generated_media_modification_time_for_imported_authority_audio() { + let sample_entry_box = encode_typed_box( + &AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..AudioSampleEntry::default() + }, + &[], + ) + .expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(1, 48_000, sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(0)) + .with_flat_source_media_creation_time(Some(0)) + .with_flat_source_media_modification_time(Some(0)); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 1_024, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_024, + presentation_duration_media: 1_024, + edit_media_time: None, + flat_timing_override: None, + }; + + let mdhd = build_mdhd(&track, Some(123)).expect("mdhd"); + + assert_eq!(mdhd.creation_time(), 0); + assert_eq!(mdhd.modification_time(), 123); + } + + #[test] + fn build_fragmented_ftyp_bytes_uses_avc3_brand_without_cmfc() { + let sample_entry_box = test_visual_sample_entry_box(FourCc::from_bytes(*b"avc3")); + let config = MuxTrackConfig::new_video(1, 1_000, 640, 360, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 1_000, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_000, + presentation_duration_media: 1_000, + edit_media_time: None, + flat_timing_override: None, + }; + + let ftyp = build_fragmented_ftyp(&[track]).expect("fragmented ftyp"); + let ftyp_bytes = encode_typed_box(&ftyp, &[]).expect("fragmented ftyp bytes"); + let ftyp = decode_typed_box::(&ftyp_bytes).expect("decode ftyp"); + + assert_eq!(ftyp.major_brand, FourCc::from_bytes(*b"mp41")); + assert_eq!( + ftyp.compatible_brands, + vec![ + FourCc::from_bytes(*b"iso8"), + FourCc::from_bytes(*b"isom"), + FourCc::from_bytes(*b"mp41"), + FourCc::from_bytes(*b"dash"), + FourCc::from_bytes(*b"avc3"), + ] + ); + } + + #[test] + fn canonicalize_fragmented_sample_entry_box_sets_avc3_compressor_name() { + let sample_entry_box = test_visual_sample_entry_box(FourCc::from_bytes(*b"avc3")); + + let normalized = + canonicalize_fragmented_sample_entry_box(&sample_entry_box).expect("normalize avc3"); + let (sample_entry, _, _) = + decode_visual_sample_entry_parts(&normalized).expect("decode visual sample entry"); + let visible_len = usize::from(sample_entry.compressorname[0]).min(31); + + assert_eq!( + &sample_entry.compressorname[1..1 + visible_len], + b"AVC Coding" + ); + } + + #[test] + fn canonicalize_fragmented_sample_entry_box_reorders_extended_av1_children() { + let child_payload = [ + encode_raw_box(FourCc::from_bytes(*b"av1C"), &[0]).expect("av1C"), + encode_raw_box(FourCc::from_bytes(*b"colr"), &[0]).expect("colr"), + encode_raw_box(FourCc::from_bytes(*b"btrt"), &[0]).expect("btrt"), + encode_raw_box(FourCc::from_bytes(*b"dvvC"), &[0]).expect("dvvC"), + ] + .concat(); + let sample_entry_box = encode_typed_box( + &VisualSampleEntry { + sample_entry: crate::boxes::iso14496_12::SampleEntry { + box_type: FourCc::from_bytes(*b"av01"), + data_reference_index: 1, + }, + width: 640, + height: 360, + ..VisualSampleEntry::default() + }, + &child_payload, + ) + .expect("av01 sample entry"); + + let normalized = + canonicalize_fragmented_sample_entry_box(&sample_entry_box).expect("normalize av01"); + let child_types = visual_sample_entry_immediate_children(&normalized) + .expect("visual children") + .into_iter() + .map(|child_box| sample_entry_box_type(&child_box).expect("child type")) + .collect::>(); + + assert_eq!( + child_types, + vec![ + FourCc::from_bytes(*b"av1C"), + FourCc::from_bytes(*b"dvvC"), + FourCc::from_bytes(*b"colr"), + ] + ); + } + + #[test] + fn fragmented_mehd_duration_trims_vp08_presentation_span_by_one_tick() { + let sample_entry_box = test_visual_sample_entry_box(FourCc::from_bytes(*b"vp08")); + let config = MuxTrackConfig::new_video(1, 30_000, 640, 360, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 259_999, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 259_999, + presentation_duration_media: 260_000, + edit_media_time: None, + flat_timing_override: None, + }; + + let duration = fragmented_mehd_duration(30_000, &track).expect("fragmented mehd duration"); + + assert_eq!(duration, 259_998); + } + + #[test] + fn fragmented_mehd_duration_preserves_full_imported_vp08_presentation_span() { + let sample_entry_box = test_visual_sample_entry_box(FourCc::from_bytes(*b"vp08")); + let config = MuxTrackConfig::new_video(1, 1_000_000, 640, 360, sample_entry_box.clone()) + .with_stsc_run_encoding_mode(StscRunEncodingMode::PreserveTerminalBoundary); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![test_prepared_sample(0, 2_736_000, true)], + chunk_sample_counts: vec![1], + fragmented_reference_group_fragment_counts: None, + media_duration: 2_736_000, + presentation_duration_media: 2_736_000, + edit_media_time: None, + flat_timing_override: None, + }; + + let duration = + fragmented_mehd_duration(1_000_000, &track).expect("fragmented mehd duration"); + + assert_eq!(duration, 2_736_000); + } + + #[test] + fn preserved_flat_stsc_override_keeps_explicit_duplicate_boundaries() { + let sample_entry_box = + encode_raw_box(FourCc::from_bytes(*b"mp4a"), &[]).expect("mp4a sample entry"); + let mut preserved_stsc = Stsc::default(); + preserved_stsc.entry_count = 3; + preserved_stsc.entries = vec![ + StscEntry { + first_chunk: 1, + samples_per_chunk: 2, + sample_description_index: 1, + }, + StscEntry { + first_chunk: 2, + samples_per_chunk: 2, + sample_description_index: 1, + }, + StscEntry { + first_chunk: 3, + samples_per_chunk: 1, + sample_description_index: 1, + }, + ]; + let config = MuxTrackConfig::new_audio(1, 48_000, sample_entry_box.clone()) + .with_flat_stsc_override(preserved_stsc.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![ + test_prepared_sample(0, 1_024, true), + test_prepared_sample(1_024, 1_024, true), + test_prepared_sample(2_048, 1_024, true), + test_prepared_sample(3_072, 1_024, true), + test_prepared_sample(4_096, 1_024, true), + ], + chunk_sample_counts: vec![2, 2, 1], + fragmented_reference_group_fragment_counts: None, + media_duration: 5_120, + presentation_duration_media: 5_120, + edit_media_time: None, + flat_timing_override: None, + }; + + let stsc = preserved_flat_stsc_or_built(&track).expect("preserved stsc"); + + assert_eq!(stsc, preserved_stsc); + } + + #[test] + fn fragmented_mehd_duration_uses_audio_sample_span_when_media_duration_rounds_up() { + let sample_entry_box = + encode_raw_box(FourCc::from_bytes(*b"mp4a"), &[]).expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(1, 44_100, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![ + test_prepared_sample(0, 264_192, true), + test_prepared_sample(264_192, 264_192, true), + test_prepared_sample(528_384, 120_832, true), + ], + chunk_sample_counts: vec![2, 1], + fragmented_reference_group_fragment_counts: None, + media_duration: 649_217, + presentation_duration_media: 649_217, + edit_media_time: None, + flat_timing_override: None, + }; + + let duration = fragmented_mehd_duration(44_100, &track).expect("fragmented mehd duration"); + + assert_eq!(duration, 649_216); + } + + #[test] + fn limit_sidx_references_caps_to_encoded_field_capacity() { + let references = (0..=MAX_SIDX_REFERENCES) + .map(|index| BuiltSidxReference { + reference: SidxReference { + referenced_size: 1, + subsegment_duration: 1, + starts_with_sap: true, + ..SidxReference::default() + }, + earliest_presentation_time: u64::try_from(index).unwrap(), + }) + .collect::>(); + + let limited = limit_sidx_references(references); + + assert_eq!(limited.len(), MAX_SIDX_REFERENCES); + assert_eq!( + limited.last().unwrap().earliest_presentation_time, + u64::from(u16::MAX - 1) + ); + } + + #[test] + fn prepare_track_uses_flat_timing_override_decode_times_for_rounded_movie_ticks() { + let sample_entry_box = + encode_raw_box(FourCc::from_bytes(*b"mp4a"), &[]).expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(2, 44_100, sample_entry_box) + .with_flat_timing_override(FlatTimingOverride { + sample_durations: vec![1_024, 1_024], + composition_offsets: vec![0, 0], + media_duration: 2_048, + presentation_duration: 2_048, + }); + let plan = plan_staged_media_items_with_chunk_sample_counts( + vec![ + MuxStagedMediaItem::new(0, 2, 0, 23, 0, 4), + MuxStagedMediaItem::new(0, 2, 23, 23, 4, 4), + ], + MuxInterleavePolicy::DecodeTime, + [(2, vec![2])], + ) + .expect("plan"); + + let prepared = prepare_track( + &MuxFileConfig::new(1_000), + &plan, + &config, + plan.planned_items().iter().collect(), + true, + ) + .expect("prepared track"); + + assert_eq!( + prepared + .samples + .iter() + .map(|sample| sample.decode_time_media) + .collect::>(), + vec![0, 1_024] + ); + } + + #[test] + fn fragmented_mehd_duration_floors_imported_audio_authority_duration_when_movie_timescale_differs() + { + let sample_entry_box = + encode_raw_box(FourCc::from_bytes(*b"mp4a"), &[]).expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(1, 10, sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(1)); + let override_value = FlatTimingOverride { + sample_durations: vec![3, 3, 3], + composition_offsets: vec![0, 0, 0], + media_duration: 9, + presentation_duration: 9, + }; + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![ + test_prepared_sample(0, 1, true), + test_prepared_sample(1, 1, true), + test_prepared_sample(2, 1, true), + ], + chunk_sample_counts: vec![2, 1], + fragmented_reference_group_fragment_counts: None, + media_duration: 9, + presentation_duration_media: 9, + edit_media_time: None, + flat_timing_override: Some(&override_value), + }; + + let duration = fragmented_mehd_duration(4, &track).expect("fragmented mehd duration"); + + assert_eq!(duration, 3); + } + + #[test] + fn fragmented_mehd_duration_preserves_imported_audio_authority_media_duration_at_same_timescale() + { + let sample_entry_box = + encode_raw_box(FourCc::from_bytes(*b"mp4a"), &[]).expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(1, 44_100, sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(1)); + let override_value = FlatTimingOverride { + sample_durations: vec![1_024, 1_024, 1_024], + composition_offsets: vec![0, 0, 0], + media_duration: 3_072, + presentation_duration: 3_071, + }; + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![ + test_prepared_sample(0, 1_024, true), + test_prepared_sample(1_024, 1_024, true), + test_prepared_sample(2_048, 1_024, true), + ], + chunk_sample_counts: vec![2, 1], + fragmented_reference_group_fragment_counts: None, + media_duration: 3_072, + presentation_duration_media: 3_071, + edit_media_time: None, + flat_timing_override: Some(&override_value), + }; + + let duration = fragmented_mehd_duration(44_100, &track).expect("fragmented mehd duration"); + + assert_eq!(duration, 3_072); + } + + #[test] + fn fragmented_mehd_duration_uses_imported_audio_sample_span_when_authority_media_duration_is_one_tick_larger() + { + let sample_entry_box = + encode_raw_box(FourCc::from_bytes(*b"mp4a"), &[]).expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(1, 44_100, sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(1)); + let override_value = FlatTimingOverride { + sample_durations: vec![1_024, 1_024, 1_024], + composition_offsets: vec![0, 0, 0], + media_duration: 3_072, + presentation_duration: 3_071, + }; + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![ + test_prepared_sample(0, 1_024, true), + test_prepared_sample(1_024, 1_024, true), + test_prepared_sample(2_048, 1_023, true), + ], + chunk_sample_counts: vec![2, 1], + fragmented_reference_group_fragment_counts: None, + media_duration: 3_072, + presentation_duration_media: 3_071, + edit_media_time: None, + flat_timing_override: Some(&override_value), + }; + + let duration = fragmented_mehd_duration(44_100, &track).expect("fragmented mehd duration"); + + assert_eq!(duration, 3_071); + } + + #[test] + fn fragmented_mehd_duration_scales_imported_audio_authority_media_duration_when_movie_timescale_differs() + { + let sample_entry_box = + encode_raw_box(FourCc::from_bytes(*b"mp4a"), &[]).expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(1, 10, sample_entry_box.clone()) + .with_flat_source_track_creation_time(Some(1)); + let override_value = FlatTimingOverride { + sample_durations: vec![3, 3, 3], + composition_offsets: vec![0, 0, 0], + media_duration: 8, + presentation_duration: 9, + }; + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![ + test_prepared_sample(0, 1, true), + test_prepared_sample(1, 1, true), + test_prepared_sample(2, 1, true), + ], + chunk_sample_counts: vec![2, 1], + fragmented_reference_group_fragment_counts: None, + media_duration: 8, + presentation_duration_media: 9, + edit_media_time: None, + flat_timing_override: Some(&override_value), + }; + + let duration = fragmented_mehd_duration(4, &track).expect("fragmented mehd duration"); + + assert_eq!(duration, 3); + } + + #[test] + fn fragmented_mehd_duration_trims_even_full_frame_mp4a_by_one_tick() { + let sample_entry_box = + encode_raw_box(FourCc::from_bytes(*b"mp4a"), &[]).expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(1, 44_100, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![ + test_prepared_sample(0, 1_024, true), + test_prepared_sample(1_024, 1_024, true), + ], + chunk_sample_counts: vec![2], + fragmented_reference_group_fragment_counts: None, + media_duration: 2_048, + presentation_duration_media: 2_048, + edit_media_time: None, + flat_timing_override: None, + }; + + let duration = fragmented_mehd_duration(44_100, &track).expect("fragmented mehd duration"); + + assert_eq!(duration, 2_047); + } + + #[test] + fn fragmented_mehd_duration_preserves_terminal_short_frame_mp4a() { + let sample_entry_box = + encode_raw_box(FourCc::from_bytes(*b"mp4a"), &[]).expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(1, 44_100, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![ + test_prepared_sample(0, 1_024, true), + test_prepared_sample(1_024, 720, true), + ], + chunk_sample_counts: vec![2], + fragmented_reference_group_fragment_counts: None, + media_duration: 1_744, + presentation_duration_media: 1_744, + edit_media_time: None, + flat_timing_override: None, + }; + + let duration = fragmented_mehd_duration(44_100, &track).expect("fragmented mehd duration"); + + assert_eq!(duration, 1_744); + } + + #[test] + fn fragmented_mehd_duration_trims_odd_ec3_sample_count_by_one_tick() { + let sample_entry_box = + encode_raw_box(FourCc::from_bytes(*b"ec-3"), &[]).expect("ec-3 sample entry"); + let config = MuxTrackConfig::new_audio(1, 48_000, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![ + test_prepared_sample(0, 1_536, true), + test_prepared_sample(1_536, 1_536, true), + test_prepared_sample(3_072, 1_536, true), + ], + chunk_sample_counts: vec![3], + fragmented_reference_group_fragment_counts: None, + media_duration: 4_608, + presentation_duration_media: 4_608, + edit_media_time: None, + flat_timing_override: None, + }; + + let duration = fragmented_mehd_duration(48_000, &track).expect("fragmented mehd duration"); + + assert_eq!(duration, 4_607); + } + + #[test] + fn fragmented_mehd_duration_preserves_even_ec3_sample_count() { + let sample_entry_box = + encode_raw_box(FourCc::from_bytes(*b"ec-3"), &[]).expect("ec-3 sample entry"); + let config = MuxTrackConfig::new_audio(1, 48_000, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![ + test_prepared_sample(0, 1_536, true), + test_prepared_sample(1_536, 1_536, true), + ], + chunk_sample_counts: vec![2], + fragmented_reference_group_fragment_counts: None, + media_duration: 3_072, + presentation_duration_media: 3_072, + edit_media_time: None, + flat_timing_override: None, + }; + + let duration = fragmented_mehd_duration(48_000, &track).expect("fragmented mehd duration"); + + assert_eq!(duration, 3_072); + } + + #[test] + fn fragmented_mehd_duration_preserves_odd_44100_ec3_sample_count() { + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"ec-3"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 44_100 << 16, + ..AudioSampleEntry::default() + }; + let dec3 = Dec3 { + data_rate: 192, + num_ind_sub: 0, + ec3_substreams: vec![crate::boxes::etsi_ts_102_366::Ec3Substream::default()], + reserved: Vec::new(), + }; + let sample_entry_box = + encode_typed_box(&sample_entry, &encode_typed_box(&dec3, &[]).expect("dec3")) + .expect("ec-3 sample entry"); + let config = MuxTrackConfig::new_audio(1, 44_100, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![ + test_prepared_sample(0, 1_536, true), + test_prepared_sample(1_536, 1_536, true), + test_prepared_sample(3_072, 1_536, true), + ], + chunk_sample_counts: vec![3], + fragmented_reference_group_fragment_counts: None, + media_duration: 4_608, + presentation_duration_media: 4_608, + edit_media_time: None, + flat_timing_override: None, + }; + + let duration = fragmented_mehd_duration(44_100, &track).expect("fragmented mehd duration"); + + assert_eq!(duration, 4_608); + } + + #[test] + fn fragmented_mehd_duration_preserves_odd_640k_ec3_sample_count() { + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"ec-3"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..AudioSampleEntry::default() + }; + let dec3 = Dec3 { + data_rate: 640, + num_ind_sub: 0, + ec3_substreams: vec![crate::boxes::etsi_ts_102_366::Ec3Substream::default()], + reserved: Vec::new(), + }; + let sample_entry_box = + encode_typed_box(&sample_entry, &encode_typed_box(&dec3, &[]).expect("dec3")) + .expect("ec-3 sample entry"); + let config = MuxTrackConfig::new_audio(1, 48_000, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![ + test_prepared_sample(0, 1_536, true), + test_prepared_sample(1_536, 1_536, true), + test_prepared_sample(3_072, 1_536, true), + ], + chunk_sample_counts: vec![3], + fragmented_reference_group_fragment_counts: None, + media_duration: 4_608, + presentation_duration_media: 4_608, + edit_media_time: None, + flat_timing_override: None, + }; + + let duration = fragmented_mehd_duration(48_000, &track).expect("fragmented mehd duration"); + + assert_eq!(duration, 4_608); + } + + #[test] + fn fragmented_mehd_duration_preserves_even_full_frame_192k_mp4a() { + let mut esds = crate::boxes::iso14496_14::Esds::default(); + esds.descriptors = vec![ + crate::boxes::iso14496_14::Descriptor { + tag: crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some( + crate::boxes::iso14496_14::DecoderConfigDescriptor { + object_type_indication: 0x40, + stream_type: 5, + reserved: true, + ..crate::boxes::iso14496_14::DecoderConfigDescriptor::default() + }, + ), + ..crate::boxes::iso14496_14::Descriptor::default() + }, + crate::boxes::iso14496_14::Descriptor { + tag: crate::boxes::iso14496_14::DECODER_SPECIFIC_INFO_TAG, + size: 2, + data: vec![0x12, 0x10], + ..crate::boxes::iso14496_14::Descriptor::default() + }, + ]; + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 44_100 << 16, + ..AudioSampleEntry::default() + }; + let sample_entry_box = + encode_typed_box(&sample_entry, &encode_typed_box(&esds, &[]).expect("esds")) + .expect("mp4a sample entry"); + let config = MuxTrackConfig::new_audio(1, 44_100, sample_entry_box.clone()); + let track = PreparedTrack { + config: &config, + sample_entry_box: &sample_entry_box, + samples: vec![ + test_prepared_sample_with_size(0, 1_024, 548, true), + test_prepared_sample_with_size(1_024, 1_024, 548, true), + ], + chunk_sample_counts: vec![2], + fragmented_reference_group_fragment_counts: None, + media_duration: 2_048, + presentation_duration_media: 2_048, + edit_media_time: None, + flat_timing_override: None, + }; + + let duration = fragmented_mehd_duration(44_100, &track).expect("fragmented mehd duration"); + + assert_eq!(duration, 2_048); + } + + #[test] + fn fragmented_mp4a_sample_entry_sample_rate_falls_back_to_sample_entry_header() { + let mut esds = Esds::default(); + esds.descriptors = vec![ + crate::boxes::iso14496_14::Descriptor { + tag: crate::boxes::iso14496_14::DECODER_CONFIG_DESCRIPTOR_TAG, + decoder_config_descriptor: Some( + crate::boxes::iso14496_14::DecoderConfigDescriptor { + object_type_indication: 0x40, + stream_type: 5, + reserved: true, + ..crate::boxes::iso14496_14::DecoderConfigDescriptor::default() + }, + ), + ..crate::boxes::iso14496_14::Descriptor::default() + }, + crate::boxes::iso14496_14::Descriptor { + tag: crate::boxes::iso14496_14::DECODER_SPECIFIC_INFO_TAG, + size: 1, + data: vec![0xff], + ..crate::boxes::iso14496_14::Descriptor::default() + }, + ]; + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"mp4a"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 44_100 << 16, + ..AudioSampleEntry::default() + }; + let sample_entry_box = + encode_typed_box(&sample_entry, &encode_typed_box(&esds, &[]).expect("esds")) + .expect("mp4a sample entry"); + + let sample_rate = + fragmented_mp4a_sample_entry_sample_rate(&sample_entry_box).expect("sample rate"); + + assert_eq!(sample_rate, 44_100 << 16); + } +} diff --git a/src/mux/rewrite.rs b/src/mux/rewrite.rs new file mode 100644 index 0000000..c693466 --- /dev/null +++ b/src/mux/rewrite.rs @@ -0,0 +1,864 @@ +//! Public elementary sample rewrite helpers built on the landed mux codec logic. +//! +//! These helpers convert extracted MP4 sample payloads back into one stable elementary-stream +//! shape without depending on crate-private mux internals. They are useful when callers have +//! already extracted sample bytes through [`crate::mux::sample_reader`] or another MP4-side path +//! and want one stable library helper for elementary-stream export. + +use std::error::Error; +use std::fmt; + +use crate::boxes::iso14496_12::{AVCDecoderConfiguration, HEVCDecoderConfiguration}; +use crate::boxes::iso14496_15::VVCDecoderConfiguration; + +/// Errors returned by the public Annex B sample rewrite helpers. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AnnexBRewriteError { + /// The supplied decoder configuration record was missing bytes needed to derive the NAL length + /// field width. + MissingConfigurationRecord { + /// Stable codec-family label used in user-facing errors. + codec: &'static str, + }, + /// The decoder configuration record carried one invalid NAL length field width. + InvalidLengthFieldWidth { + /// Stable codec-family label used in user-facing errors. + codec: &'static str, + /// Invalid number of bytes claimed by the configuration record. + width: u8, + }, + /// The sample ended before one complete NAL length field could be read. + TruncatedLengthField { + /// Stable codec-family label used in user-facing errors. + codec: &'static str, + /// Byte offset where the truncated length field started. + offset: usize, + /// Expected length-field width in bytes. + width: usize, + }, + /// One declared NAL payload was empty. + EmptyNalUnit { + /// Stable codec-family label used in user-facing errors. + codec: &'static str, + /// Byte offset where the empty NAL length field started. + offset: usize, + }, + /// The sample ended before the full declared NAL payload was available. + TruncatedNalUnit { + /// Stable codec-family label used in user-facing errors. + codec: &'static str, + /// Byte offset where the NAL payload was expected to begin. + offset: usize, + /// NAL payload size declared by the sample. + declared_size: usize, + /// Remaining bytes available from `offset` onward. + remaining_size: usize, + }, +} + +impl fmt::Display for AnnexBRewriteError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingConfigurationRecord { codec } => write!( + f, + "{codec} decoder configuration record is missing the bytes needed to derive the NAL length field width" + ), + Self::InvalidLengthFieldWidth { codec, width } => write!( + f, + "{codec} decoder configuration declared an unsupported NAL length field width of {width} bytes" + ), + Self::TruncatedLengthField { + codec, + offset, + width, + } => write!( + f, + "{codec} sample ended while reading the {width}-byte NAL length field at byte offset {offset}" + ), + Self::EmptyNalUnit { codec, offset } => write!( + f, + "{codec} sample declared one empty NAL unit at byte offset {offset}" + ), + Self::TruncatedNalUnit { + codec, + offset, + declared_size, + remaining_size, + } => write!( + f, + "{codec} sample declared one {declared_size}-byte NAL unit at byte offset {offset}, but only {remaining_size} payload bytes remained" + ), + } + } +} + +impl Error for AnnexBRewriteError {} + +/// Errors returned by the public AV1 Annex B temporal-unit rewrite helper. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Av1AnnexBRewriteError { + /// One OBU header was missing bytes before the full header could be parsed. + TruncatedObuHeader { + /// Byte offset where the truncated OBU header started. + offset: usize, + }, + /// One OBU header used a forbidden or reserved bit pattern that the helper rejects. + InvalidObuHeader { + /// Byte offset where the invalid OBU header started. + offset: usize, + /// Human-readable validation detail. + message: &'static str, + }, + /// One OBU omitted its internal size field, which is required on the public helper path. + MissingObuSizeField { + /// Byte offset where the OBU header started. + offset: usize, + }, + /// One leb128-encoded OBU size field was truncated or did not terminate. + TruncatedObuSizeField { + /// Byte offset where the size field started. + offset: usize, + }, + /// One OBU declared a payload larger than the remaining sample bytes. + TruncatedObuPayload { + /// Byte offset where the OBU payload was expected to begin. + offset: usize, + /// OBU payload size declared by the sample. + declared_size: usize, + /// Remaining bytes available from `offset` onward. + remaining_size: usize, + }, +} + +impl fmt::Display for Av1AnnexBRewriteError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TruncatedObuHeader { offset } => { + write!( + f, + "AV1 sample ended while reading the OBU header at byte offset {offset}" + ) + } + Self::InvalidObuHeader { offset, message } => { + write!(f, "AV1 OBU header at byte offset {offset} {message}") + } + Self::MissingObuSizeField { offset } => write!( + f, + "AV1 OBU at byte offset {offset} omitted the internal size field required for MP4 sample export" + ), + Self::TruncatedObuSizeField { offset } => write!( + f, + "AV1 sample ended while reading the leb128 OBU size field at byte offset {offset}" + ), + Self::TruncatedObuPayload { + offset, + declared_size, + remaining_size, + } => write!( + f, + "AV1 sample declared one {declared_size}-byte OBU payload at byte offset {offset}, but only {remaining_size} payload bytes remained" + ), + } + } +} + +impl Error for Av1AnnexBRewriteError {} + +/// Errors returned by the public AAC ADTS rewrite helper. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AdtsRewriteError { + /// The supplied AudioSpecificConfig ended before the required first two bytes. + TruncatedAudioSpecificConfig, + /// The supplied AudioSpecificConfig used one unsupported audio object type. + UnsupportedAudioObjectType { + /// Parsed AAC audio object type. + audio_object_type: u8, + }, + /// The supplied AudioSpecificConfig used one reserved or unsupported sample-rate index. + UnsupportedSamplingFrequencyIndex { + /// Parsed sampling-frequency index. + sampling_frequency_index: u8, + }, + /// The supplied AudioSpecificConfig used one invalid channel configuration. + InvalidChannelConfiguration { + /// Parsed channel configuration. + channel_configuration: u8, + }, + /// The supplied AAC payload would not fit in one seven-byte ADTS frame. + FrameTooLarge { + /// AAC payload size in bytes. + payload_size: usize, + }, +} + +impl fmt::Display for AdtsRewriteError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TruncatedAudioSpecificConfig => write!( + f, + "AAC AudioSpecificConfig is truncated before the required first two bytes" + ), + Self::UnsupportedAudioObjectType { audio_object_type } => write!( + f, + "AAC AudioSpecificConfig declared unsupported audio object type {audio_object_type} for ADTS export" + ), + Self::UnsupportedSamplingFrequencyIndex { + sampling_frequency_index, + } => write!( + f, + "AAC AudioSpecificConfig declared unsupported sampling-frequency index {sampling_frequency_index} for ADTS export" + ), + Self::InvalidChannelConfiguration { + channel_configuration, + } => write!( + f, + "AAC AudioSpecificConfig declared invalid channel configuration {channel_configuration} for ADTS export" + ), + Self::FrameTooLarge { payload_size } => write!( + f, + "AAC payload size {payload_size} does not fit in one 13-bit ADTS frame length" + ), + } + } +} + +impl Error for AdtsRewriteError {} + +/// Errors returned by the public MHAS stream export helper. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MhasRewriteError { + /// The supplied sample list was empty. + EmptySampleList, + /// One sample ended before a complete MHAS packet header could be parsed. + TruncatedPacketHeader { + /// Index of the sample containing the truncated packet header. + sample_index: usize, + /// Byte offset within the sample where the truncated packet header started. + offset: usize, + }, + /// One sample declared a packet payload larger than the remaining sample bytes. + TruncatedPacketPayload { + /// Index of the sample containing the truncated packet payload. + sample_index: usize, + /// Byte offset within the sample where the payload was expected to begin. + offset: usize, + /// Packet payload size declared by the packet header. + declared_size: usize, + /// Remaining bytes available from `offset` onward. + remaining_size: usize, + }, + /// One packet type is not currently accepted by the public helper. + UnsupportedPacketType { + /// Index of the sample containing the packet. + sample_index: usize, + /// Byte offset within the sample where the packet started. + offset: usize, + /// Parsed packet type. + packet_type: u32, + }, + /// The leading stream packet was not the required sync packet. + MissingLeadingSyncPacket, + /// The leading sync packet did not use marker `0xA5`. + InvalidLeadingSyncMarker { + /// Marker byte read from the leading sync packet payload. + marker: u8, + }, + /// One frame packet appeared before any configuration packet. + FrameBeforeConfig { + /// Index of the sample containing the frame packet. + sample_index: usize, + /// Byte offset within the sample where the frame packet started. + offset: usize, + }, + /// One truncation packet requested active sample trimming, which is not exported yet. + ActiveTruncationUnsupported { + /// Index of the sample containing the truncation packet. + sample_index: usize, + /// Byte offset within the sample where the truncation packet started. + offset: usize, + }, + /// The supplied samples did not contain any frame packet payloads. + MissingFramePacket, +} + +impl fmt::Display for MhasRewriteError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptySampleList => { + write!( + f, + "MHAS stream export requires at least one packetized sample" + ) + } + Self::TruncatedPacketHeader { + sample_index, + offset, + } => write!( + f, + "MHAS sample {sample_index} ended while reading the packet header at byte offset {offset}" + ), + Self::TruncatedPacketPayload { + sample_index, + offset, + declared_size, + remaining_size, + } => write!( + f, + "MHAS sample {sample_index} declared one {declared_size}-byte packet payload at byte offset {offset}, but only {remaining_size} payload bytes remained" + ), + Self::UnsupportedPacketType { + sample_index, + offset, + packet_type, + } => write!( + f, + "MHAS sample {sample_index} used unsupported packet type {packet_type} at byte offset {offset}" + ), + Self::MissingLeadingSyncPacket => write!( + f, + "MHAS stream export requires the first packet to be the leading sync packet" + ), + Self::InvalidLeadingSyncMarker { marker } => write!( + f, + "MHAS leading sync packet used marker 0x{marker:02X}, but the exported stream requires 0xA5" + ), + Self::FrameBeforeConfig { + sample_index, + offset, + } => write!( + f, + "MHAS sample {sample_index} carried one frame packet before any configuration packet at byte offset {offset}" + ), + Self::ActiveTruncationUnsupported { + sample_index, + offset, + } => write!( + f, + "MHAS sample {sample_index} used one active truncation packet at byte offset {offset}, which is not supported for public stream export" + ), + Self::MissingFramePacket => { + write!(f, "MHAS stream export requires at least one frame packet") + } + } + } +} + +impl Error for MhasRewriteError {} + +/// Rewrites one length-prefixed AVC sample payload into Annex B start-code form. +/// +/// The supplied `sample` is expected to contain one MP4-style length-prefixed access unit that +/// uses the NAL length-field width declared by `avcc`. The returned bytes replace each length +/// field with one four-byte Annex B start code and preserve NAL payload order exactly. +pub fn rewrite_avc_sample_to_annex_b( + sample: &[u8], + avcc: &AVCDecoderConfiguration, +) -> Result, AnnexBRewriteError> { + rewrite_length_prefixed_sample_to_annex_b(sample, avcc_length_field_size(avcc)?, "AVC") +} + +/// Rewrites one length-prefixed HEVC sample payload into Annex B start-code form. +/// +/// The supplied `sample` is expected to contain one MP4-style length-prefixed access unit that +/// uses the NAL length-field width declared by `hvcc`. The returned bytes replace each length +/// field with one four-byte Annex B start code and preserve NAL payload order exactly. +pub fn rewrite_hevc_sample_to_annex_b( + sample: &[u8], + hvcc: &HEVCDecoderConfiguration, +) -> Result, AnnexBRewriteError> { + rewrite_length_prefixed_sample_to_annex_b(sample, hevc_length_field_size(hvcc)?, "HEVC") +} + +/// Rewrites one length-prefixed VVC sample payload into Annex B start-code form. +/// +/// The supplied `sample` is expected to contain one MP4-style length-prefixed access unit that +/// uses the NAL length-field width declared by `vvcc`. The returned bytes replace each length +/// field with one four-byte Annex B start code and preserve NAL payload order exactly. +pub fn rewrite_vvc_sample_to_annex_b( + sample: &[u8], + vvcc: &VVCDecoderConfiguration, +) -> Result, AnnexBRewriteError> { + rewrite_length_prefixed_sample_to_annex_b(sample, vvc_length_field_size(vvcc)?, "VVC") +} + +/// Rewrites one MP4-style AV1 sample payload into one AV1 Annex B temporal unit. +/// +/// The supplied `sample` is expected to contain one MP4-style AV1 sample payload made of +/// Section 5 OBUs with explicit internal size fields. The returned bytes wrap the sample as a +/// single Annex B temporal unit with one frame unit while preserving OBU order exactly. +pub fn rewrite_av1_sample_to_annex_b(sample: &[u8]) -> Result, Av1AnnexBRewriteError> { + if sample.is_empty() { + return Ok(Vec::new()); + } + + let mut frame_unit_payload = Vec::with_capacity(sample.len().saturating_add(16)); + let mut offset = 0usize; + while offset < sample.len() { + let obu_start = offset; + let header = *sample + .get(offset) + .ok_or(Av1AnnexBRewriteError::TruncatedObuHeader { offset })?; + if header >> 7 != 0 { + return Err(Av1AnnexBRewriteError::InvalidObuHeader { + offset, + message: "used a non-zero forbidden bit", + }); + } + if header & 0x01 != 0 { + return Err(Av1AnnexBRewriteError::InvalidObuHeader { + offset, + message: "used a non-zero reserved bit", + }); + } + offset += 1; + + let extension_flag = (header >> 2) & 0x01 != 0; + let has_size_field = (header >> 1) & 0x01 != 0; + if extension_flag { + if sample.get(offset).is_none() { + return Err(Av1AnnexBRewriteError::TruncatedObuHeader { offset }); + } + offset += 1; + } + if !has_size_field { + return Err(Av1AnnexBRewriteError::MissingObuSizeField { offset: obu_start }); + } + let (payload_size, leb_size) = read_leb128(sample, offset)?; + offset += leb_size; + let payload_end = + offset + .checked_add(payload_size) + .ok_or(Av1AnnexBRewriteError::TruncatedObuPayload { + offset, + declared_size: payload_size, + remaining_size: sample.len().saturating_sub(offset), + })?; + if payload_end > sample.len() { + return Err(Av1AnnexBRewriteError::TruncatedObuPayload { + offset, + declared_size: payload_size, + remaining_size: sample.len() - offset, + }); + } + let obu = &sample[obu_start..payload_end]; + frame_unit_payload + .extend_from_slice(&encode_leb128(u32::try_from(obu.len()).unwrap_or(u32::MAX))); + frame_unit_payload.extend_from_slice(obu); + offset = payload_end; + } + + let mut temporal_unit = Vec::with_capacity(frame_unit_payload.len().saturating_add(16)); + temporal_unit.extend_from_slice(&encode_leb128( + u32::try_from(frame_unit_payload.len()).unwrap_or(u32::MAX), + )); + temporal_unit.extend_from_slice(&frame_unit_payload); + + let mut annex_b = Vec::with_capacity(temporal_unit.len().saturating_add(8)); + annex_b.extend_from_slice(&encode_leb128( + u32::try_from(temporal_unit.len()).unwrap_or(u32::MAX), + )); + annex_b.extend_from_slice(&temporal_unit); + Ok(annex_b) +} + +/// Rewrites one raw AAC sample payload into one seven-byte-header ADTS frame. +/// +/// The supplied `audio_specific_config` is expected to contain the standard MPEG-4 AudioSpecificConfig +/// prefix carried by MP4 AAC sample entries. The helper currently exports ADTS for audio object +/// types `1` through `4` only. +pub fn rewrite_aac_sample_to_adts( + sample: &[u8], + audio_specific_config: &[u8], +) -> Result, AdtsRewriteError> { + let Some((&first, rest)) = audio_specific_config.split_first() else { + return Err(AdtsRewriteError::TruncatedAudioSpecificConfig); + }; + let Some(&second) = rest.first() else { + return Err(AdtsRewriteError::TruncatedAudioSpecificConfig); + }; + + let audio_object_type = (first >> 3) & 0x1F; + if !(1..=4).contains(&audio_object_type) { + return Err(AdtsRewriteError::UnsupportedAudioObjectType { audio_object_type }); + } + let sampling_frequency_index = ((first & 0x07) << 1) | ((second >> 7) & 0x01); + if matches!(sampling_frequency_index, 13..=15) { + return Err(AdtsRewriteError::UnsupportedSamplingFrequencyIndex { + sampling_frequency_index, + }); + } + let channel_configuration = (second >> 3) & 0x0F; + if channel_configuration == 0 || channel_configuration > 7 { + return Err(AdtsRewriteError::InvalidChannelConfiguration { + channel_configuration, + }); + } + + let frame_length = sample.len().saturating_add(7); + if frame_length > 0x1FFF { + return Err(AdtsRewriteError::FrameTooLarge { + payload_size: sample.len(), + }); + } + + let profile = audio_object_type - 1; + let mut header = [0_u8; 7]; + header[0] = 0xFF; + header[1] = 0xF1; + header[2] = + (profile << 6) | (sampling_frequency_index << 2) | ((channel_configuration >> 2) & 0x01); + header[3] = + ((channel_configuration & 0x03) << 6) | u8::try_from((frame_length >> 11) & 0x03).unwrap(); + header[4] = u8::try_from((frame_length >> 3) & 0xFF).unwrap(); + header[5] = (u8::try_from(frame_length & 0x07).unwrap() << 5) | 0x1F; + header[6] = 0xFC; + + let mut frame = Vec::with_capacity(frame_length); + frame.extend_from_slice(&header); + frame.extend_from_slice(sample); + Ok(frame) +} + +/// Validates and concatenates packetized MHAS sample payloads back into one elementary stream. +/// +/// The first supplied sample is expected to preserve the required leading sync and configuration +/// packets before the first frame packet. Later samples may contain additional frame or inactive +/// truncation packets. The returned bytes preserve packet order exactly. +pub fn rewrite_mhas_samples_to_stream(samples: &[&[u8]]) -> Result, MhasRewriteError> { + if samples.is_empty() { + return Err(MhasRewriteError::EmptySampleList); + } + + let mut output = Vec::new(); + let mut saw_leading_sync = false; + let mut saw_config = false; + let mut saw_frame = false; + + for (sample_index, sample) in samples.iter().enumerate() { + let mut offset = 0usize; + while offset < sample.len() { + let packet_offset = offset; + let header = parse_mhas_packet_header(sample, &mut offset, sample_index)?; + let payload_end = offset.checked_add(header.payload_size).ok_or( + MhasRewriteError::TruncatedPacketPayload { + sample_index, + offset, + declared_size: header.payload_size, + remaining_size: sample.len().saturating_sub(offset), + }, + )?; + if payload_end > sample.len() { + return Err(MhasRewriteError::TruncatedPacketPayload { + sample_index, + offset, + declared_size: header.payload_size, + remaining_size: sample.len() - offset, + }); + } + + match header.packet_type { + 6 => { + if !saw_leading_sync { + saw_leading_sync = true; + } + if offset == payload_end { + return Err(MhasRewriteError::TruncatedPacketPayload { + sample_index, + offset, + declared_size: 1, + remaining_size: 0, + }); + } + let marker = sample[offset]; + if marker != 0xA5 { + return Err(MhasRewriteError::InvalidLeadingSyncMarker { marker }); + } + } + 1 => { + if !saw_leading_sync { + return Err(MhasRewriteError::MissingLeadingSyncPacket); + } + saw_config = true; + } + 2 => { + if !saw_config { + return Err(MhasRewriteError::FrameBeforeConfig { + sample_index, + offset: packet_offset, + }); + } + saw_frame = true; + } + 17 => { + if !mhas_truncation_packet_is_inactive( + &sample[offset..payload_end], + sample_index, + packet_offset, + )? { + return Err(MhasRewriteError::ActiveTruncationUnsupported { + sample_index, + offset: packet_offset, + }); + } + } + packet_type => { + return Err(MhasRewriteError::UnsupportedPacketType { + sample_index, + offset: packet_offset, + packet_type, + }); + } + } + + output.extend_from_slice(&sample[packet_offset..payload_end]); + offset = payload_end; + } + } + + if !saw_leading_sync { + return Err(MhasRewriteError::MissingLeadingSyncPacket); + } + if !saw_frame { + return Err(MhasRewriteError::MissingFramePacket); + } + Ok(output) +} + +fn avcc_length_field_size(avcc: &AVCDecoderConfiguration) -> Result { + length_field_size_from_minus_one("AVC", avcc.length_size_minus_one) +} + +fn hevc_length_field_size(hvcc: &HEVCDecoderConfiguration) -> Result { + length_field_size_from_minus_one("HEVC", hvcc.length_size_minus_one) +} + +fn vvc_length_field_size(vvcc: &VVCDecoderConfiguration) -> Result { + let Some(&first_byte) = vvcc.decoder_configuration_record.first() else { + return Err(AnnexBRewriteError::MissingConfigurationRecord { codec: "VVC" }); + }; + Ok(usize::from(((first_byte >> 1) & 0x03) + 1)) +} + +fn length_field_size_from_minus_one( + codec: &'static str, + length_size_minus_one: u8, +) -> Result { + if length_size_minus_one > 0x03 { + return Err(AnnexBRewriteError::InvalidLengthFieldWidth { + codec, + width: length_size_minus_one.saturating_add(1), + }); + } + Ok(usize::from(length_size_minus_one) + 1) +} + +fn rewrite_length_prefixed_sample_to_annex_b( + sample: &[u8], + length_field_size: usize, + codec: &'static str, +) -> Result, AnnexBRewriteError> { + if sample.is_empty() { + return Ok(Vec::new()); + } + let mut output = Vec::with_capacity(sample.len().saturating_add(16)); + let mut offset = 0usize; + while offset < sample.len() { + if sample.len() - offset < length_field_size { + return Err(AnnexBRewriteError::TruncatedLengthField { + codec, + offset, + width: length_field_size, + }); + } + let length_offset = offset; + let nal_size = read_length_field( + &sample[offset..offset + length_field_size], + length_field_size, + ); + offset += length_field_size; + if nal_size == 0 { + return Err(AnnexBRewriteError::EmptyNalUnit { + codec, + offset: length_offset, + }); + } + let remaining_size = sample.len() - offset; + if remaining_size < nal_size { + return Err(AnnexBRewriteError::TruncatedNalUnit { + codec, + offset, + declared_size: nal_size, + remaining_size, + }); + } + output.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); + output.extend_from_slice(&sample[offset..offset + nal_size]); + offset += nal_size; + } + Ok(output) +} + +fn read_length_field(field: &[u8], width: usize) -> usize { + match width { + 1 => usize::from(field[0]), + 2 => usize::from(u16::from_be_bytes([field[0], field[1]])), + 3 => (usize::from(field[0]) << 16) | (usize::from(field[1]) << 8) | usize::from(field[2]), + 4 => usize::try_from(u32::from_be_bytes([field[0], field[1], field[2], field[3]])).unwrap(), + _ => unreachable!("validated length field width"), + } +} + +fn read_leb128(bytes: &[u8], offset: usize) -> Result<(usize, usize), Av1AnnexBRewriteError> { + let mut value = 0usize; + let mut shift = 0usize; + for (index, byte) in bytes + .get(offset..) + .unwrap_or_default() + .iter() + .copied() + .enumerate() + { + value |= usize::from(byte & 0x7F) << shift; + if byte & 0x80 == 0 { + return Ok((value, index + 1)); + } + shift += 7; + if shift >= usize::BITS as usize { + break; + } + } + Err(Av1AnnexBRewriteError::TruncatedObuSizeField { offset }) +} + +fn encode_leb128(mut value: u32) -> Vec { + let mut bytes = Vec::new(); + loop { + let mut byte = u8::try_from(value & 0x7F).unwrap(); + value >>= 7; + if value != 0 { + byte |= 0x80; + } + bytes.push(byte); + if value == 0 { + return bytes; + } + } +} + +#[derive(Clone, Copy)] +struct ParsedMhasHeader { + packet_type: u32, + payload_size: usize, +} + +fn parse_mhas_packet_header( + sample: &[u8], + offset: &mut usize, + sample_index: usize, +) -> Result { + let mut cursor = MhasBitCursor::new(sample, *offset, sample_index); + let packet_type = u32::try_from(cursor.read_escaped_value(3, 8, 8)?).unwrap(); + let _label = cursor.read_escaped_value(2, 8, 32)?; + let payload_size = usize::try_from(cursor.read_escaped_value(11, 24, 24)?).unwrap(); + *offset = cursor.bytes_consumed(); + Ok(ParsedMhasHeader { + packet_type, + payload_size, + }) +} + +fn mhas_truncation_packet_is_inactive( + payload: &[u8], + sample_index: usize, + packet_offset: usize, +) -> Result { + let mut cursor = MhasBitCursor::new(payload, 0, sample_index); + let is_active = cursor.read_bool()?; + let _reserved = cursor.read_bool()?; + let _trunc_from_begin = cursor.read_bool()?; + let _trunc_samples = cursor.read_escaped_value(13, 24, 24)?; + if is_active { + return Ok(false); + } + let _ = packet_offset; + Ok(true) +} + +struct MhasBitCursor<'a> { + data: &'a [u8], + bit_offset: usize, + sample_index: usize, +} + +impl<'a> MhasBitCursor<'a> { + fn new(data: &'a [u8], byte_offset: usize, sample_index: usize) -> Self { + Self { + data, + bit_offset: byte_offset.saturating_mul(8), + sample_index, + } + } + + fn bytes_consumed(&self) -> usize { + self.bit_offset.div_ceil(8) + } + + fn read_bits(&mut self, width: usize) -> Result { + let end = + self.bit_offset + .checked_add(width) + .ok_or(MhasRewriteError::TruncatedPacketHeader { + sample_index: self.sample_index, + offset: self.bytes_consumed(), + })?; + if end > self.data.len() * 8 { + return Err(MhasRewriteError::TruncatedPacketHeader { + sample_index: self.sample_index, + offset: self.bytes_consumed(), + }); + } + let mut value = 0_u64; + for _ in 0..width { + let byte = self.data[self.bit_offset / 8]; + let shift = 7 - (self.bit_offset % 8); + value = (value << 1) | u64::from((byte >> shift) & 0x01); + self.bit_offset += 1; + } + Ok(value) + } + + fn read_bool(&mut self) -> Result { + Ok(self.read_bits(1)? != 0) + } + + fn read_escaped_value( + &mut self, + first_width: usize, + escape_width: usize, + final_width: usize, + ) -> Result { + let value = self.read_bits(first_width)?; + let max_first = (1_u64 << first_width) - 1; + if value != max_first { + return Ok(value); + } + let escape = self.read_bits(escape_width)?; + let max_escape = (1_u64 << escape_width) - 1; + if escape != max_escape { + return value + .checked_add(escape) + .ok_or(MhasRewriteError::TruncatedPacketHeader { + sample_index: self.sample_index, + offset: self.bytes_consumed(), + }); + } + let final_value = self.read_bits(final_width)?; + value + .checked_add(escape) + .and_then(|prefix| prefix.checked_add(final_value)) + .ok_or(MhasRewriteError::TruncatedPacketHeader { + sample_index: self.sample_index, + offset: self.bytes_consumed(), + }) + } +} diff --git a/src/mux/sample_reader.rs b/src/mux/sample_reader.rs new file mode 100644 index 0000000..7618425 --- /dev/null +++ b/src/mux/sample_reader.rs @@ -0,0 +1,844 @@ +//! Feature-gated mux sample-reader helpers built on mux plans. +//! +//! This additive surface exposes one-sample-at-a-time readers for callers that want to consume +//! staged sample payloads directly without depending on the crate-private queue layer. The public +//! API stays aligned with the mux plan semantics: callers enable the crate's `mux` feature, bring +//! one [`crate::mux::MuxPlan`], then choose either seekable or progressive readers from +//! [`crate::mux::sample_reader`] depending on the source handles they have. Internally, these +//! readers now walk the mux event graph instead of depending on the older queue-parser stage loop +//! directly. + +use std::collections::BTreeMap; +use std::error::Error; +use std::fmt; +use std::io::{self, Read, Seek, SeekFrom}; + +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +use super::{MuxEventCursor, MuxPlan, MuxSampleEvent, MuxTrackConfig, MuxTrackKind}; +#[cfg(feature = "async")] +use crate::async_io::{AsyncReadForward, AsyncReadSeek}; + +/// Stable metadata for one sample emitted by the planned sample readers. +/// +/// This mirrors the current mux boundary surface intentionally: callers get one sample at a time +/// with both its decode interval and its output payload span, without needing a separate event +/// graph above the staged mux plan. +/// +/// When readers are constructed with companion [`MuxTrackConfig`] values, the metadata also +/// carries stable track identity for the landed text and subtitle paths. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SampleTrackMetadata { + kind: MuxTrackKind, + language: [u8; 3], +} + +impl SampleTrackMetadata { + /// Returns the mux track kind that produced this sample. + pub const fn kind(&self) -> MuxTrackKind { + self.kind + } + + /// Returns the three-letter ISO-639-2 language code carried by this sample's track. + pub const fn language(&self) -> [u8; 3] { + self.language + } +} + +/// Stable metadata for one sample emitted by the planned sample readers. +/// +/// Every reader exposes the staged source and timing fields that come from the mux plan itself. +/// When the reader is constructed with companion [`MuxTrackConfig`] values, the metadata also +/// carries stable per-track identity for mixed audio, text, and subtitle jobs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SampleMetadata { + source_index: usize, + track_id: u32, + track: Option, + decode_time: u64, + composition_time_offset: i32, + duration: u32, + data_offset: u64, + data_size: u32, + output_offset: u64, + is_sync_sample: bool, +} + +impl SampleMetadata { + /// Returns the staged source index that supplies this sample's bytes. + pub const fn source_index(&self) -> usize { + self.source_index + } + + /// Returns the destination track identifier carried by this sample. + pub const fn track_id(&self) -> u32 { + self.track_id + } + + /// Returns stable per-track metadata when the reader was constructed with track configs. + pub const fn track(&self) -> Option { + self.track + } + + /// Returns the normalized decode time used by the plan. + pub const fn decode_time(&self) -> u64 { + self.decode_time + } + + /// Returns the composition-time offset carried by this sample. + pub const fn composition_time_offset(&self) -> i32 { + self.composition_time_offset + } + + /// Returns the decode duration carried by this sample. + pub const fn duration(&self) -> u32 { + self.duration + } + + /// Returns the staged source byte offset for this sample payload. + pub const fn data_offset(&self) -> u64 { + self.data_offset + } + + /// Returns the number of payload bytes described by the plan for this sample. + pub const fn data_size(&self) -> u32 { + self.data_size + } + + /// Returns the output payload offset assigned by the plan. + pub const fn output_offset(&self) -> u64 { + self.output_offset + } + + /// Returns the first byte offset after this sample's payload in the planned output order. + pub const fn output_end_offset(&self) -> u64 { + self.output_offset + self.data_size as u64 + } + + /// Returns the decode end time of this sample on the planned mux timeline. + pub const fn decode_end_time(&self) -> u64 { + self.decode_time + self.duration as u64 + } + + /// Returns whether this sample is marked as a sync sample. + pub const fn is_sync_sample(&self) -> bool { + self.is_sync_sample + } +} + +/// One owned sample payload emitted by a planned sample reader. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SamplePacket { + metadata: SampleMetadata, + bytes: Vec, +} + +impl SamplePacket { + /// Returns the stable metadata associated with this sample payload. + pub const fn metadata(&self) -> &SampleMetadata { + &self.metadata + } + + /// Returns the owned sample bytes. + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// Splits this owned sample into metadata and bytes. + pub fn into_parts(self) -> (SampleMetadata, Vec) { + (self.metadata, self.bytes) + } +} + +/// Errors returned by the planned sample-reader helpers. +#[derive(Debug)] +pub enum SampleReaderError { + /// The planned sample size does not fit in memory on the current platform. + SampleSizeOverflow { size: u64 }, + /// One planned sample referenced a staged source index the caller did not provide. + MissingSourceIndex { + source_index: usize, + source_count: usize, + }, + /// A progressive source would need to seek backward to satisfy the plan. + NonMonotonicSourceOffset { + source_index: usize, + previous_offset: u64, + next_offset: u64, + }, + /// A progressive source ended before it reached the staged offset needed by the next sample. + IncompleteAdvance { + source_index: usize, + expected_offset: u64, + actual_offset: u64, + }, + /// A source ended before it produced the full sample payload. + IncompleteSample { + source_index: usize, + expected_size: u64, + actual_size: u64, + }, + /// An I/O error occurred while reading sample data. + Io(io::Error), +} + +impl fmt::Display for SampleReaderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SampleSizeOverflow { size } => write!( + f, + "planned sample size {size} does not fit in memory on this platform" + ), + Self::MissingSourceIndex { + source_index, + source_count, + } => write!( + f, + "sample plan referenced source index {source_index}, but only {source_count} sources were provided" + ), + Self::NonMonotonicSourceOffset { + source_index, + previous_offset, + next_offset, + } => write!( + f, + "source index {source_index} would need to move backward from offset {previous_offset} to {next_offset}" + ), + Self::IncompleteAdvance { + source_index, + expected_offset, + actual_offset, + } => write!( + f, + "source index {source_index} ended while advancing to offset {expected_offset}; only reached {actual_offset}" + ), + Self::IncompleteSample { + source_index, + expected_size, + actual_size, + } => write!( + f, + "source index {source_index} produced {actual_size} bytes for one sample, expected {expected_size}" + ), + Self::Io(error) => write!(f, "{error}"), + } + } +} + +impl Error for SampleReaderError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Io(error) => Some(error), + _ => None, + } + } +} + +impl From for SampleReaderError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +/// One seekable planned-sample reader. +/// +/// This reader follows the sample order assigned by [`crate::mux::plan_staged_media_items`] and +/// can freely seek inside each staged source as needed. +pub struct PlannedSampleReader<'a, R> { + sources: &'a mut [R], + cursor: MuxEventCursor<'a>, + track_metadata: BTreeMap, +} + +impl<'a, R> PlannedSampleReader<'a, R> +where + R: Read + Seek, +{ + /// Creates one seekable planned-sample reader over the staged `sources` and `plan`. + pub fn new(sources: &'a mut [R], plan: &'a MuxPlan) -> Self { + Self { + sources, + cursor: plan.event_graph().cursor(), + track_metadata: BTreeMap::new(), + } + } + + /// Creates one seekable planned-sample reader with companion track identity metadata. + pub fn new_with_track_configs( + sources: &'a mut [R], + plan: &'a MuxPlan, + track_configs: &[MuxTrackConfig], + ) -> Self { + Self { + sources, + cursor: plan.event_graph().cursor(), + track_metadata: build_track_metadata(track_configs), + } + } + + /// Reads the next sample in planned order. + pub fn next_sample(&mut self) -> Result, SampleReaderError> { + let mut bytes = Vec::new(); + let Some(metadata) = self.next_sample_into(&mut bytes)? else { + return Ok(None); + }; + Ok(Some(SamplePacket { metadata, bytes })) + } + + /// Reads the next sample in planned order into a caller-owned byte buffer. + /// + /// The buffer is cleared and resized to the next sample payload size, allowing callers that + /// process samples one at a time to reuse its allocation across reads. + pub fn next_sample_into( + &mut self, + bytes: &mut Vec, + ) -> Result, SampleReaderError> { + let Some(event) = next_sample_event(&mut self.cursor) else { + return Ok(None); + }; + let staged = event.planned_item().staged(); + let source_count = self.sources.len(); + let Some(source) = self.sources.get_mut(staged.source_index()) else { + return Err(SampleReaderError::MissingSourceIndex { + source_index: staged.source_index(), + source_count, + }); + }; + + source.seek(SeekFrom::Start(staged.data_offset()))?; + read_sample_bytes_into( + source, + staged.source_index(), + u64::from(staged.data_size()), + bytes, + )?; + Ok(Some(metadata_from_sample_event( + event, + &self.track_metadata, + ))) + } +} + +/// One progressive planned-sample reader for forward-only sync sources. +/// +/// This reader supports only plans whose staged items consume each source in monotonic byte-offset +/// order. +pub struct ProgressiveSampleReader<'a, R> { + sources: &'a mut [R], + cursor: MuxEventCursor<'a>, + track_metadata: BTreeMap, + source_offsets: Vec, + advance_buffer: Vec, +} + +impl<'a, R> ProgressiveSampleReader<'a, R> +where + R: Read, +{ + /// Creates one progressive planned-sample reader over forward-only sync `sources`. + pub fn new(sources: &'a mut [R], plan: &'a MuxPlan) -> Self { + Self { + source_offsets: vec![0_u64; sources.len()], + sources, + cursor: plan.event_graph().cursor(), + track_metadata: BTreeMap::new(), + advance_buffer: vec![0_u8; 16 * 1024], + } + } + + /// Creates one progressive planned-sample reader with companion track identity metadata. + pub fn new_with_track_configs( + sources: &'a mut [R], + plan: &'a MuxPlan, + track_configs: &[MuxTrackConfig], + ) -> Self { + Self { + source_offsets: vec![0_u64; sources.len()], + sources, + cursor: plan.event_graph().cursor(), + track_metadata: build_track_metadata(track_configs), + advance_buffer: vec![0_u8; 16 * 1024], + } + } + + /// Reads the next sample in planned order. + pub fn next_sample(&mut self) -> Result, SampleReaderError> { + let mut bytes = Vec::new(); + let Some(metadata) = self.next_sample_into(&mut bytes)? else { + return Ok(None); + }; + Ok(Some(SamplePacket { metadata, bytes })) + } + + /// Reads the next sample in planned order into a caller-owned byte buffer. + /// + /// The buffer is cleared and resized to the next sample payload size, allowing forward-only + /// consumers to reuse storage while the reader advances each source monotonically. + pub fn next_sample_into( + &mut self, + bytes: &mut Vec, + ) -> Result, SampleReaderError> { + let Some(event) = next_sample_event(&mut self.cursor) else { + return Ok(None); + }; + let staged = event.planned_item().staged(); + let source_count = self.sources.len(); + let Some(source) = self.sources.get_mut(staged.source_index()) else { + return Err(SampleReaderError::MissingSourceIndex { + source_index: staged.source_index(), + source_count, + }); + }; + + let source_offset = self.source_offsets.get_mut(staged.source_index()).unwrap(); + advance_progressive_source( + source, + staged.source_index(), + source_offset, + staged.data_offset(), + &mut self.advance_buffer, + )?; + read_progressive_sample_into( + source, + staged.source_index(), + source_offset, + u64::from(staged.data_size()), + bytes, + )?; + Ok(Some(metadata_from_sample_event( + event, + &self.track_metadata, + ))) + } +} + +/// One seekable async planned-sample reader. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(all(feature = "mux", feature = "async"))))] +pub struct AsyncPlannedSampleReader<'a, R> { + sources: &'a mut [R], + cursor: MuxEventCursor<'a>, + track_metadata: BTreeMap, +} + +#[cfg(feature = "async")] +impl<'a, R> AsyncPlannedSampleReader<'a, R> +where + R: AsyncReadSeek, +{ + /// Creates one seekable async planned-sample reader over `sources` and `plan`. + pub fn new(sources: &'a mut [R], plan: &'a MuxPlan) -> Self { + Self { + sources, + cursor: plan.event_graph().cursor(), + track_metadata: BTreeMap::new(), + } + } + + /// Creates one seekable async planned-sample reader with companion track identity metadata. + pub fn new_with_track_configs( + sources: &'a mut [R], + plan: &'a MuxPlan, + track_configs: &[MuxTrackConfig], + ) -> Self { + Self { + sources, + cursor: plan.event_graph().cursor(), + track_metadata: build_track_metadata(track_configs), + } + } + + /// Reads the next sample in planned order. + pub async fn next_sample(&mut self) -> Result, SampleReaderError> { + let mut bytes = Vec::new(); + let Some(metadata) = self.next_sample_into(&mut bytes).await? else { + return Ok(None); + }; + Ok(Some(SamplePacket { metadata, bytes })) + } + + /// Reads the next sample in planned order into a caller-owned byte buffer. + /// + /// The buffer is cleared and resized to the next sample payload size, allowing async callers + /// that process samples one at a time to reuse its allocation across awaits. + pub async fn next_sample_into( + &mut self, + bytes: &mut Vec, + ) -> Result, SampleReaderError> { + let Some(event) = next_sample_event(&mut self.cursor) else { + return Ok(None); + }; + let staged = event.planned_item().staged(); + let source_count = self.sources.len(); + let Some(source) = self.sources.get_mut(staged.source_index()) else { + return Err(SampleReaderError::MissingSourceIndex { + source_index: staged.source_index(), + source_count, + }); + }; + + source.seek(SeekFrom::Start(staged.data_offset())).await?; + read_sample_bytes_into_async( + source, + staged.source_index(), + u64::from(staged.data_size()), + bytes, + ) + .await?; + Ok(Some(metadata_from_sample_event( + event, + &self.track_metadata, + ))) + } +} + +/// One progressive async planned-sample reader for forward-only sources. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(all(feature = "mux", feature = "async"))))] +pub struct AsyncProgressiveSampleReader<'a, R> { + sources: &'a mut [R], + cursor: MuxEventCursor<'a>, + track_metadata: BTreeMap, + source_offsets: Vec, + advance_buffer: Vec, +} + +#[cfg(feature = "async")] +impl<'a, R> AsyncProgressiveSampleReader<'a, R> +where + R: AsyncReadForward, +{ + /// Creates one progressive async planned-sample reader over forward-only sources. + pub fn new(sources: &'a mut [R], plan: &'a MuxPlan) -> Self { + Self { + source_offsets: vec![0_u64; sources.len()], + sources, + cursor: plan.event_graph().cursor(), + track_metadata: BTreeMap::new(), + advance_buffer: vec![0_u8; 16 * 1024], + } + } + + /// Creates one progressive async planned-sample reader with companion track identity metadata. + pub fn new_with_track_configs( + sources: &'a mut [R], + plan: &'a MuxPlan, + track_configs: &[MuxTrackConfig], + ) -> Self { + Self { + source_offsets: vec![0_u64; sources.len()], + sources, + cursor: plan.event_graph().cursor(), + track_metadata: build_track_metadata(track_configs), + advance_buffer: vec![0_u8; 16 * 1024], + } + } + + /// Reads the next sample in planned order. + pub async fn next_sample(&mut self) -> Result, SampleReaderError> { + let mut bytes = Vec::new(); + let Some(metadata) = self.next_sample_into(&mut bytes).await? else { + return Ok(None); + }; + Ok(Some(SamplePacket { metadata, bytes })) + } + + /// Reads the next sample in planned order into a caller-owned byte buffer. + /// + /// The buffer is cleared and resized to the next sample payload size, allowing forward-only + /// async consumers to reuse storage while each source advances monotonically. + pub async fn next_sample_into( + &mut self, + bytes: &mut Vec, + ) -> Result, SampleReaderError> { + let Some(event) = next_sample_event(&mut self.cursor) else { + return Ok(None); + }; + let staged = event.planned_item().staged(); + let source_count = self.sources.len(); + let Some(source) = self.sources.get_mut(staged.source_index()) else { + return Err(SampleReaderError::MissingSourceIndex { + source_index: staged.source_index(), + source_count, + }); + }; + + let source_offset = self.source_offsets.get_mut(staged.source_index()).unwrap(); + advance_progressive_source_async( + source, + staged.source_index(), + source_offset, + staged.data_offset(), + &mut self.advance_buffer, + ) + .await?; + read_progressive_sample_into_async( + source, + staged.source_index(), + source_offset, + u64::from(staged.data_size()), + bytes, + ) + .await?; + Ok(Some(metadata_from_sample_event( + event, + &self.track_metadata, + ))) + } +} + +fn next_sample_event<'a>(cursor: &mut MuxEventCursor<'a>) -> Option<&'a MuxSampleEvent> { + cursor.next_sample() +} + +fn build_track_metadata(track_configs: &[MuxTrackConfig]) -> BTreeMap { + track_configs + .iter() + .map(|track| { + ( + track.track_id(), + SampleTrackMetadata { + kind: track.kind(), + language: track.language(), + }, + ) + }) + .collect() +} + +fn metadata_from_sample_event( + event: &MuxSampleEvent, + track_metadata: &BTreeMap, +) -> SampleMetadata { + let staged = event.planned_item().staged(); + SampleMetadata { + source_index: staged.source_index(), + track_id: staged.track_id(), + track: track_metadata.get(&staged.track_id()).copied(), + decode_time: staged.decode_time(), + composition_time_offset: staged.composition_time_offset(), + duration: staged.duration(), + data_offset: staged.data_offset(), + data_size: staged.data_size(), + output_offset: event.planned_item().output_offset(), + is_sync_sample: staged.is_sync_sample(), + } +} + +fn read_sample_bytes_into( + source: &mut R, + source_index: usize, + size: u64, + bytes: &mut Vec, +) -> Result<(), SampleReaderError> +where + R: Read, +{ + let len = usize::try_from(size).map_err(|_| SampleReaderError::SampleSizeOverflow { size })?; + bytes.clear(); + bytes.resize(len, 0); + let mut copied = 0_usize; + while copied < len { + let read = match source.read(&mut bytes[copied..]) { + Ok(read) => read, + Err(error) => { + bytes.truncate(copied); + return Err(SampleReaderError::Io(error)); + } + }; + if read == 0 { + bytes.truncate(copied); + return Err(SampleReaderError::IncompleteSample { + source_index, + expected_size: size, + actual_size: copied as u64, + }); + } + copied += read; + } + Ok(()) +} + +fn advance_progressive_source( + source: &mut R, + source_index: usize, + current_offset: &mut u64, + target_offset: u64, + buffer: &mut [u8], +) -> Result<(), SampleReaderError> +where + R: Read, +{ + if target_offset < *current_offset { + return Err(SampleReaderError::NonMonotonicSourceOffset { + source_index, + previous_offset: *current_offset, + next_offset: target_offset, + }); + } + + let mut remaining = target_offset - *current_offset; + while remaining > 0 { + let chunk_len = remaining.min(buffer.len() as u64) as usize; + let read = source.read(&mut buffer[..chunk_len])?; + if read == 0 { + return Err(SampleReaderError::IncompleteAdvance { + source_index, + expected_offset: target_offset, + actual_offset: *current_offset, + }); + } + *current_offset += read as u64; + remaining -= read as u64; + } + Ok(()) +} + +fn read_progressive_sample_into( + source: &mut R, + source_index: usize, + current_offset: &mut u64, + size: u64, + bytes: &mut Vec, +) -> Result<(), SampleReaderError> +where + R: Read, +{ + let len = usize::try_from(size).map_err(|_| SampleReaderError::SampleSizeOverflow { size })?; + bytes.clear(); + bytes.resize(len, 0); + let mut copied = 0_usize; + while copied < len { + let read = match source.read(&mut bytes[copied..]) { + Ok(read) => read, + Err(error) => { + bytes.truncate(copied); + return Err(SampleReaderError::Io(error)); + } + }; + if read == 0 { + bytes.truncate(copied); + return Err(SampleReaderError::IncompleteSample { + source_index, + expected_size: size, + actual_size: copied as u64, + }); + } + copied += read; + } + *current_offset = current_offset + .checked_add(size) + .ok_or(SampleReaderError::SampleSizeOverflow { size })?; + Ok(()) +} + +#[cfg(feature = "async")] +async fn read_sample_bytes_into_async( + source: &mut R, + source_index: usize, + size: u64, + bytes: &mut Vec, +) -> Result<(), SampleReaderError> +where + R: AsyncReadForward, +{ + let len = usize::try_from(size).map_err(|_| SampleReaderError::SampleSizeOverflow { size })?; + bytes.clear(); + bytes.resize(len, 0); + let mut copied = 0_usize; + while copied < len { + let read = match source.read(&mut bytes[copied..]).await { + Ok(read) => read, + Err(error) => { + bytes.truncate(copied); + return Err(SampleReaderError::Io(error)); + } + }; + if read == 0 { + bytes.truncate(copied); + return Err(SampleReaderError::IncompleteSample { + source_index, + expected_size: size, + actual_size: copied as u64, + }); + } + copied += read; + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn advance_progressive_source_async( + source: &mut R, + source_index: usize, + current_offset: &mut u64, + target_offset: u64, + buffer: &mut [u8], +) -> Result<(), SampleReaderError> +where + R: AsyncReadForward, +{ + if target_offset < *current_offset { + return Err(SampleReaderError::NonMonotonicSourceOffset { + source_index, + previous_offset: *current_offset, + next_offset: target_offset, + }); + } + + let mut remaining = target_offset - *current_offset; + while remaining > 0 { + let chunk_len = remaining.min(buffer.len() as u64) as usize; + let read = source.read(&mut buffer[..chunk_len]).await?; + if read == 0 { + return Err(SampleReaderError::IncompleteAdvance { + source_index, + expected_offset: target_offset, + actual_offset: *current_offset, + }); + } + *current_offset += read as u64; + remaining -= read as u64; + } + Ok(()) +} + +#[cfg(feature = "async")] +async fn read_progressive_sample_into_async( + source: &mut R, + source_index: usize, + current_offset: &mut u64, + size: u64, + bytes: &mut Vec, +) -> Result<(), SampleReaderError> +where + R: AsyncReadForward, +{ + let len = usize::try_from(size).map_err(|_| SampleReaderError::SampleSizeOverflow { size })?; + bytes.clear(); + bytes.resize(len, 0); + let mut copied = 0_usize; + while copied < len { + let read = match source.read(&mut bytes[copied..]).await { + Ok(read) => read, + Err(error) => { + bytes.truncate(copied); + return Err(SampleReaderError::Io(error)); + } + }; + if read == 0 { + bytes.truncate(copied); + return Err(SampleReaderError::IncompleteSample { + source_index, + expected_size: size, + actual_size: copied as u64, + }); + } + copied += read; + } + *current_offset = current_offset + .checked_add(size) + .ok_or(SampleReaderError::SampleSizeOverflow { size })?; + Ok(()) +} diff --git a/src/probe.rs b/src/probe.rs index 450dfdf..1532903 100644 --- a/src/probe.rs +++ b/src/probe.rs @@ -1,6 +1,7 @@ //! File-summary helpers built on the extraction and box layers, with byte-slice convenience entry //! points for in-memory probe flows. +use std::collections::BTreeMap; use std::error::Error; use std::fmt; use std::io::{self, Cursor, Read, Seek, SeekFrom}; @@ -14,9 +15,9 @@ use crate::boxes::av1::AV1CodecConfiguration; use crate::boxes::etsi_ts_102_366::Dac3; use crate::boxes::iso14496_12::{ AVCDecoderConfiguration, AudioSampleEntry, Btrt, Clap, Co64, CoLL, Colr, Ctts, Elng, - EventMessageSampleEntry, Fiel, HEVCDecoderConfiguration, Mvhd, Pasp, SmDm, Stco, Stsc, Stsz, - Stts, TextSubtitleSampleEntry, Tfdt, Tfhd, Tkhd, Trun, VisualSampleEntry, - XMLSubtitleSampleEntry, + EventMessageSampleEntry, Fiel, GenericMediaSampleEntry, HEVCDecoderConfiguration, Mvhd, Pasp, + SmDm, Stco, Stsc, Stsz, Stts, TextSubtitleSampleEntry, Tfdt, Tfhd, Tkhd, Trun, + VisualSampleEntry, XMLSubtitleSampleEntry, }; use crate::boxes::iso14496_12::{Frma, Hdlr, Schm}; use crate::boxes::iso14496_14::Esds; @@ -24,17 +25,24 @@ use crate::boxes::iso14496_30::{WebVTTConfigurationBox, WebVTTSourceLabelBox}; use crate::boxes::iso23001_5::PcmC; use crate::boxes::opus::DOps; use crate::boxes::vp::VpCodecConfiguration; +#[cfg(feature = "async")] +use crate::codec::unmarshal_async; use crate::codec::{CodecBox, CodecError, ImmutableBox, unmarshal}; use crate::extract::{ExtractError, ExtractedBox, extract_boxes, extract_boxes_with_payload}; #[cfg(feature = "async")] use crate::extract::{extract_boxes_async, extract_boxes_with_payload_async}; use crate::header::HeaderError; use crate::walk::BoxPath; +use miniz_oxide::inflate::decompress_to_vec_zlib; #[cfg(feature = "async")] use tokio::io::{AsyncReadExt, AsyncSeekExt}; const FTYP: FourCc = FourCc::from_bytes(*b"ftyp"); const MOOV: FourCc = FourCc::from_bytes(*b"moov"); +const CMOV: FourCc = FourCc::from_bytes(*b"cmov"); +const DCOM: FourCc = FourCc::from_bytes(*b"dcom"); +const CMVD: FourCc = FourCc::from_bytes(*b"cmvd"); +const ZLIB: FourCc = FourCc::from_bytes(*b"zlib"); const MVHD: FourCc = FourCc::from_bytes(*b"mvhd"); const TRAK: FourCc = FourCc::from_bytes(*b"trak"); const MOOF: FourCc = FourCc::from_bytes(*b"moof"); @@ -51,6 +59,8 @@ const STBL: FourCc = FourCc::from_bytes(*b"stbl"); const STSD: FourCc = FourCc::from_bytes(*b"stsd"); const AVC1: FourCc = FourCc::from_bytes(*b"avc1"); const AVCC: FourCc = FourCc::from_bytes(*b"avcC"); +const DVHE: FourCc = FourCc::from_bytes(*b"dvhe"); +const DVH1: FourCc = FourCc::from_bytes(*b"dvh1"); const HEV1: FourCc = FourCc::from_bytes(*b"hev1"); const HVC1: FourCc = FourCc::from_bytes(*b"hvc1"); const HVCC: FourCc = FourCc::from_bytes(*b"hvcC"); @@ -63,7 +73,17 @@ const AV01: FourCc = FourCc::from_bytes(*b"av01"); const AV1C: FourCc = FourCc::from_bytes(*b"av1C"); const VP08: FourCc = FourCc::from_bytes(*b"vp08"); const VP09: FourCc = FourCc::from_bytes(*b"vp09"); +const VP10: FourCc = FourCc::from_bytes(*b"vp10"); const VPCC: FourCc = FourCc::from_bytes(*b"vpcC"); +const DIV3_ENTRY: FourCc = FourCc::from_bytes(*b"DIV3"); +const DIV4_ENTRY: FourCc = FourCc::from_bytes(*b"DIV4"); +const BGR3_ENTRY: FourCc = FourCc::from_bytes(*b"BGR3"); +const H263_ENTRY_ALIAS: FourCc = FourCc::from_bytes(*b"H263"); +const JPEG_ENTRY: FourCc = FourCc::from_bytes(*b"jpeg"); +const MJPG_ENTRY_ALIAS: FourCc = FourCc::from_bytes(*b"MJPG"); +const MPEG_ENTRY: FourCc = FourCc::from_bytes(*b"MPEG"); +const PNG_ENTRY: FourCc = FourCc::from_bytes(*b"png "); +const PNG_ENTRY_ALIAS: FourCc = FourCc::from_bytes(*b"PNG "); const ENCV: FourCc = FourCc::from_bytes(*b"encv"); const BTRT: FourCc = FourCc::from_bytes(*b"btrt"); const CLAP: FourCc = FourCc::from_bytes(*b"clap"); @@ -73,7 +93,19 @@ const FIEL: FourCc = FourCc::from_bytes(*b"fiel"); const PASP: FourCc = FourCc::from_bytes(*b"pasp"); const SMDM: FourCc = FourCc::from_bytes(*b"SmDm"); const MP4A: FourCc = FourCc::from_bytes(*b"mp4a"); +const MP4V: FourCc = FourCc::from_bytes(*b"mp4v"); +const DOT_MP3: FourCc = FourCc::from_bytes(*b".mp3"); +const ALAW: FourCc = FourCc::from_bytes(*b"alaw"); +const MLAW: FourCc = FourCc::from_bytes(*b"MLAW"); const OPUS: FourCc = FourCc::from_bytes(*b"Opus"); +const SPEX: FourCc = FourCc::from_bytes(*b"spex"); +const SAMR: FourCc = FourCc::from_bytes(*b"samr"); +const SAWB: FourCc = FourCc::from_bytes(*b"sawb"); +const SQCP: FourCc = FourCc::from_bytes(*b"sqcp"); +const SEVC: FourCc = FourCc::from_bytes(*b"sevc"); +const SSMV: FourCc = FourCc::from_bytes(*b"ssmv"); +const ULAW: FourCc = FourCc::from_bytes(*b"ulaw"); +const S263: FourCc = FourCc::from_bytes(*b"s263"); const DOPS: FourCc = FourCc::from_bytes(*b"dOps"); const AC_3: FourCc = FourCc::from_bytes(*b"ac-3"); const EC_3: FourCc = FourCc::from_bytes(*b"ec-3"); @@ -81,8 +113,19 @@ const DAC3: FourCc = FourCc::from_bytes(*b"dac3"); const DEC3: FourCc = FourCc::from_bytes(*b"dec3"); const AC_4: FourCc = FourCc::from_bytes(*b"ac-4"); const DAC4: FourCc = FourCc::from_bytes(*b"dac4"); +const ALAC: FourCc = FourCc::from_bytes(*b"alac"); +const MLPA: FourCc = FourCc::from_bytes(*b"mlpa"); +const DTSC: FourCc = FourCc::from_bytes(*b"dtsc"); +const DTSE: FourCc = FourCc::from_bytes(*b"dtse"); +const DTSH: FourCc = FourCc::from_bytes(*b"dtsh"); +const DTSL: FourCc = FourCc::from_bytes(*b"dtsl"); +const DTSM: FourCc = FourCc::from_bytes(*b"dtsm"); +const DTS_MINUS: FourCc = FourCc::from_bytes(*b"dts-"); +const DTSX: FourCc = FourCc::from_bytes(*b"dtsx"); +const DTSY: FourCc = FourCc::from_bytes(*b"dtsy"); const FLAC: FourCc = FourCc::from_bytes(*b"fLaC"); const DFLA: FourCc = FourCc::from_bytes(*b"dfLa"); +const IAMF: FourCc = FourCc::from_bytes(*b"iamf"); const MHA1: FourCc = FourCc::from_bytes(*b"mha1"); const MHA2: FourCc = FourCc::from_bytes(*b"mha2"); const MHM1: FourCc = FourCc::from_bytes(*b"mhm1"); @@ -94,6 +137,9 @@ const PCMC: FourCc = FourCc::from_bytes(*b"pcmC"); const WAVE: FourCc = FourCc::from_bytes(*b"wave"); const ESDS: FourCc = FourCc::from_bytes(*b"esds"); const ENCA: FourCc = FourCc::from_bytes(*b"enca"); +const DVBS: FourCc = FourCc::from_bytes(*b"dvbs"); +const DVBT: FourCc = FourCc::from_bytes(*b"dvbt"); +const MP4S: FourCc = FourCc::from_bytes(*b"mp4s"); const STPP: FourCc = FourCc::from_bytes(*b"stpp"); const SBTT: FourCc = FourCc::from_bytes(*b"sbtt"); const WVTT: FourCc = FourCc::from_bytes(*b"wvtt"); @@ -908,8 +954,8 @@ pub enum TrackCodecFamily { /// Returns the additive codec-family label used by detailed reporting. /// /// The stable [`TrackCodecFamily`] enum intentionally keeps its current shape. Newer sample-entry -/// families that do not yet warrant an enum expansion still surface here through their -/// sample-entry or protected original-format box type. +/// families that would otherwise require a breaking enum expansion surface here through their +/// sample-entry or protected original-format box type instead. pub fn normalized_codec_family_name( codec_family: TrackCodecFamily, sample_entry_type: Option, @@ -917,9 +963,35 @@ pub fn normalized_codec_family_name( ) -> &'static str { match codec_family { TrackCodecFamily::Unknown => match original_format.or(sample_entry_type) { + Some(VVC1 | VVI1) => "vvc", Some(AVS3) => "avs3", + Some(EC_3) => "eac3", + Some(AC_4) => "ac4", + Some(ALAC) => "alac", + Some(DOT_MP3) => "mp3", + Some(SPEX) => "speex", + Some(SAMR) => "amr", + Some(SAWB) => "amr_wb", + Some(SQCP) => "qcelp", + Some(SEVC) => "evrc", + Some(SSMV) => "smv", + Some(MLPA) => "truehd", + Some(DTSC | DTSE | DTSH | DTSL | DTSM | DTS_MINUS | DTSX | DTSY) => "dts", Some(FLAC) => "flac", + Some(IAMF) => "iamf", Some(MHA1 | MHA2 | MHM1 | MHM2) => "mpeg_h", + Some(JPEG_ENTRY | MJPG_ENTRY_ALIAS) => "jpeg", + Some(S263 | H263_ENTRY_ALIAS) => "h263", + Some(MPEG_ENTRY) => "mpeg2_video", + Some(MP4V) => "mpeg4_visual", + Some(PNG_ENTRY | PNG_ENTRY_ALIAS) => "png", + Some(VP10) => "vp10", + Some(DVBS) => "dvb_subtitle", + Some(DVBT) => "dvb_teletext", + Some(MP4S) => "subpicture", + Some(STPP) => "xml_subtitle", + Some(SBTT) => "text_subtitle", + Some(WVTT) => "webvtt", _ => "unknown", }, TrackCodecFamily::Avc => "avc", @@ -1044,6 +1116,22 @@ pub struct SegmentInfo { pub size: u32, } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct ParsedMoofSegment { + summary: SegmentInfo, + zero_duration_sample_count: u32, + sample_durations: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct FragmentedTrackWarningDiagnostics { + pub zero_duration_sample_count: u64, + pub sample_duration_change_count: u64, + pub min_non_zero_sample_duration: Option, + pub max_non_zero_sample_duration: Option, + last_non_zero_sample_duration: Option, +} + /// Probes a file and returns the backwards-compatible coarse movie, track, and fragment summary. /// /// For richer sample-entry, handler, language, and protection metadata, use [`probe_detailed`]. @@ -1175,7 +1263,16 @@ where R: Read + Seek, { let paths = root_probe_box_paths(options); - let infos = extract_boxes(reader, None, &paths)?; + let infos = match extract_boxes(reader, None, &paths) { + Ok(infos) => infos, + Err(error) => { + if let Some(root_bytes) = extract_compressed_movie_root_bytes_sync(reader)? { + let mut cursor = Cursor::new(root_bytes); + return probe_codec_detailed_with_options(&mut cursor, options); + } + return Err(error.into()); + } + }; let mut summary = CodecDetailedProbeInfo::default(); let mut mdat_appeared = false; @@ -1211,9 +1308,430 @@ where } } + if (summary.tracks.is_empty() || summary.timescale == 0) + && let Some(root_bytes) = extract_compressed_movie_root_bytes_sync(reader)? + { + let mut cursor = Cursor::new(root_bytes); + let fallback = probe_codec_detailed_with_options(&mut cursor, options)?; + if !fallback.tracks.is_empty() || fallback.timescale != 0 { + return Ok(fallback); + } + } + Ok(summary) } +pub(crate) fn extract_compressed_movie_root_bytes_sync( + reader: &mut R, +) -> Result>, ProbeError> +where + R: Read + Seek, +{ + let ftyp_bytes = extract_root_box_bytes_sync(reader, FTYP)?; + let Some(moov_info) = find_root_box_info_sync(reader, MOOV)? else { + return Ok(None); + }; + let Some(decoded_moov_box_bytes) = + decode_compressed_movie_moov_box_bytes_sync(reader, moov_info)? + else { + return Ok(None); + }; + + let mut root_bytes = + Vec::with_capacity(ftyp_bytes.as_ref().map_or(0, Vec::len) + decoded_moov_box_bytes.len()); + if let Some(ftyp_box_bytes) = ftyp_bytes { + root_bytes.extend_from_slice(&ftyp_box_bytes); + } + root_bytes.extend_from_slice(&decoded_moov_box_bytes); + Ok(Some(root_bytes)) +} + +#[cfg(feature = "async")] +pub(crate) async fn extract_compressed_movie_root_bytes_async( + reader: &mut R, +) -> Result>, ProbeError> +where + R: AsyncReadSeek, +{ + let ftyp_bytes = extract_root_box_bytes_async(reader, FTYP).await?; + let Some(moov_info) = find_root_box_info_async(reader, MOOV).await? else { + return Ok(None); + }; + let Some(decoded_moov_box_bytes) = + decode_compressed_movie_moov_box_bytes_async(reader, moov_info).await? + else { + return Ok(None); + }; + + let mut root_bytes = + Vec::with_capacity(ftyp_bytes.as_ref().map_or(0, Vec::len) + decoded_moov_box_bytes.len()); + if let Some(ftyp_box_bytes) = ftyp_bytes { + root_bytes.extend_from_slice(&ftyp_box_bytes); + } + root_bytes.extend_from_slice(&decoded_moov_box_bytes); + Ok(Some(root_bytes)) +} + +fn decode_compressed_movie_moov_box_bytes_sync( + reader: &mut R, + moov_info: BoxInfo, +) -> Result>, ProbeError> +where + R: Read + Seek, +{ + let Some(cmov_info) = find_child_box_info_sync(reader, moov_info, CMOV)? else { + return Ok(None); + }; + let Some(dcom_payload) = read_child_box_payload_bytes_sync(reader, cmov_info, DCOM)? else { + return Err(ProbeError::MissingRequiredBox("dcom")); + }; + if dcom_payload.as_slice() != ZLIB.as_bytes() { + return Err(ProbeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "unsupported compressed movie method `{}`", + String::from_utf8_lossy(&dcom_payload) + ), + ))); + } + let Some(cmvd_payload) = read_child_box_payload_bytes_sync(reader, cmov_info, CMVD)? else { + return Err(ProbeError::MissingRequiredBox("cmvd")); + }; + decode_compressed_movie_cmvd_payload(&cmvd_payload) +} + +#[cfg(feature = "async")] +async fn decode_compressed_movie_moov_box_bytes_async( + reader: &mut R, + moov_info: BoxInfo, +) -> Result>, ProbeError> +where + R: AsyncReadSeek, +{ + let Some(cmov_info) = find_child_box_info_async(reader, moov_info, CMOV).await? else { + return Ok(None); + }; + let Some(dcom_payload) = read_child_box_payload_bytes_async(reader, cmov_info, DCOM).await? + else { + return Err(ProbeError::MissingRequiredBox("dcom")); + }; + if dcom_payload.as_slice() != ZLIB.as_bytes() { + return Err(ProbeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "unsupported compressed movie method `{}`", + String::from_utf8_lossy(&dcom_payload) + ), + ))); + } + let Some(cmvd_payload) = read_child_box_payload_bytes_async(reader, cmov_info, CMVD).await? + else { + return Err(ProbeError::MissingRequiredBox("cmvd")); + }; + decode_compressed_movie_cmvd_payload(&cmvd_payload) +} + +fn decode_compressed_movie_cmvd_payload( + cmvd_payload: &[u8], +) -> Result>, ProbeError> { + if cmvd_payload.len() < 4 { + return Err(ProbeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + "compressed movie payload is truncated before the encoded size field", + ))); + } + + let declared_len = u32::from_be_bytes(cmvd_payload[..4].try_into().unwrap()); + let decompressed = decompress_to_vec_zlib(&cmvd_payload[4..]).map_err(|error| { + ProbeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!("failed to inflate compressed movie payload: {error:?}"), + )) + })?; + if decompressed.len() != usize::try_from(declared_len).unwrap_or(usize::MAX) { + return Err(ProbeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "compressed movie payload declared {} bytes but inflated to {} bytes", + declared_len, + decompressed.len() + ), + ))); + } + + let mut inflated_cursor = Cursor::new(decompressed.as_slice()); + let inflated_moov_info = BoxInfo::read(&mut inflated_cursor)?; + if inflated_moov_info.box_type() != MOOV { + return Err(ProbeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + "inflated compressed movie payload did not yield a moov box", + ))); + } + + Ok(Some(decompressed)) +} + +fn find_root_box_info_sync( + reader: &mut R, + box_type: FourCc, +) -> Result, ProbeError> +where + R: Read + Seek, +{ + reader.seek(SeekFrom::Start(0))?; + loop { + let start = reader.stream_position()?; + let info = match BoxInfo::read(reader) { + Ok(info) => info, + Err(HeaderError::Io(error)) if error.kind() == io::ErrorKind::UnexpectedEof => { + reader.seek(SeekFrom::Start(start))?; + return Ok(None); + } + Err(error) => return Err(error.into()), + }; + if info.box_type() == box_type { + return Ok(Some(info)); + } + info.seek_to_end(reader)?; + } +} + +#[cfg(feature = "async")] +async fn find_root_box_info_async( + reader: &mut R, + box_type: FourCc, +) -> Result, ProbeError> +where + R: AsyncReadSeek, +{ + reader.seek(SeekFrom::Start(0)).await?; + loop { + let start = reader.stream_position().await?; + let info = match BoxInfo::read_async(reader).await { + Ok(info) => info, + Err(HeaderError::Io(error)) if error.kind() == io::ErrorKind::UnexpectedEof => { + reader.seek(SeekFrom::Start(start)).await?; + return Ok(None); + } + Err(error) => return Err(error.into()), + }; + if info.box_type() == box_type { + return Ok(Some(info)); + } + info.seek_to_end_async(reader).await?; + } +} + +fn extract_root_box_bytes_sync( + reader: &mut R, + box_type: FourCc, +) -> Result>, ProbeError> +where + R: Read + Seek, +{ + let Some(info) = find_root_box_info_sync(reader, box_type)? else { + return Ok(None); + }; + validate_box_fits_stream_sync(reader, info, "root box")?; + info.seek_to_start(reader)?; + let mut bytes = vec![ + 0_u8; + usize::try_from(info.size()).map_err(|_| ProbeError::NumericOverflow { + field_name: "box size", + })? + ]; + reader.read_exact(&mut bytes)?; + Ok(Some(bytes)) +} + +#[cfg(feature = "async")] +async fn extract_root_box_bytes_async( + reader: &mut R, + box_type: FourCc, +) -> Result>, ProbeError> +where + R: AsyncReadSeek, +{ + let Some(info) = find_root_box_info_async(reader, box_type).await? else { + return Ok(None); + }; + validate_box_fits_stream_async(reader, info, "root box").await?; + info.seek_to_start_async(reader).await?; + let mut bytes = vec![ + 0_u8; + usize::try_from(info.size()).map_err(|_| ProbeError::NumericOverflow { + field_name: "box size", + })? + ]; + tokio::io::AsyncReadExt::read_exact(reader, &mut bytes).await?; + Ok(Some(bytes)) +} + +fn find_child_box_info_sync( + reader: &mut R, + parent_info: BoxInfo, + child_type: FourCc, +) -> Result, ProbeError> +where + R: Read + Seek, +{ + let parent_end = parent_info.offset() + parent_info.size(); + reader.seek(SeekFrom::Start( + parent_info.offset() + parent_info.header_size(), + ))?; + while reader.stream_position()? < parent_end { + let child_info = BoxInfo::read(reader)?; + if child_info.box_type() == child_type { + return Ok(Some(child_info)); + } + child_info.seek_to_end(reader)?; + } + Ok(None) +} + +#[cfg(feature = "async")] +async fn find_child_box_info_async( + reader: &mut R, + parent_info: BoxInfo, + child_type: FourCc, +) -> Result, ProbeError> +where + R: AsyncReadSeek, +{ + let parent_end = parent_info.offset() + parent_info.size(); + reader + .seek(SeekFrom::Start( + parent_info.offset() + parent_info.header_size(), + )) + .await?; + while reader.stream_position().await? < parent_end { + let child_info = BoxInfo::read_async(reader).await?; + if child_info.box_type() == child_type { + return Ok(Some(child_info)); + } + child_info.seek_to_end_async(reader).await?; + } + Ok(None) +} + +fn read_child_box_payload_bytes_sync( + reader: &mut R, + parent_info: BoxInfo, + child_type: FourCc, +) -> Result>, ProbeError> +where + R: Read + Seek, +{ + let Some(child_info) = find_child_box_info_sync(reader, parent_info, child_type)? else { + return Ok(None); + }; + validate_child_box_fits_parent(child_info, parent_info)?; + validate_box_fits_stream_sync(reader, child_info, "child box")?; + child_info.seek_to_payload(reader)?; + let mut bytes = vec![ + 0_u8; + usize::try_from(child_info.payload_size()?).map_err(|_| { + ProbeError::NumericOverflow { + field_name: "child box payload size", + } + })? + ]; + reader.read_exact(&mut bytes)?; + Ok(Some(bytes)) +} + +#[cfg(feature = "async")] +async fn read_child_box_payload_bytes_async( + reader: &mut R, + parent_info: BoxInfo, + child_type: FourCc, +) -> Result>, ProbeError> +where + R: AsyncReadSeek, +{ + let Some(child_info) = find_child_box_info_async(reader, parent_info, child_type).await? else { + return Ok(None); + }; + validate_child_box_fits_parent(child_info, parent_info)?; + validate_box_fits_stream_async(reader, child_info, "child box").await?; + child_info.seek_to_payload_async(reader).await?; + let mut bytes = vec![ + 0_u8; + usize::try_from(child_info.payload_size()?).map_err(|_| { + ProbeError::NumericOverflow { + field_name: "child box payload size", + } + })? + ]; + tokio::io::AsyncReadExt::read_exact(reader, &mut bytes).await?; + Ok(Some(bytes)) +} + +fn validate_child_box_fits_parent( + child_info: BoxInfo, + parent_info: BoxInfo, +) -> Result<(), ProbeError> { + let child_end = checked_box_end(child_info, "child box end")?; + let parent_end = checked_box_end(parent_info, "parent box end")?; + if child_end > parent_end { + return Err(truncated_box_error("child box")); + } + Ok(()) +} + +fn validate_box_fits_stream_sync( + reader: &mut R, + info: BoxInfo, + label: &'static str, +) -> Result<(), ProbeError> +where + R: Seek, +{ + let position = reader.stream_position()?; + let stream_len = reader.seek(SeekFrom::End(0))?; + reader.seek(SeekFrom::Start(position))?; + validate_box_end_within_stream(info, stream_len, label) +} + +#[cfg(feature = "async")] +async fn validate_box_fits_stream_async( + reader: &mut R, + info: BoxInfo, + label: &'static str, +) -> Result<(), ProbeError> +where + R: AsyncReadSeek, +{ + let position = reader.stream_position().await?; + let stream_len = reader.seek(SeekFrom::End(0)).await?; + reader.seek(SeekFrom::Start(position)).await?; + validate_box_end_within_stream(info, stream_len, label) +} + +fn validate_box_end_within_stream( + info: BoxInfo, + stream_len: u64, + label: &'static str, +) -> Result<(), ProbeError> { + if checked_box_end(info, "box end")? > stream_len { + return Err(truncated_box_error(label)); + } + Ok(()) +} + +fn checked_box_end(info: BoxInfo, field_name: &'static str) -> Result { + info.offset() + .checked_add(info.size()) + .ok_or(ProbeError::NumericOverflow { field_name }) +} + +fn truncated_box_error(label: &'static str) -> ProbeError { + ProbeError::Io(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!("declared {label} extends beyond input"), + )) +} + /// Probes a file through the additive Tokio-based async surface with expansion controls and /// returns the codec-detailed summary. #[cfg(feature = "async")] @@ -1226,7 +1744,16 @@ where R: AsyncReadSeek, { let paths = root_probe_box_paths(options); - let infos = extract_boxes_async(reader, None, &paths).await?; + let infos = match extract_boxes_async(reader, None, &paths).await { + Ok(infos) => infos, + Err(error) => { + if let Some(root_bytes) = extract_compressed_movie_root_bytes_async(reader).await? { + let mut cursor = Cursor::new(root_bytes); + return probe_codec_detailed_with_options(&mut cursor, options); + } + return Err(error.into()); + } + }; let mut summary = CodecDetailedProbeInfo::default(); let mut mdat_appeared = false; @@ -1264,6 +1791,16 @@ where } } + if (summary.tracks.is_empty() || summary.timescale == 0) + && let Some(root_bytes) = extract_compressed_movie_root_bytes_async(reader).await? + { + let mut cursor = Cursor::new(root_bytes); + let fallback = probe_codec_detailed_with_options(&mut cursor, options)?; + if !fallback.tracks.is_empty() || fallback.timescale != 0 { + return Ok(fallback); + } + } + Ok(summary) } @@ -1538,6 +2075,49 @@ pub fn probe_bytes_with_options( probe_with_options(&mut reader, options) } +pub(crate) fn fragmented_track_warning_diagnostics( + reader: &mut R, +) -> Result, ProbeError> +where + R: Read + Seek, +{ + let infos = extract_boxes(reader, None, &[BoxPath::from([MOOF])])?; + let mut diagnostics = BTreeMap::new(); + + for info in infos { + let parsed = probe_moof_parsed(reader, &info)?; + let entry = diagnostics + .entry(parsed.summary.track_id) + .or_insert_with(FragmentedTrackWarningDiagnostics::default); + entry.zero_duration_sample_count += u64::from(parsed.zero_duration_sample_count); + + for sample_duration in parsed.sample_durations { + if sample_duration == 0 { + continue; + } + + if let Some(previous_duration) = entry.last_non_zero_sample_duration + && previous_duration != sample_duration + { + entry.sample_duration_change_count += 1; + } + entry.last_non_zero_sample_duration = Some(sample_duration); + entry.min_non_zero_sample_duration = Some( + entry + .min_non_zero_sample_duration + .map_or(sample_duration, |value| value.min(sample_duration)), + ); + entry.max_non_zero_sample_duration = Some( + entry + .max_non_zero_sample_duration + .map_or(sample_duration, |value| value.max(sample_duration)), + ); + } + } + + Ok(diagnostics) +} + /// Probes an in-memory MP4 byte slice and returns the additive detailed summary. /// /// This is equivalent to calling [`probe_detailed`] with `Cursor<&[u8]>`. @@ -1751,57 +2331,23 @@ pub fn detect_aac_profile(esds: &Esds) -> Result, ProbeEr ))?; let mut reader = BitReader::new(Cursor::new(specific_info)); - let mut remaining_bits = specific_info.len() * 8; - - let (audio_object_type, read_bits) = get_audio_object_type(&mut reader)?; - remaining_bits = remaining_bits.saturating_sub(read_bits); + let (audio_object_type, mut bit_offset) = get_audio_object_type(&mut reader)?; let sampling_frequency_index = read_bits_u8(&mut reader, 4)?; - remaining_bits = remaining_bits.saturating_sub(4); + bit_offset = bit_offset.saturating_add(4); if sampling_frequency_index == 0x0f { let _ = read_bits_u32(&mut reader, 24)?; - remaining_bits = remaining_bits.saturating_sub(24); - } - - if audio_object_type == 2 && remaining_bits >= 20 { - let _ = read_bits_u8(&mut reader, 4)?; - remaining_bits = remaining_bits.saturating_sub(4); - let sync_extension_type = read_bits_u16(&mut reader, 11)?; - remaining_bits = remaining_bits.saturating_sub(11); - if sync_extension_type == 0x02b7 { - let (ext_audio_object_type, _) = get_audio_object_type(&mut reader)?; - if ext_audio_object_type == 5 || ext_audio_object_type == 22 { - let sbr = read_bits_u8(&mut reader, 1)?; - remaining_bits = remaining_bits.saturating_sub(1); - if sbr != 0 { - if ext_audio_object_type == 5 { - let ext_sampling_frequency_index = read_bits_u8(&mut reader, 4)?; - remaining_bits = remaining_bits.saturating_sub(4); - if ext_sampling_frequency_index == 0x0f { - let _ = read_bits_u32(&mut reader, 24)?; - remaining_bits = remaining_bits.saturating_sub(24); - } - if remaining_bits >= 12 { - let sync_extension_type = read_bits_u16(&mut reader, 11)?; - if sync_extension_type == 0x0548 { - let ps = read_bits_u8(&mut reader, 1)?; - if ps != 0 { - return Ok(Some(AacProfileInfo { - object_type_indication: 0x40, - audio_object_type: 29, - })); - } - } - } - } + bit_offset = bit_offset.saturating_add(24); + } - return Ok(Some(AacProfileInfo { - object_type_indication: 0x40, - audio_object_type: 5, - })); - } - } - } + if audio_object_type == 2 + && let Some(extension) = + detect_aac_sync_extension_info(specific_info, bit_offset.saturating_add(4)) + { + return Ok(Some(AacProfileInfo { + object_type_indication: 0x40, + audio_object_type: extension.audio_object_type, + })); } Ok(Some(AacProfileInfo { @@ -1810,6 +2356,187 @@ pub fn detect_aac_profile(esds: &Esds) -> Result, ProbeEr })) } +/// Detects the effective AAC sample rate signaled by one `esds` descriptor stream. +pub fn detect_aac_effective_sample_rate(esds: &Esds) -> Result, ProbeError> { + let Some(decoder_config) = esds.decoder_config_descriptor() else { + return Ok(None); + }; + if decoder_config.object_type_indication != 0x40 { + return Ok(None); + } + + let specific_info = esds + .decoder_specific_info() + .ok_or(ProbeError::MissingDescriptor( + "decoder specific info descriptor", + ))?; + + let mut reader = BitReader::new(Cursor::new(specific_info)); + let (audio_object_type, mut bit_offset) = get_audio_object_type(&mut reader)?; + let (sample_rate, sample_rate_bits) = read_aac_sample_rate_with_bits(&mut reader)?; + bit_offset = bit_offset.saturating_add(sample_rate_bits); + let _ = read_bits_u8(&mut reader, 4)?; + bit_offset = bit_offset.saturating_add(4); + + if matches!(audio_object_type, 5 | 29) { + return Ok(Some(read_aac_sample_rate(&mut reader)?)); + } + + if audio_object_type == 2 + && let Some(extension) = detect_aac_sync_extension_info(specific_info, bit_offset) + && let Some(sample_rate) = extension.sample_rate + { + return Ok(Some(sample_rate)); + } + + Ok(Some(sample_rate)) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct AacSyncExtensionInfo { + audio_object_type: u8, + sample_rate: Option, +} + +fn detect_aac_sync_extension_info( + specific_info: &[u8], + search_start_bit: usize, +) -> Option { + let total_bits = specific_info.len().checked_mul(8)?; + if search_start_bit + .checked_add(17) + .is_none_or(|minimum| minimum > total_bits) + { + return None; + } + + for sync_bit in search_start_bit..=total_bits.saturating_sub(17) { + if read_bits_from_slice(specific_info, sync_bit, 11)? != 0x02b7 { + continue; + } + let (ext_audio_object_type, ext_aot_bits) = + get_audio_object_type_from_slice(specific_info, sync_bit.saturating_add(11))?; + if ext_audio_object_type != 5 && ext_audio_object_type != 22 { + continue; + } + + let mut bit_offset = sync_bit.saturating_add(11).saturating_add(ext_aot_bits); + let sbr = read_bits_from_slice(specific_info, bit_offset, 1)?; + bit_offset = bit_offset.saturating_add(1); + if sbr == 0 { + continue; + } + + if ext_audio_object_type == 5 { + let ext_sampling_frequency_index = + read_bits_from_slice(specific_info, bit_offset, 4)? as u8; + bit_offset = bit_offset.saturating_add(4); + let sample_rate = if ext_sampling_frequency_index == 0x0f { + let sample_rate = read_bits_from_slice(specific_info, bit_offset, 24)?; + bit_offset = bit_offset.saturating_add(24); + if bit_offset > total_bits { + continue; + } + Some(sample_rate) + } else { + aac_sampling_frequency(ext_sampling_frequency_index) + }; + if ext_sampling_frequency_index == 0x0f && sample_rate.is_none() { + continue; + } + if bit_offset.saturating_add(12) <= total_bits + && read_bits_from_slice(specific_info, bit_offset, 11)? == 0x0548 + && read_bits_from_slice(specific_info, bit_offset.saturating_add(11), 1)? != 0 + { + return Some(AacSyncExtensionInfo { + audio_object_type: 29, + sample_rate, + }); + } + return Some(AacSyncExtensionInfo { + audio_object_type: 5, + sample_rate, + }); + } + + return Some(AacSyncExtensionInfo { + audio_object_type: 5, + sample_rate: None, + }); + } + + None +} + +fn read_aac_sample_rate(reader: &mut BitReader) -> Result +where + R: Read, +{ + Ok(read_aac_sample_rate_with_bits(reader)?.0) +} + +fn read_aac_sample_rate_with_bits(reader: &mut BitReader) -> Result<(u32, usize), ProbeError> +where + R: Read, +{ + let sampling_frequency_index = read_bits_u8(reader, 4)?; + if sampling_frequency_index == 0x0f { + return Ok((read_bits_u32(reader, 24)?, 28)); + } + + Ok(( + aac_sampling_frequency(sampling_frequency_index).ok_or(ProbeError::MissingDescriptor( + "supported AAC sampling-frequency index", + ))?, + 4, + )) +} + +const fn aac_sampling_frequency(index: u8) -> Option { + match index { + 0 => Some(96_000), + 1 => Some(88_200), + 2 => Some(64_000), + 3 => Some(48_000), + 4 => Some(44_100), + 5 => Some(32_000), + 6 => Some(24_000), + 7 => Some(22_050), + 8 => Some(16_000), + 9 => Some(12_000), + 10 => Some(11_025), + 11 => Some(8_000), + 12 => Some(7_350), + _ => None, + } +} + +fn get_audio_object_type_from_slice(data: &[u8], bit_offset: usize) -> Option<(u8, usize)> { + let audio_object_type = u8::try_from(read_bits_from_slice(data, bit_offset, 5)?).ok()?; + if audio_object_type != 0x1f { + return Some((audio_object_type, 5)); + } + + let extended = + u8::try_from(read_bits_from_slice(data, bit_offset.saturating_add(5), 6)?).ok()?; + Some((extended.saturating_add(32), 11)) +} + +fn read_bits_from_slice(data: &[u8], bit_offset: usize, width: usize) -> Option { + let end = bit_offset.checked_add(width)?; + if end > data.len().checked_mul(8)? || width > 32 { + return None; + } + + let mut value = 0_u32; + for index in bit_offset..end { + let byte = *data.get(index / 8)?; + let shift = 7_u8.saturating_sub(u8::try_from(index % 8).ok()?); + value = (value << 1) | u32::from((byte >> shift) & 1); + } + Some(value) +} + /// Finds sample indices whose AVC payload contains an IDR NAL unit. pub fn find_idr_frames(reader: &mut R, track: &TrackInfo) -> Result, ProbeError> where @@ -2048,9 +2775,35 @@ fn root_probe_box_paths(options: ProbeOptions) -> Vec { } fn track_probe_box_paths(options: ProbeOptions) -> Vec { - let visual_sample_entries = [AVC1, HEV1, HVC1, VVC1, VVI1, AVS3, AV01, VP08, VP09, ENCV]; + let visual_sample_entries = [ + AVC1, + HEV1, + HVC1, + DVHE, + DVH1, + VVC1, + VVI1, + AVS3, + AV01, + JPEG_ENTRY, + MJPG_ENTRY_ALIAS, + MP4V, + DIV3_ENTRY, + DIV4_ENTRY, + BGR3_ENTRY, + S263, + H263_ENTRY_ALIAS, + PNG_ENTRY, + PNG_ENTRY_ALIAS, + VP08, + VP09, + VP10, + ENCV, + ]; let audio_sample_entries = [ - MP4A, OPUS, AC_3, EC_3, AC_4, FLAC, MHA1, MHA2, MHM1, MHM2, IPCM, FPCM, ENCA, + MP4A, DOT_MP3, ALAW, MLAW, OPUS, SPEX, SAMR, SAWB, SQCP, SEVC, SSMV, ULAW, AC_3, EC_3, + AC_4, ALAC, MLPA, DTSC, DTSE, DTSH, DTSL, DTSM, DTS_MINUS, DTSX, DTSY, FLAC, IAMF, MHA1, + MHA2, MHM1, MHM2, IPCM, FPCM, ENCA, ]; let mut paths = vec![ BoxPath::from([TKHD]), @@ -2064,6 +2817,10 @@ fn track_probe_box_paths(options: ProbeOptions) -> Vec { BoxPath::from([MDIA, MINF, STBL, STSD, HEV1, HVCC]), BoxPath::from([MDIA, MINF, STBL, STSD, HVC1]), BoxPath::from([MDIA, MINF, STBL, STSD, HVC1, HVCC]), + BoxPath::from([MDIA, MINF, STBL, STSD, DVHE]), + BoxPath::from([MDIA, MINF, STBL, STSD, DVHE, HVCC]), + BoxPath::from([MDIA, MINF, STBL, STSD, DVH1]), + BoxPath::from([MDIA, MINF, STBL, STSD, DVH1, HVCC]), BoxPath::from([MDIA, MINF, STBL, STSD, VVC1]), BoxPath::from([MDIA, MINF, STBL, STSD, VVC1, VVCC]), BoxPath::from([MDIA, MINF, STBL, STSD, VVI1]), @@ -2072,10 +2829,23 @@ fn track_probe_box_paths(options: ProbeOptions) -> Vec { BoxPath::from([MDIA, MINF, STBL, STSD, AVS3, AV3C]), BoxPath::from([MDIA, MINF, STBL, STSD, AV01]), BoxPath::from([MDIA, MINF, STBL, STSD, AV01, AV1C]), + BoxPath::from([MDIA, MINF, STBL, STSD, JPEG_ENTRY]), + BoxPath::from([MDIA, MINF, STBL, STSD, MJPG_ENTRY_ALIAS]), + BoxPath::from([MDIA, MINF, STBL, STSD, MP4V]), + BoxPath::from([MDIA, MINF, STBL, STSD, MP4V, ESDS]), + BoxPath::from([MDIA, MINF, STBL, STSD, DIV3_ENTRY]), + BoxPath::from([MDIA, MINF, STBL, STSD, DIV4_ENTRY]), + BoxPath::from([MDIA, MINF, STBL, STSD, BGR3_ENTRY]), + BoxPath::from([MDIA, MINF, STBL, STSD, S263]), + BoxPath::from([MDIA, MINF, STBL, STSD, H263_ENTRY_ALIAS]), + BoxPath::from([MDIA, MINF, STBL, STSD, PNG_ENTRY]), + BoxPath::from([MDIA, MINF, STBL, STSD, PNG_ENTRY_ALIAS]), BoxPath::from([MDIA, MINF, STBL, STSD, VP08]), BoxPath::from([MDIA, MINF, STBL, STSD, VP08, VPCC]), BoxPath::from([MDIA, MINF, STBL, STSD, VP09]), BoxPath::from([MDIA, MINF, STBL, STSD, VP09, VPCC]), + BoxPath::from([MDIA, MINF, STBL, STSD, VP10]), + BoxPath::from([MDIA, MINF, STBL, STSD, VP10, VPCC]), BoxPath::from([MDIA, MINF, STBL, STSD, ENCV]), BoxPath::from([MDIA, MINF, STBL, STSD, ENCV, AVCC]), BoxPath::from([MDIA, MINF, STBL, STSD, ENCV, HVCC]), @@ -2088,16 +2858,37 @@ fn track_probe_box_paths(options: ProbeOptions) -> Vec { BoxPath::from([MDIA, MINF, STBL, STSD, MP4A]), BoxPath::from([MDIA, MINF, STBL, STSD, MP4A, ESDS]), BoxPath::from([MDIA, MINF, STBL, STSD, MP4A, WAVE, ESDS]), + BoxPath::from([MDIA, MINF, STBL, STSD, DOT_MP3]), + BoxPath::from([MDIA, MINF, STBL, STSD, ALAW]), + BoxPath::from([MDIA, MINF, STBL, STSD, MLAW]), BoxPath::from([MDIA, MINF, STBL, STSD, OPUS]), BoxPath::from([MDIA, MINF, STBL, STSD, OPUS, DOPS]), + BoxPath::from([MDIA, MINF, STBL, STSD, SPEX]), + BoxPath::from([MDIA, MINF, STBL, STSD, SAMR]), + BoxPath::from([MDIA, MINF, STBL, STSD, SAWB]), + BoxPath::from([MDIA, MINF, STBL, STSD, SQCP]), + BoxPath::from([MDIA, MINF, STBL, STSD, SEVC]), + BoxPath::from([MDIA, MINF, STBL, STSD, SSMV]), + BoxPath::from([MDIA, MINF, STBL, STSD, ULAW]), BoxPath::from([MDIA, MINF, STBL, STSD, AC_3]), BoxPath::from([MDIA, MINF, STBL, STSD, AC_3, DAC3]), BoxPath::from([MDIA, MINF, STBL, STSD, EC_3]), BoxPath::from([MDIA, MINF, STBL, STSD, EC_3, DEC3]), BoxPath::from([MDIA, MINF, STBL, STSD, AC_4]), BoxPath::from([MDIA, MINF, STBL, STSD, AC_4, DAC4]), + BoxPath::from([MDIA, MINF, STBL, STSD, ALAC]), + BoxPath::from([MDIA, MINF, STBL, STSD, MLPA]), + BoxPath::from([MDIA, MINF, STBL, STSD, DTSC]), + BoxPath::from([MDIA, MINF, STBL, STSD, DTSE]), + BoxPath::from([MDIA, MINF, STBL, STSD, DTSH]), + BoxPath::from([MDIA, MINF, STBL, STSD, DTSL]), + BoxPath::from([MDIA, MINF, STBL, STSD, DTSM]), + BoxPath::from([MDIA, MINF, STBL, STSD, DTS_MINUS]), + BoxPath::from([MDIA, MINF, STBL, STSD, DTSX]), + BoxPath::from([MDIA, MINF, STBL, STSD, DTSY]), BoxPath::from([MDIA, MINF, STBL, STSD, FLAC]), BoxPath::from([MDIA, MINF, STBL, STSD, FLAC, DFLA]), + BoxPath::from([MDIA, MINF, STBL, STSD, IAMF]), BoxPath::from([MDIA, MINF, STBL, STSD, MHA1]), BoxPath::from([MDIA, MINF, STBL, STSD, MHA1, MHAC]), BoxPath::from([MDIA, MINF, STBL, STSD, MHA2]), @@ -2124,6 +2915,8 @@ fn track_probe_box_paths(options: ProbeOptions) -> Vec { BoxPath::from([MDIA, MINF, STBL, STSD, ENCA, SINF, SCHM]), BoxPath::from([MDIA, MINF, STBL, STSD, STPP]), BoxPath::from([MDIA, MINF, STBL, STSD, SBTT]), + BoxPath::from([MDIA, MINF, STBL, STSD, DVBS]), + BoxPath::from([MDIA, MINF, STBL, STSD, DVBT]), BoxPath::from([MDIA, MINF, STBL, STSD, WVTT]), BoxPath::from([MDIA, MINF, STBL, STSD, EVTE]), BoxPath::from([MDIA, MINF, STBL, STSD, EVTE, BTRT]), @@ -2388,6 +3181,16 @@ fn parse_trak_rich_details( track.sample_entry_type = Some(HVC1); visual_sample_entry = Some(downcast_clone::(&extracted)?); } + DVHE => { + track.codec_family = TrackCodecFamily::Hevc; + track.sample_entry_type = Some(DVHE); + visual_sample_entry = Some(downcast_clone::(&extracted)?); + } + DVH1 => { + track.codec_family = TrackCodecFamily::Hevc; + track.sample_entry_type = Some(DVH1); + visual_sample_entry = Some(downcast_clone::(&extracted)?); + } VVC1 => { track.sample_entry_type = Some(VVC1); visual_sample_entry = Some(downcast_clone::(&extracted)?); @@ -2405,6 +3208,32 @@ fn parse_trak_rich_details( track.sample_entry_type = Some(AV01); visual_sample_entry = Some(downcast_clone::(&extracted)?); } + JPEG_ENTRY | MJPG_ENTRY_ALIAS => { + track.sample_entry_type = Some(extracted.info.box_type()); + visual_sample_entry = Some(downcast_clone::(&extracted)?); + } + MPEG_ENTRY | MP4V => { + track.sample_entry_type = Some(extracted.info.box_type()); + visual_sample_entry = Some(downcast_clone::(&extracted)?); + } + S263 | H263_ENTRY_ALIAS => { + track.sample_entry_type = Some(extracted.info.box_type()); + visual_sample_entry = Some(downcast_clone::(&extracted)?); + } + PNG_ENTRY | PNG_ENTRY_ALIAS => { + track.sample_entry_type = Some(extracted.info.box_type()); + visual_sample_entry = Some(downcast_clone::(&extracted)?); + } + ENCV => { + track.summary.codec = TrackCodec::Avc1; + track.summary.encrypted = true; + track.sample_entry_type = Some(ENCV); + visual_sample_entry = Some(downcast_clone::(&extracted)?); + } + other if extracted.payload.as_any().is::() => { + track.sample_entry_type = Some(other); + visual_sample_entry = Some(downcast_clone::(&extracted)?); + } AV1C => { av1c = Some(downcast_clone::(&extracted)?); } @@ -2418,21 +3247,33 @@ fn parse_trak_rich_details( track.sample_entry_type = Some(VP09); visual_sample_entry = Some(downcast_clone::(&extracted)?); } + VP10 => { + track.sample_entry_type = Some(VP10); + visual_sample_entry = Some(downcast_clone::(&extracted)?); + } VPCC => { vpcc = Some(downcast_clone::(&extracted)?); } - ENCV => { - track.summary.codec = TrackCodec::Avc1; - track.summary.encrypted = true; - track.sample_entry_type = Some(ENCV); - visual_sample_entry = Some(downcast_clone::(&extracted)?); - } MP4A => { track.summary.codec = TrackCodec::Mp4a; track.codec_family = TrackCodecFamily::Mp4Audio; track.sample_entry_type = Some(MP4A); audio_sample_entry = Some(downcast_clone::(&extracted)?); } + DOT_MP3 => { + track.sample_entry_type = Some(DOT_MP3); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + ALAW => { + track.codec_family = TrackCodecFamily::Pcm; + track.sample_entry_type = Some(ALAW); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + MLAW => { + track.codec_family = TrackCodecFamily::Pcm; + track.sample_entry_type = Some(MLAW); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } ENCA => { track.summary.codec = TrackCodec::Mp4a; track.summary.encrypted = true; @@ -2444,6 +3285,35 @@ fn parse_trak_rich_details( track.sample_entry_type = Some(OPUS); audio_sample_entry = Some(downcast_clone::(&extracted)?); } + SPEX => { + track.sample_entry_type = Some(SPEX); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + SAMR => { + track.sample_entry_type = Some(SAMR); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + SAWB => { + track.sample_entry_type = Some(SAWB); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + SQCP => { + track.sample_entry_type = Some(SQCP); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + SEVC => { + track.sample_entry_type = Some(SEVC); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + SSMV => { + track.sample_entry_type = Some(SSMV); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + ULAW => { + track.codec_family = TrackCodecFamily::Pcm; + track.sample_entry_type = Some(ULAW); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } DOPS => { dops = Some(downcast_clone::(&extracted)?); } @@ -2460,10 +3330,54 @@ fn parse_trak_rich_details( track.sample_entry_type = Some(AC_4); audio_sample_entry = Some(downcast_clone::(&extracted)?); } + ALAC => { + track.sample_entry_type = Some(ALAC); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + MLPA => { + track.sample_entry_type = Some(MLPA); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + DTSC => { + track.sample_entry_type = Some(DTSC); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + DTSE => { + track.sample_entry_type = Some(DTSE); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + DTSH => { + track.sample_entry_type = Some(DTSH); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + DTSL => { + track.sample_entry_type = Some(DTSL); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + DTSM => { + track.sample_entry_type = Some(DTSM); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + DTS_MINUS => { + track.sample_entry_type = Some(DTS_MINUS); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + DTSX => { + track.sample_entry_type = Some(DTSX); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + DTSY => { + track.sample_entry_type = Some(DTSY); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } FLAC => { track.sample_entry_type = Some(FLAC); audio_sample_entry = Some(downcast_clone::(&extracted)?); } + IAMF => { + track.sample_entry_type = Some(IAMF); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } MHA1 => { track.sample_entry_type = Some(MHA1); audio_sample_entry = Some(downcast_clone::(&extracted)?); @@ -2508,6 +3422,18 @@ fn parse_trak_rich_details( text_subtitle_sample_entry = Some(downcast_clone::(&extracted)?); } + DVBS => { + track.sample_entry_type = Some(DVBS); + let _ = downcast_clone::(&extracted)?; + } + DVBT => { + track.sample_entry_type = Some(DVBT); + let _ = downcast_clone::(&extracted)?; + } + MP4S => { + track.sample_entry_type = Some(MP4S); + let _ = downcast_clone::(&extracted)?; + } EVTE => { track.sample_entry_type = Some(EVTE); let _ = downcast_clone::(&extracted)?; @@ -2743,14 +3669,15 @@ fn parse_trak_rich_details( fn codec_family_from_sample_entry(sample_entry_type: FourCc) -> TrackCodecFamily { match sample_entry_type { AVC1 => TrackCodecFamily::Avc, - HEV1 | HVC1 => TrackCodecFamily::Hevc, + HEV1 | HVC1 | DVHE | DVH1 => TrackCodecFamily::Hevc, AV01 => TrackCodecFamily::Av1, VP08 => TrackCodecFamily::Vp8, VP09 => TrackCodecFamily::Vp9, MP4A => TrackCodecFamily::Mp4Audio, OPUS => TrackCodecFamily::Opus, AC_3 => TrackCodecFamily::Ac3, - IPCM | FPCM => TrackCodecFamily::Pcm, + ALAW | MLAW | ULAW | IPCM | FPCM => TrackCodecFamily::Pcm, + MP4S => TrackCodecFamily::Unknown, STPP => TrackCodecFamily::XmlSubtitle, SBTT => TrackCodecFamily::TextSubtitle, WVTT => TrackCodecFamily::WebVtt, @@ -3058,16 +3985,7 @@ fn probe_moof(reader: &mut R, parent: &BoxInfo) -> Result(reader: &mut R, parent: &BoxInfo) -> Result +where + R: Read + Seek, +{ + let boxes = extract_boxes_with_payload( + reader, + Some(parent), + &[ + BoxPath::from([TRAF, TFHD]), + BoxPath::from([TRAF, TFDT]), + BoxPath::from([TRAF, TRUN]), + ], + )?; parse_moof_segment(boxes, parent.offset()) } fn parse_moof_segment( boxes: Vec, moof_offset: u64, -) -> Result { +) -> Result { let mut tfhd = None; let mut tfdt = None; let mut trun = None; @@ -3105,42 +4039,64 @@ fn parse_moof_segment( } let tfhd = tfhd.ok_or(ProbeError::MissingRequiredBox("tfhd"))?; - let mut segment = SegmentInfo { - track_id: tfhd.track_id, - moof_offset, - default_sample_duration: tfhd.default_sample_duration, - ..SegmentInfo::default() + let mut parsed = ParsedMoofSegment { + summary: SegmentInfo { + track_id: tfhd.track_id, + moof_offset, + default_sample_duration: tfhd.default_sample_duration, + ..SegmentInfo::default() + }, + ..ParsedMoofSegment::default() }; if let Some(tfdt) = tfdt.as_ref() { - segment.base_media_decode_time = tfdt.base_media_decode_time(); + parsed.summary.base_media_decode_time = tfdt.base_media_decode_time(); } if let Some(trun) = trun.as_ref() { - segment.sample_count = trun.sample_count; + parsed.summary.sample_count = trun.sample_count; if trun.flags() & crate::boxes::iso14496_12::TRUN_SAMPLE_DURATION_PRESENT != 0 { - segment.duration = trun + parsed.sample_durations = trun + .entries + .iter() + .map(|entry| entry.sample_duration) + .collect(); + parsed.summary.duration = trun .entries .iter() .map(|entry| entry.sample_duration) .sum::(); + parsed.zero_duration_sample_count = parsed + .sample_durations + .iter() + .filter(|sample_duration| **sample_duration == 0) + .count() + .try_into() + .map_err(|_| ProbeError::NumericOverflow { + field_name: "segment zero-duration sample count", + })?; } else { - segment.duration = tfhd + parsed.sample_durations = + vec![tfhd.default_sample_duration; parsed.summary.sample_count as usize]; + parsed.summary.duration = tfhd .default_sample_duration - .saturating_mul(segment.sample_count); + .saturating_mul(parsed.summary.sample_count); + if tfhd.default_sample_duration == 0 { + parsed.zero_duration_sample_count = parsed.summary.sample_count; + } } if trun.flags() & crate::boxes::iso14496_12::TRUN_SAMPLE_SIZE_PRESENT != 0 { - segment.size = trun + parsed.summary.size = trun .entries .iter() .map(|entry| entry.sample_size) .sum::(); } else { - segment.size = tfhd + parsed.summary.size = tfhd .default_sample_size - .saturating_mul(segment.sample_count); + .saturating_mul(parsed.summary.sample_count); } let mut duration = 0_u32; @@ -3157,14 +4113,14 @@ fn parse_moof_segment( ); } if let Some(offset) = min_offset { - segment.composition_time_offset = + parsed.summary.composition_time_offset = offset.try_into().map_err(|_| ProbeError::NumericOverflow { field_name: "segment composition time offset", })?; } } - Ok(segment) + Ok(parsed) } fn read_payload_as(reader: &mut R, info: &BoxInfo) -> Result @@ -3187,20 +4143,8 @@ where reader .seek(SeekFrom::Start(info.offset() + info.header_size())) .await?; - let mut payload_bytes = Vec::with_capacity(info.payload_size()?.try_into().unwrap_or(0)); - let mut payload_reader = (&mut *reader).take(info.payload_size()?); - let payload_read = payload_reader.read_to_end(&mut payload_bytes).await? as u64; - if payload_read != info.payload_size()? { - return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into()); - } - let mut decoded = B::default(); - unmarshal( - &mut Cursor::new(payload_bytes.as_slice()), - info.payload_size()?, - &mut decoded, - None, - )?; + unmarshal_async(reader, info.payload_size()?, &mut decoded, None).await?; Ok(decoded) } @@ -3246,20 +4190,6 @@ where }) } -fn read_bits_u16(reader: &mut BitReader, width: usize) -> Result -where - R: Read, -{ - let bits = reader.read_bits(width).map_err(ProbeError::Io)?; - let mut value = 0_u32; - for byte in bits { - value = (value << 8) | u32::from(byte); - } - u16::try_from(value).map_err(|_| ProbeError::NumericOverflow { - field_name: "bitfield read", - }) -} - fn read_bits_u32(reader: &mut BitReader, width: usize) -> Result where R: Read, diff --git a/src/queue.rs b/src/queue.rs new file mode 100644 index 0000000..693dfdc --- /dev/null +++ b/src/queue.rs @@ -0,0 +1,628 @@ +//! Internal queue-backed work-item helpers for direct queue consumers. + +use std::collections::BTreeSet; +#[cfg(any(feature = "decrypt", test))] +use std::collections::{HashMap, VecDeque}; +#[cfg(any(feature = "decrypt", test))] +use std::fmt; + +#[cfg(any(feature = "decrypt", test))] +use crate::FourCc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct QueueAuxiliaryInfoSpan { + pub(crate) absolute_offset: u64, + pub(crate) size: u64, +} + +pub(crate) trait QueueWorkItem { + fn queue_order_key(&self) -> u64; + + fn auxiliary_info_span(&self) -> Option { + None + } +} + +#[cfg(any(feature = "decrypt", test))] +pub(crate) trait QueueRangeWorkItem: QueueWorkItem { + fn queue_range_start(&self) -> u64; + + fn queue_range_size(&self) -> u64; +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct OrderedWorkQueue { + items: Vec, + auxiliary_info_spans: Vec, +} + +impl OrderedWorkQueue +where + T: QueueWorkItem, +{ + pub(crate) fn new(mut items: Vec) -> Self { + items.sort_by_key(QueueWorkItem::queue_order_key); + + let mut seen_auxiliary_info_spans = BTreeSet::new(); + let mut auxiliary_info_spans = Vec::new(); + for span in items.iter().filter_map(QueueWorkItem::auxiliary_info_span) { + if seen_auxiliary_info_spans.insert(span) { + auxiliary_info_spans.push(span); + } + } + + Self { + items, + auxiliary_info_spans, + } + } + + #[cfg(feature = "mux")] + pub(crate) fn iter(&self) -> std::slice::Iter<'_, T> { + self.items.iter() + } + + #[cfg(feature = "decrypt")] + pub(crate) fn items(&self) -> &[T] { + &self.items + } + + #[cfg(any(feature = "decrypt", test))] + pub(crate) fn auxiliary_info_spans(&self) -> &[QueueAuxiliaryInfoSpan] { + &self.auxiliary_info_spans + } +} + +#[cfg(any(feature = "decrypt", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RawOffsetQueueError { + RangeOverflow { start: u64, size: u64 }, + RequestedClearedRange { start: u64, head: u64 }, + RequestedUnbufferedRange { end: u64, tail: u64 }, + TrimBeyondTail { target: u64, tail: u64 }, +} + +#[cfg(any(feature = "decrypt", test))] +impl fmt::Display for RawOffsetQueueError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RangeOverflow { start, size } => write!( + f, + "raw offset queue range at offset {start} with size {size} overflowed the supported range" + ), + Self::RequestedClearedRange { start, head } => write!( + f, + "raw offset queue request at offset {start} started before the current clear window head {head}" + ), + Self::RequestedUnbufferedRange { end, tail } => write!( + f, + "raw offset queue request ending at offset {end} exceeded the buffered tail {tail}" + ), + Self::TrimBeyondTail { target, tail } => write!( + f, + "raw offset queue clear-window target {target} exceeded the buffered tail {tail}" + ), + } + } +} + +#[cfg(any(feature = "decrypt", test))] +pub(crate) struct RawOffsetQueue { + head: u64, + bytes: VecDeque, +} + +#[cfg(any(feature = "decrypt", test))] +impl RawOffsetQueue { + pub(crate) fn new(head: u64) -> Self { + Self { + head, + bytes: VecDeque::new(), + } + } + + pub(crate) fn head(&self) -> u64 { + self.head + } + + pub(crate) fn tail(&self) -> u64 { + self.head + u64::try_from(self.bytes.len()).unwrap() + } + + #[cfg(test)] + pub(crate) fn buffered_len(&self) -> usize { + self.bytes.len() + } + + pub(crate) fn push_bytes(&mut self, bytes: &[u8]) { + self.bytes.extend(bytes.iter().copied()); + } + + pub(crate) fn trim_to(&mut self, target: u64) -> Result<(), RawOffsetQueueError> { + if target < self.head { + return Ok(()); + } + let tail = self.tail(); + if target > tail { + return Err(RawOffsetQueueError::TrimBeyondTail { target, tail }); + } + let trim_len = usize::try_from(target - self.head) + .map_err(|_| RawOffsetQueueError::TrimBeyondTail { target, tail })?; + self.bytes.drain(..trim_len); + self.head = target; + Ok(()) + } + + pub(crate) fn with_range_bytes( + &mut self, + start: u64, + size: u64, + read: F, + ) -> Result + where + F: FnOnce(&[u8]) -> T, + { + let end = start + .checked_add(size) + .ok_or(RawOffsetQueueError::RangeOverflow { start, size })?; + if start < self.head { + return Err(RawOffsetQueueError::RequestedClearedRange { + start, + head: self.head, + }); + } + let tail = self.tail(); + if end > tail { + return Err(RawOffsetQueueError::RequestedUnbufferedRange { end, tail }); + } + + let start_index = usize::try_from(start - self.head) + .map_err(|_| RawOffsetQueueError::RangeOverflow { start, size })?; + let len = usize::try_from(size) + .map_err(|_| RawOffsetQueueError::RangeOverflow { start, size })?; + let contiguous = self.bytes.make_contiguous(); + Ok(read(&contiguous[start_index..start_index + len])) + } +} + +#[cfg(any(feature = "decrypt", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RangeQueueParserStage<'a, T> { + AuxiliaryInfo(&'a [QueueAuxiliaryInfoSpan]), + CopyRange { start: u64, size: u64 }, + WorkItem(&'a T), + Complete, +} + +#[cfg(any(feature = "decrypt", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RangeQueueParserError { + OverlappingWorkItemRange { next_start: u64, cursor: u64 }, + WorkItemRangeOverflow { start: u64, size: u64 }, +} + +#[cfg(any(feature = "decrypt", test))] +impl fmt::Display for RangeQueueParserError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::OverlappingWorkItemRange { next_start, cursor } => write!( + f, + "queue work item started at offset {next_start} before the parser cursor {cursor}" + ), + Self::WorkItemRangeOverflow { start, size } => write!( + f, + "queue work item at offset {start} with size {size} overflowed the supported range" + ), + } + } +} + +#[cfg(any(feature = "decrypt", test))] +pub(crate) struct RangeQueueParser<'a, T> { + auxiliary_info_spans: &'a [QueueAuxiliaryInfoSpan], + range_items: Vec<&'a T>, + next_item_index: usize, + pending_item: Option<&'a T>, + cursor: u64, + range_end: u64, + emitted_auxiliary_info: bool, + emitted_tail: bool, +} + +#[cfg(any(feature = "decrypt", test))] +impl<'a, T> RangeQueueParser<'a, T> +where + T: QueueRangeWorkItem, +{ + pub(crate) fn new( + queue: Option<&'a OrderedWorkQueue>, + range_start: u64, + range_end: u64, + ) -> Self { + let auxiliary_info_spans = queue + .map(OrderedWorkQueue::auxiliary_info_spans) + .unwrap_or(&[]); + let mut range_items = queue + .map(|queue| queue.items.iter().collect::>()) + .unwrap_or_default(); + range_items.sort_by_key(|item| item.queue_range_start()); + Self { + auxiliary_info_spans, + range_items, + next_item_index: 0, + pending_item: None, + cursor: range_start, + range_end, + emitted_auxiliary_info: false, + emitted_tail: false, + } + } + + pub(crate) fn next_stage( + &mut self, + ) -> Result, RangeQueueParserError> { + if let Some(item) = self.pending_item.take() { + self.cursor = checked_range_end(item.queue_range_start(), item.queue_range_size())?; + return Ok(RangeQueueParserStage::WorkItem(item)); + } + + if !self.emitted_auxiliary_info { + self.emitted_auxiliary_info = true; + return Ok(RangeQueueParserStage::AuxiliaryInfo( + self.auxiliary_info_spans, + )); + } + + if let Some(item) = self.range_items.get(self.next_item_index).copied() { + self.next_item_index += 1; + let item_start = item.queue_range_start(); + let item_size = item.queue_range_size(); + if item_start < self.cursor { + return Err(RangeQueueParserError::OverlappingWorkItemRange { + next_start: item_start, + cursor: self.cursor, + }); + } + if item_start > self.cursor { + self.pending_item = Some(item); + return Ok(RangeQueueParserStage::CopyRange { + start: self.cursor, + size: item_start - self.cursor, + }); + } + self.cursor = checked_range_end(item_start, item_size)?; + return Ok(RangeQueueParserStage::WorkItem(item)); + } + + if !self.emitted_tail && self.cursor < self.range_end { + self.emitted_tail = true; + return Ok(RangeQueueParserStage::CopyRange { + start: self.cursor, + size: self.range_end - self.cursor, + }); + } + Ok(RangeQueueParserStage::Complete) + } +} + +#[cfg(any(feature = "decrypt", test))] +fn checked_range_end(start: u64, size: u64) -> Result { + start + .checked_add(size) + .ok_or(RangeQueueParserError::WorkItemRangeOverflow { start, size }) +} + +#[cfg(any(feature = "decrypt", test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct DecryptorReuseKey { + scheme_type: FourCc, + key_bytes: [u8; 16], +} + +#[cfg(any(feature = "decrypt", test))] +impl DecryptorReuseKey { + pub(crate) fn new(scheme_type: FourCc, key_bytes: [u8; 16]) -> Self { + Self { + scheme_type, + key_bytes, + } + } +} + +#[cfg(any(feature = "decrypt", test))] +pub(crate) struct DecryptorReuseCache { + entries: HashMap, +} + +#[cfg(any(feature = "decrypt", test))] +impl DecryptorReuseCache { + pub(crate) fn new() -> Self { + Self { + entries: HashMap::new(), + } + } + + pub(crate) fn touch_or_insert_with(&mut self, key: DecryptorReuseKey, build: F) -> &mut T + where + F: FnOnce() -> T, + { + self.entries.entry(key).or_insert_with(build) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct TestWorkItem { + queue_order_key: u64, + queue_range_start: u64, + auxiliary_info_span: Option, + } + + impl QueueWorkItem for TestWorkItem { + fn queue_order_key(&self) -> u64 { + self.queue_order_key + } + + fn auxiliary_info_span(&self) -> Option { + self.auxiliary_info_span + } + } + + impl QueueRangeWorkItem for TestWorkItem { + fn queue_range_start(&self) -> u64 { + self.queue_range_start + } + + fn queue_range_size(&self) -> u64 { + 4 + } + } + + #[test] + fn ordered_work_queue_sorts_items_and_preserves_first_auxiliary_stage_order() { + let queue = OrderedWorkQueue::new(vec![ + TestWorkItem { + queue_order_key: 44, + queue_range_start: 44, + auxiliary_info_span: Some(QueueAuxiliaryInfoSpan { + absolute_offset: 20, + size: 8, + }), + }, + TestWorkItem { + queue_order_key: 12, + queue_range_start: 12, + auxiliary_info_span: Some(QueueAuxiliaryInfoSpan { + absolute_offset: 20, + size: 8, + }), + }, + TestWorkItem { + queue_order_key: 31, + queue_range_start: 31, + auxiliary_info_span: Some(QueueAuxiliaryInfoSpan { + absolute_offset: 8, + size: 4, + }), + }, + ]); + + assert_eq!( + queue + .items + .iter() + .map(|item| item.queue_order_key) + .collect::>(), + vec![12, 31, 44] + ); + assert_eq!( + queue.auxiliary_info_spans, + vec![ + QueueAuxiliaryInfoSpan { + absolute_offset: 20, + size: 8, + }, + QueueAuxiliaryInfoSpan { + absolute_offset: 8, + size: 4, + }, + ] + ); + } + + #[test] + fn decryptor_reuse_cache_reuses_existing_entries_for_the_same_key() { + let mut cache = DecryptorReuseCache::new(); + let key = DecryptorReuseKey::new(FourCc::from_bytes(*b"cenc"), [0x11; 16]); + + *cache.touch_or_insert_with(key, || 1_u32) += 1; + *cache.touch_or_insert_with(key, || 9_u32) += 1; + + assert_eq!(cache.entries.len(), 1); + assert_eq!(cache.entries.get(&key), Some(&3_u32)); + } + + #[test] + fn range_queue_parser_emits_copy_gaps_work_items_and_tail() { + let queue = OrderedWorkQueue::new(vec![ + TestWorkItem { + queue_order_key: 12, + queue_range_start: 12, + auxiliary_info_span: Some(QueueAuxiliaryInfoSpan { + absolute_offset: 4, + size: 2, + }), + }, + TestWorkItem { + queue_order_key: 24, + queue_range_start: 24, + auxiliary_info_span: None, + }, + ]); + let mut parser = RangeQueueParser::new(Some(&queue), 8, 32); + + assert_eq!( + parser.next_stage().unwrap(), + RangeQueueParserStage::AuxiliaryInfo(&[QueueAuxiliaryInfoSpan { + absolute_offset: 4, + size: 2, + }]) + ); + assert_eq!( + parser.next_stage().unwrap(), + RangeQueueParserStage::CopyRange { start: 8, size: 4 } + ); + match parser.next_stage().unwrap() { + RangeQueueParserStage::WorkItem(item) => assert_eq!(item.queue_order_key, 12), + other => panic!("unexpected range queue stage: {other:?}"), + } + assert_eq!( + parser.next_stage().unwrap(), + RangeQueueParserStage::CopyRange { start: 16, size: 8 } + ); + match parser.next_stage().unwrap() { + RangeQueueParserStage::WorkItem(item) => assert_eq!(item.queue_order_key, 24), + other => panic!("unexpected range queue stage: {other:?}"), + } + assert_eq!( + parser.next_stage().unwrap(), + RangeQueueParserStage::CopyRange { start: 28, size: 4 } + ); + assert_eq!( + parser.next_stage().unwrap(), + RangeQueueParserStage::Complete + ); + } + + #[test] + fn range_queue_parser_rejects_overlapping_work_item_ranges() { + let queue = OrderedWorkQueue::new(vec![ + TestWorkItem { + queue_order_key: 12, + queue_range_start: 12, + auxiliary_info_span: None, + }, + TestWorkItem { + queue_order_key: 14, + queue_range_start: 14, + auxiliary_info_span: None, + }, + ]); + let mut parser = RangeQueueParser::new(Some(&queue), 8, 24); + + assert!(matches!( + parser.next_stage().unwrap(), + RangeQueueParserStage::AuxiliaryInfo(_) + )); + assert!(matches!( + parser.next_stage().unwrap(), + RangeQueueParserStage::CopyRange { start: 8, size: 4 } + )); + assert!(matches!( + parser.next_stage().unwrap(), + RangeQueueParserStage::WorkItem(_) + )); + assert_eq!( + parser.next_stage().unwrap_err(), + RangeQueueParserError::OverlappingWorkItemRange { + next_start: 14, + cursor: 16, + } + ); + } + + #[test] + fn range_queue_parser_uses_range_order_even_when_queue_order_differs() { + let queue = OrderedWorkQueue::new(vec![ + TestWorkItem { + queue_order_key: 10, + queue_range_start: 24, + auxiliary_info_span: Some(QueueAuxiliaryInfoSpan { + absolute_offset: 6, + size: 2, + }), + }, + TestWorkItem { + queue_order_key: 20, + queue_range_start: 12, + auxiliary_info_span: None, + }, + ]); + let mut parser = RangeQueueParser::new(Some(&queue), 8, 32); + + assert!(matches!( + parser.next_stage().unwrap(), + RangeQueueParserStage::AuxiliaryInfo(_) + )); + assert_eq!( + parser.next_stage().unwrap(), + RangeQueueParserStage::CopyRange { start: 8, size: 4 } + ); + match parser.next_stage().unwrap() { + RangeQueueParserStage::WorkItem(item) => { + assert_eq!(item.queue_order_key, 20); + assert_eq!(item.queue_range_start, 12); + } + other => panic!("unexpected range queue stage: {other:?}"), + } + assert_eq!( + parser.next_stage().unwrap(), + RangeQueueParserStage::CopyRange { start: 16, size: 8 } + ); + match parser.next_stage().unwrap() { + RangeQueueParserStage::WorkItem(item) => { + assert_eq!(item.queue_order_key, 10); + assert_eq!(item.queue_range_start, 24); + } + other => panic!("unexpected range queue stage: {other:?}"), + } + } + + #[test] + fn raw_offset_queue_reads_and_trims_buffered_ranges() { + let mut queue = RawOffsetQueue::new(100); + queue.push_bytes(&[1, 2, 3, 4, 5, 6]); + + let copied = queue.with_range_bytes(102, 3, <[u8]>::to_vec).unwrap(); + assert_eq!(copied, vec![3, 4, 5]); + assert_eq!(queue.head(), 100); + assert_eq!(queue.tail(), 106); + + queue.trim_to(104).unwrap(); + assert_eq!(queue.head(), 104); + assert_eq!(queue.tail(), 106); + assert_eq!(queue.buffered_len(), 2); + + let remaining = queue.with_range_bytes(104, 2, <[u8]>::to_vec).unwrap(); + assert_eq!(remaining, vec![5, 6]); + } + + #[test] + fn raw_offset_queue_rejects_cleared_and_unbuffered_ranges() { + let mut queue = RawOffsetQueue::new(40); + queue.push_bytes(&[7, 8, 9, 10]); + queue.trim_to(42).unwrap(); + + assert_eq!( + queue.with_range_bytes(41, 1, <[u8]>::to_vec).unwrap_err(), + RawOffsetQueueError::RequestedClearedRange { + start: 41, + head: 42 + } + ); + assert_eq!( + queue.with_range_bytes(44, 2, <[u8]>::to_vec).unwrap_err(), + RawOffsetQueueError::RequestedUnbufferedRange { end: 46, tail: 44 } + ); + assert_eq!( + queue.trim_to(45).unwrap_err(), + RawOffsetQueueError::TrimBeyondTail { + target: 45, + tail: 44 + } + ); + } +} diff --git a/src/rewrite.rs b/src/rewrite.rs index 7daa46c..f65e9cf 100644 --- a/src/rewrite.rs +++ b/src/rewrite.rs @@ -18,6 +18,8 @@ use crate::boxes::iso14496_12::{ use crate::boxes::metadata::Keys; use crate::boxes::{BoxLookupContext, BoxRegistry, default_registry}; use crate::codec::{CodecBox, CodecError, marshal_dyn, unmarshal, unmarshal_any_with_context}; +#[cfg(feature = "async")] +use crate::codec::{marshal_dyn_async, unmarshal_any_with_context_async}; use crate::header::{BoxInfo, HeaderError, SMALL_HEADER_SIZE}; use crate::walk::{BoxPath, PathMatch}; use crate::writer::{Writer, WriterError}; @@ -562,60 +564,49 @@ where .seek(SeekFrom::Start(info.offset() + info.header_size())) .await?; let payload_size = info.payload_size()?; - let mut payload_bytes = Vec::with_capacity(payload_size.try_into().unwrap_or(0)); - let mut payload_reader = (&mut *reader).take(payload_size); - let payload_read = payload_reader.read_to_end(&mut payload_bytes).await? as u64; - if payload_read != payload_size { - return Err(RewriteError::UnexpectedEof); - } - let (encoded_payload, payload_read, is_visual_sample_entry) = { - let (mut payload, payload_read) = unmarshal_any_with_context( - &mut Cursor::new(payload_bytes.as_slice()), - payload_size, - info.box_type(), - registry, - info.lookup_context(), - None, - ) - .map_err(|source| RewriteError::PayloadDecode { - path: current_path.clone(), - box_type: info.box_type(), - offset: info.offset(), - source, - })?; - - if path_match.exact_match { - let typed = payload.as_any_mut().downcast_mut::().ok_or_else(|| { - RewriteError::UnexpectedPayloadType { - path: current_path.clone(), - box_type: info.box_type(), - offset: info.offset(), - expected_type: type_name::(), - } - })?; - (plan.edit)(typed); - *plan.rewritten_count += 1; - } + let (mut payload, payload_read) = unmarshal_any_with_context_async( + reader, + payload_size, + info.box_type(), + registry, + info.lookup_context(), + None, + ) + .await + .map_err(|source| RewriteError::PayloadDecode { + path: current_path.clone(), + box_type: info.box_type(), + offset: info.offset(), + source, + })?; - let is_visual_sample_entry = payload.as_any().is::(); - let mut encoded_payload = Vec::new(); - marshal_dyn(&mut encoded_payload, payload.as_ref(), None).map_err(|source| { - RewriteError::PayloadEncode { + if path_match.exact_match { + let typed = payload.as_any_mut().downcast_mut::().ok_or_else(|| { + RewriteError::UnexpectedPayloadType { path: current_path.clone(), box_type: info.box_type(), offset: info.offset(), - source, + expected_type: type_name::(), } })?; - (encoded_payload, payload_read, is_visual_sample_entry) - }; + (plan.edit)(typed); + *plan.rewritten_count += 1; + } + let is_visual_sample_entry = payload.as_any().is::(); let placeholder = BoxInfo::new(info.box_type(), info.header_size()) .with_header_size(info.header_size()) .with_lookup_context(info.lookup_context()) .with_extend_to_eof(info.extend_to_eof()); writer.start_box_async(placeholder).await?; - writer.write_all(&encoded_payload).await?; + marshal_dyn_async(&mut *writer, payload.as_ref(), None) + .await + .map_err(|source| RewriteError::PayloadEncode { + path: current_path.clone(), + box_type: info.box_type(), + offset: info.offset(), + source, + })?; let children_offset = info.offset() + info.header_size() + payload_read; let (children_size, trailing_bytes) = if is_visual_sample_entry { diff --git a/src/sidx.rs b/src/sidx.rs index 2b5f99f..f0c3b6e 100644 --- a/src/sidx.rs +++ b/src/sidx.rs @@ -16,8 +16,12 @@ use crate::boxes::iso14496_12::{ TRUN_SAMPLE_COMPOSITION_TIME_OFFSET_PRESENT, TRUN_SAMPLE_DURATION_PRESENT, Tfdt, Tfhd, Tkhd, Trex, Trun, }; +#[cfg(feature = "async")] +use crate::codec::unmarshal_async; use crate::codec::{CodecBox, CodecError, ImmutableBox, MutableBox, marshal, unmarshal}; use crate::extract::{ExtractError, extract_box_as, extract_boxes}; +#[cfg(feature = "async")] +use crate::extract::{extract_box_as_async, extract_boxes_async}; use crate::header::{BoxInfo, HeaderError, LARGE_HEADER_SIZE, SMALL_HEADER_SIZE}; use crate::walk::BoxPath; #[cfg(feature = "async")] @@ -826,8 +830,59 @@ pub async fn analyze_top_level_sidx_update_async( where R: AsyncReadSeek, { - let input = read_all_bytes_async(reader).await?; - analyze_top_level_sidx_update_bytes(&input) + let root_boxes = scan_root_boxes_async(reader).await?; + let has_fragment_markers = root_boxes + .iter() + .any(|info| matches!(info.box_type(), MOOF | STYP | SIDX)); + + let moov = root_boxes + .iter() + .find(|info| info.box_type() == MOOV) + .copied() + .ok_or(SidxAnalysisError::MissingMovieBox)?; + + let has_mvex = !extract_boxes_async(reader, Some(&moov), &[BoxPath::from([MVEX])]) + .await? + .is_empty(); + if !has_fragment_markers && !has_mvex { + return Err(SidxAnalysisError::NotFragmented); + } + if !has_mvex { + return Err(SidxAnalysisError::MissingMovieExtendsBox); + } + + let init = analyze_init_segment_async(reader, &moov).await?; + let (segments, existing_top_level_sidxs) = + group_media_segments_async(reader, &root_boxes).await?; + if segments.is_empty() { + return Err(SidxAnalysisError::MissingMediaSegments); + } + + let mut analyzed_segments = Vec::with_capacity(segments.len()); + for (segment_index, segment) in segments.iter().enumerate() { + analyzed_segments.push( + analyze_segment_async( + reader, + segment_index + 1, + segment, + init.timing_track.track_id, + &init.trex, + ) + .await?, + ); + } + + Ok(TopLevelSidxUpdateAnalysis { + timing_track: init.timing_track, + placement: TopLevelSidxPlacement { + insertion_box: segments[0].first_box, + existing_top_level_sidxs: existing_top_level_sidxs + .into_iter() + .map(|entry| entry.public) + .collect(), + }, + segments: analyzed_segments, + }) } /// Builds a deterministic top-level `sidx` refresh plan from analyzed file data. @@ -1101,6 +1156,24 @@ where Ok(boxes) } +#[cfg(feature = "async")] +async fn scan_root_boxes_async(reader: &mut R) -> Result, SidxAnalysisError> +where + R: AsyncReadSeek, +{ + let end = reader.seek(SeekFrom::End(0)).await?; + reader.seek(SeekFrom::Start(0)).await?; + + let mut boxes = Vec::new(); + while reader.stream_position().await? < end { + let info = BoxInfo::read_async(reader).await?; + boxes.push(info); + info.seek_to_end_async(reader).await?; + } + + Ok(boxes) +} + fn analyze_init_segment( reader: &mut R, moov: &BoxInfo, @@ -1137,6 +1210,45 @@ where Ok(InitAnalysis { timing_track, trex }) } +#[cfg(feature = "async")] +async fn analyze_init_segment_async( + reader: &mut R, + moov: &BoxInfo, +) -> Result +where + R: AsyncReadSeek, +{ + let mvex = require_single_child_info_async(reader, moov, MVEX).await?; + let traks = extract_boxes_async(reader, Some(moov), &[BoxPath::from([TRAK])]).await?; + if traks.is_empty() { + return Err(SidxAnalysisError::MissingTracks); + } + + let mut tracks = Vec::with_capacity(traks.len()); + for trak in traks { + let tkhd = require_single_child_as_async::<_, Tkhd>(reader, &trak, TKHD).await?; + let mdhd = + require_single_nested_child_as_async::<_, Mdhd>(reader, &trak, MDIA, MDHD).await?; + let handler_type = extract_optional_handler_type_async(reader, &trak).await?; + tracks.push(InitTrackInfo { + track_id: tkhd.track_id, + handler_type, + timescale: mdhd.timescale, + }); + } + + let timing_track = select_timing_track(&tracks)?; + let trex = extract_box_as_async::<_, Trex>(reader, Some(&mvex), BoxPath::from([TREX])) + .await? + .into_iter() + .find(|entry| entry.track_id == timing_track.track_id) + .ok_or(SidxAnalysisError::MissingTrackExtends { + track_id: timing_track.track_id, + })?; + + Ok(InitAnalysis { timing_track, trex }) +} + fn select_timing_track(tracks: &[InitTrackInfo]) -> Result { let track = tracks .iter() @@ -1176,6 +1288,32 @@ where } } +#[cfg(feature = "async")] +async fn extract_optional_handler_type_async( + reader: &mut R, + trak: &BoxInfo, +) -> Result, SidxAnalysisError> +where + R: AsyncReadSeek, +{ + let handlers = extract_box_as_async::<_, crate::boxes::iso14496_12::Hdlr>( + reader, + Some(trak), + BoxPath::from([MDIA, HDLR]), + ) + .await?; + match handlers.len() { + 0 => Ok(None), + 1 => Ok(Some(handlers[0].handler_type)), + count => Err(SidxAnalysisError::UnexpectedChildCount { + parent_box_type: trak.box_type(), + parent_offset: trak.offset(), + child_box_type: HDLR, + count, + }), + } +} + fn require_single_child_info( reader: &mut R, parent: &BoxInfo, @@ -1201,6 +1339,33 @@ where } } +#[cfg(feature = "async")] +async fn require_single_child_info_async( + reader: &mut R, + parent: &BoxInfo, + child_box_type: FourCc, +) -> Result +where + R: AsyncReadSeek, +{ + let infos = + extract_boxes_async(reader, Some(parent), &[BoxPath::from([child_box_type])]).await?; + match infos.len() { + 0 => Err(SidxAnalysisError::MissingRequiredChild { + parent_box_type: parent.box_type(), + parent_offset: parent.offset(), + child_box_type, + }), + 1 => Ok(infos[0]), + count => Err(SidxAnalysisError::UnexpectedChildCount { + parent_box_type: parent.box_type(), + parent_offset: parent.offset(), + child_box_type, + count, + }), + } +} + fn require_single_child_as( reader: &mut R, parent: &BoxInfo, @@ -1227,6 +1392,34 @@ where } } +#[cfg(feature = "async")] +async fn require_single_child_as_async( + reader: &mut R, + parent: &BoxInfo, + child_box_type: FourCc, +) -> Result +where + R: AsyncReadSeek, + B: CodecBox + Clone + 'static, +{ + let boxes = + extract_box_as_async::<_, B>(reader, Some(parent), BoxPath::from([child_box_type])).await?; + match boxes.len() { + 0 => Err(SidxAnalysisError::MissingRequiredChild { + parent_box_type: parent.box_type(), + parent_offset: parent.offset(), + child_box_type, + }), + 1 => Ok(boxes.into_iter().next().unwrap()), + count => Err(SidxAnalysisError::UnexpectedChildCount { + parent_box_type: parent.box_type(), + parent_offset: parent.offset(), + child_box_type, + count, + }), + } +} + fn require_single_nested_child_as( reader: &mut R, parent: &BoxInfo, @@ -1258,6 +1451,39 @@ where } } +#[cfg(feature = "async")] +async fn require_single_nested_child_as_async( + reader: &mut R, + parent: &BoxInfo, + intermediate_box_type: FourCc, + child_box_type: FourCc, +) -> Result +where + R: AsyncReadSeek, + B: CodecBox + Clone + 'static, +{ + let boxes = extract_box_as_async::<_, B>( + reader, + Some(parent), + BoxPath::from([intermediate_box_type, child_box_type]), + ) + .await?; + match boxes.len() { + 0 => Err(SidxAnalysisError::MissingRequiredChild { + parent_box_type: parent.box_type(), + parent_offset: parent.offset(), + child_box_type, + }), + 1 => Ok(boxes.into_iter().next().unwrap()), + count => Err(SidxAnalysisError::UnexpectedChildCount { + parent_box_type: parent.box_type(), + parent_offset: parent.offset(), + child_box_type, + count, + }), + } +} + fn group_media_segments( reader: &mut R, root_boxes: &[BoxInfo], @@ -1329,6 +1555,78 @@ where Ok((segments, existing_top_level_sidxs)) } +#[cfg(feature = "async")] +async fn group_media_segments_async( + reader: &mut R, + root_boxes: &[BoxInfo], +) -> Result<(Vec, Vec), SidxAnalysisError> +where + R: AsyncReadSeek, +{ + let mut segments = Vec::new(); + let mut existing_top_level_sidxs = Vec::new(); + let mut previous_box_type = None; + + for info in root_boxes { + match info.box_type() { + STYP => { + start_segment(&mut segments, *info); + add_segment_size( + segments.last_mut().unwrap(), + info.size(), + "media segment size", + )?; + } + SIDX => { + if segments.is_empty() && previous_box_type != Some(MDAT) { + let decoded = read_payload_as_async::<_, Sidx>(reader, info).await?; + let internal = analyze_existing_top_level_sidx(info, &decoded)?; + existing_top_level_sidxs.push(internal); + } else if previous_box_type == Some(STYP) + && segments.last().is_some_and(is_pending_styp_prelude_segment) + { + let decoded = read_payload_as_async::<_, Sidx>(reader, info).await?; + let internal = analyze_existing_top_level_sidx(info, &decoded)?; + existing_top_level_sidxs.push(internal); + segments.pop(); + } else if previous_box_type == Some(MDAT) { + start_segment(&mut segments, *info); + let segment = segments.last_mut().unwrap(); + segment.segment_sidx_count = 1; + add_segment_size(segment, info.size(), "media segment size")?; + } else if let Some(segment) = segments.last_mut() { + if segment.segment_sidx_count == 0 { + add_segment_size(segment, info.size(), "media segment size")?; + } + segment.segment_sidx_count += 1; + } + } + EMSG | MOOF => { + if should_start_segment(info.offset(), segments.len(), &existing_top_level_sidxs) { + start_segment(&mut segments, *info); + } + + if let Some(segment) = segments.last_mut() { + add_segment_size(segment, info.size(), "media segment size")?; + if info.box_type() == MOOF { + segment.moofs.push(*info); + } + } + } + MDAT => { + if let Some(segment) = segments.last_mut() { + add_segment_size(segment, info.size(), "media segment size")?; + } + } + _ => {} + } + + previous_box_type = Some(info.box_type()); + } + + Ok((segments, existing_top_level_sidxs)) +} + fn is_pending_styp_prelude_segment(segment: &SegmentAccumulator) -> bool { segment.first_box.box_type() == STYP && segment.moofs.is_empty() @@ -1512,6 +1810,109 @@ where }) } +#[cfg(feature = "async")] +async fn analyze_segment_async( + reader: &mut R, + segment_index: usize, + segment: &SegmentAccumulator, + timing_track_id: u32, + trex: &Trex, +) -> Result +where + R: AsyncReadSeek, +{ + let first_moof = + segment + .moofs + .first() + .copied() + .ok_or(SidxAnalysisError::SegmentWithoutMovieFragment { + segment_index, + segment_offset: segment.first_box.offset(), + })?; + + let mut base_decode_time = 0_u64; + let mut first_composition_time_offset = 0_i64; + let mut duration = 0_u64; + let mut timing_fragment_count = 0_usize; + let mut matched_any_fragment = false; + + for (fragment_index, moof) in segment.moofs.iter().enumerate() { + let trafs = extract_boxes_async(reader, Some(moof), &[BoxPath::from([TRAF])]).await?; + let mut matched_timing_fragment = false; + + for traf in trafs { + let tfhd = require_single_child_as_async::<_, Tfhd>(reader, &traf, TFHD).await?; + if tfhd.track_id != timing_track_id { + continue; + } + + let tfdt = require_single_child_as_async::<_, Tfdt>(reader, &traf, TFDT) + .await + .map_err(|error| match error { + SidxAnalysisError::MissingRequiredChild { .. } => { + SidxAnalysisError::MissingTrackFragmentDecodeTime { + segment_index, + moof_offset: moof.offset(), + track_id: timing_track_id, + } + } + other => other, + })?; + let truns = + extract_box_as_async::<_, Trun>(reader, Some(&traf), BoxPath::from([TRUN])).await?; + + if !matched_timing_fragment { + timing_fragment_count += 1; + matched_timing_fragment = true; + } + matched_any_fragment = true; + + if fragment_index == 0 { + base_decode_time = tfdt.base_media_decode_time(); + } + + for (trun_index, trun) in truns.iter().enumerate() { + validate_trun_sample_count(trun, moof, timing_track_id)?; + + if fragment_index == 0 && trun_index == 0 && trun.sample_count > 0 { + first_composition_time_offset = effective_first_composition_time_offset(trun)?; + } + + duration = checked_add( + duration, + effective_trun_duration(trun, &tfhd, trex), + "segment duration", + )?; + } + } + } + + if !matched_any_fragment { + return Err(SidxAnalysisError::MissingTimingTrackFragments { + segment_index, + track_id: timing_track_id, + }); + } + + let presentation_time = base_decode_time + .checked_add_signed(first_composition_time_offset) + .ok_or(SidxAnalysisError::NumericOverflow { + field_name: "segment presentation time", + })?; + + Ok(SidxMediaSegment { + first_box: segment.first_box, + first_moof_offset: first_moof.offset(), + moof_count: segment.moofs.len(), + timing_fragment_count, + presentation_time, + base_decode_time, + duration, + size: segment.size, + }) +} + fn validate_trun_sample_count( trun: &Trun, moof: &BoxInfo, @@ -1590,6 +1991,19 @@ where Ok(decoded) } +#[cfg(feature = "async")] +async fn read_payload_as_async(reader: &mut R, info: &BoxInfo) -> Result +where + R: AsyncReadSeek, + B: CodecBox + Default + Send, +{ + info.seek_to_payload_async(reader).await?; + let payload_size = info.payload_size()?; + let mut decoded = B::default(); + unmarshal_async(reader, payload_size, &mut decoded, None).await?; + Ok(decoded) +} + fn add_segment_size( segment: &mut SegmentAccumulator, size: u64, @@ -1604,18 +2018,6 @@ fn checked_add(lhs: u64, rhs: u64, field_name: &'static str) -> Result(reader: &mut R) -> Result, SidxAnalysisError> -where - R: AsyncReadSeek, -{ - reader.seek(SeekFrom::Start(0)).await?; - let mut bytes = Vec::new(); - reader.read_to_end(&mut bytes).await?; - reader.seek(SeekFrom::Start(0)).await?; - Ok(bytes) -} - fn encoded_payload_size(sidx: &Sidx) -> Result { let mut payload = Vec::new(); marshal(&mut payload, sidx, None)?; diff --git a/src/walk.rs b/src/walk.rs index 8d7912e..656a769 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -14,10 +14,12 @@ use crate::FourCc; #[cfg(feature = "async")] use crate::async_io::{AsyncReadSeek, AsyncWrite}; use crate::boxes::iso14496_12::{ - Ftyp, VisualSampleEntry, split_box_children_with_optional_trailing_bytes, + AudioSampleEntry, Ftyp, VisualSampleEntry, split_box_children_with_optional_trailing_bytes, }; use crate::boxes::metadata::Keys; use crate::boxes::{BoxLookupContext, BoxRegistry, default_registry}; +#[cfg(feature = "async")] +use crate::codec::unmarshal_any_with_context_async; use crate::codec::{CodecError, DynCodecBox, unmarshal, unmarshal_any_with_context}; use crate::fourcc::ParseFourCcError; use crate::header::{BoxInfo, HeaderError, SMALL_HEADER_SIZE}; @@ -358,6 +360,7 @@ where /// Decodes the current payload into a descriptor-backed runtime box value. pub fn read_payload(&mut self) -> Result<(Box, u64), WalkError> { + validate_box_fits_stream(self.reader, &self.info)?; self.info.seek_to_payload(self.reader)?; let payload_size = self.info.payload_size()?; let (boxed, read) = unmarshal_any_with_context( @@ -383,6 +386,7 @@ where where W: Write, { + validate_box_fits_stream(self.reader, &self.info)?; self.info.seek_to_payload(self.reader)?; let payload_size = self.info.payload_size()?; let mut limited = (&mut *self.reader).take(payload_size); @@ -431,32 +435,28 @@ where /// Decodes the current payload into a descriptor-backed runtime box value. pub async fn read_payload_async(&mut self) -> Result<(Box, u64), WalkError> { + validate_box_fits_stream_async(self.reader, &self.info).await?; self.info.seek_to_payload_async(self.reader).await?; let payload_size = self.info.payload_size()?; - let payload = crate::codec::read_exact_vec_untrusted_async( + let (boxed, read) = unmarshal_any_with_context_async( self.reader, - usize::try_from(payload_size) - .map_err(|_| io::Error::from(io::ErrorKind::OutOfMemory))?, - ) - .await?; - self.info.seek_to_payload_async(self.reader).await?; - - let mut payload_reader = std::io::Cursor::new(payload.as_slice()); - let (boxed, read) = crate::codec::unmarshal_any_with_context( - &mut payload_reader, payload_size, self.info.box_type(), self.registry, self.info.lookup_context(), None, - )?; - self.children_layout = Some(children_layout_for_buffered_payload( - &self.info, - payload_size, - read, - boxed.as_any().is::(), - &payload, - )?); + ) + .await?; + self.children_layout = Some( + children_layout_for_payload_async( + self.reader, + &self.info, + payload_size, + read, + boxed.as_ref(), + ) + .await?, + ); Ok((boxed, read)) } @@ -465,6 +465,7 @@ where where W: AsyncWrite + Unpin, { + validate_box_fits_stream_async(self.reader, &self.info).await?; self.info.seek_to_payload_async(self.reader).await?; let payload_size = self.info.payload_size()?; let mut limited = (&mut *self.reader).take(payload_size); @@ -551,6 +552,7 @@ where &mut visitor, &mut parent, &BoxPath::default(), + false, ) } @@ -631,6 +633,7 @@ where &mut visitor, &mut parent, &BoxPath::default(), + false, ) .await } @@ -674,7 +677,7 @@ where } info.set_lookup_context(sibling_lookup_context); - walk_box(reader, registry, visitor, &mut info, path)?; + walk_box(reader, registry, visitor, &mut info, path, is_root)?; if info.lookup_context().is_quicktime_compatible() { sibling_lookup_context = sibling_lookup_context.with_quicktime_compatible(true); @@ -698,6 +701,7 @@ fn walk_box( visitor: &mut F, info: &mut BoxInfo, path: &BoxPath, + is_root: bool, ) -> Result<(), WalkError> where R: Read + Seek, @@ -733,7 +737,7 @@ where )?; } - handle.info.seek_to_end(handle.reader)?; + seek_to_box_end(handle.reader, &handle.info, is_root)?; Ok(()) } @@ -779,7 +783,7 @@ where } info.set_lookup_context(sibling_lookup_context); - walk_box_async(reader, registry, visitor, &mut info, path).await?; + walk_box_async(reader, registry, visitor, &mut info, path, is_root).await?; if info.lookup_context().is_quicktime_compatible() { sibling_lookup_context = sibling_lookup_context.with_quicktime_compatible(true); @@ -804,6 +808,7 @@ async fn walk_box_async( visitor: &mut V, info: &mut BoxInfo, path: &BoxPath, + is_root: bool, ) -> Result<(), WalkError> where R: AsyncReadSeek, @@ -847,7 +852,7 @@ where } let info = handle.info; - info.seek_to_end_async(handle.reader).await?; + seek_to_box_end_async(handle.reader, &info, is_root).await?; Ok(()) } @@ -862,7 +867,7 @@ where R: Read + Seek, { let offset = info.offset() + info.header_size() + payload_read; - let size = if payload.as_any().is::() { + let size = if payload_uses_optional_trailing_bytes(payload) { visual_sample_entry_child_payload_size( reader, offset, @@ -876,21 +881,24 @@ where } #[cfg(feature = "async")] -fn children_layout_for_buffered_payload( +async fn children_layout_for_payload_async( + reader: &mut R, info: &BoxInfo, payload_size: u64, payload_read: u64, - is_visual_sample_entry: bool, - payload: &[u8], -) -> Result { + payload: &dyn DynCodecBox, +) -> Result +where + R: AsyncReadSeek, +{ let offset = info.offset() + info.header_size() + payload_read; - let size = if is_visual_sample_entry { - let payload_read = usize::try_from(payload_read) - .map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?; - let remaining = payload - .get(payload_read..) - .ok_or_else(|| io::Error::from(io::ErrorKind::UnexpectedEof))?; - split_box_children_with_optional_trailing_bytes(remaining) as u64 + let size = if payload_uses_optional_trailing_bytes(payload) { + visual_sample_entry_child_payload_size_async( + reader, + offset, + payload_size.saturating_sub(payload_read), + ) + .await? } else { payload_size.saturating_sub(payload_read) }; @@ -898,6 +906,10 @@ fn children_layout_for_buffered_payload( Ok(ChildrenLayout { offset, size }) } +fn payload_uses_optional_trailing_bytes(payload: &dyn DynCodecBox) -> bool { + payload.as_any().is::() || payload.as_any().is::() +} + fn visual_sample_entry_child_payload_size( reader: &mut R, extension_offset: u64, @@ -925,6 +937,38 @@ where Ok(bytes) } +#[cfg(feature = "async")] +async fn visual_sample_entry_child_payload_size_async( + reader: &mut R, + extension_offset: u64, + extension_size: u64, +) -> Result +where + R: AsyncReadSeek, +{ + let checkpoint = reader.stream_position().await?; + reader.seek(SeekFrom::Start(extension_offset)).await?; + let bytes = read_extension_bytes_async(reader, extension_size).await?; + reader.seek(SeekFrom::Start(checkpoint)).await?; + Ok(split_box_children_with_optional_trailing_bytes(&bytes) as u64) +} + +#[cfg(feature = "async")] +async fn read_extension_bytes_async( + reader: &mut R, + extension_size: u64, +) -> Result, WalkError> +where + R: AsyncReadSeek, +{ + let extension_len = usize::try_from(extension_size).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "payload extension is too large") + })?; + let mut bytes = vec![0; extension_len]; + reader.read_exact(&mut bytes).await?; + Ok(bytes) +} + fn inspect_context_carriers( reader: &mut R, info: &mut BoxInfo, @@ -1012,7 +1056,51 @@ where } let end = reader.seek(SeekFrom::End(0))?; - Ok(start == end) + Ok(start >= end) +} + +fn validate_box_fits_stream(reader: &mut R, info: &BoxInfo) -> Result<(), WalkError> +where + R: Seek, +{ + let position = reader.stream_position()?; + let stream_len = reader.seek(SeekFrom::End(0))?; + reader.seek(SeekFrom::Start(position))?; + validate_box_end_within_stream(info, stream_len) +} + +fn validate_box_end_within_stream(info: &BoxInfo, stream_len: u64) -> Result<(), WalkError> { + let end = checked_box_end(info)?; + if end > stream_len { + return Err(WalkError::UnexpectedEof); + } + Ok(()) +} + +fn checked_box_end(info: &BoxInfo) -> Result { + info.offset() + .checked_add(info.size()) + .ok_or(WalkError::UnexpectedEof) +} + +fn seek_to_box_end( + reader: &mut R, + info: &BoxInfo, + clamp_to_stream_end: bool, +) -> Result +where + R: Seek, +{ + let end = checked_box_end(info)?; + let target = if clamp_to_stream_end { + let position = reader.stream_position()?; + let stream_len = reader.seek(SeekFrom::End(0))?; + reader.seek(SeekFrom::Start(position))?; + end.min(stream_len) + } else { + end + }; + reader.seek(SeekFrom::Start(target)).map_err(WalkError::Io) } #[cfg(feature = "async")] @@ -1029,7 +1117,42 @@ where } let end = reader.seek(SeekFrom::End(0)).await?; - Ok(start == end) + Ok(start >= end) +} + +#[cfg(feature = "async")] +async fn validate_box_fits_stream_async(reader: &mut R, info: &BoxInfo) -> Result<(), WalkError> +where + R: AsyncReadSeek, +{ + let position = reader.stream_position().await?; + let stream_len = reader.seek(SeekFrom::End(0)).await?; + reader.seek(SeekFrom::Start(position)).await?; + validate_box_end_within_stream(info, stream_len) +} + +#[cfg(feature = "async")] +async fn seek_to_box_end_async( + reader: &mut R, + info: &BoxInfo, + clamp_to_stream_end: bool, +) -> Result +where + R: AsyncReadSeek, +{ + let end = checked_box_end(info)?; + let target = if clamp_to_stream_end { + let position = reader.stream_position().await?; + let stream_len = reader.seek(SeekFrom::End(0)).await?; + reader.seek(SeekFrom::Start(position)).await?; + end.min(stream_len) + } else { + end + }; + reader + .seek(SeekFrom::Start(target)) + .await + .map_err(WalkError::Io) } /// Errors raised while walking a box tree. diff --git a/tests/async_feature_gate.rs b/tests/async_feature_gate.rs index 1b24f8c..ec413a0 100644 --- a/tests/async_feature_gate.rs +++ b/tests/async_feature_gate.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use mp4forge::FourCc; -use mp4forge::async_io::{AsyncReadSeek, AsyncWriteSeek}; +use mp4forge::async_io::{AsyncReadForward, AsyncReadSeek, AsyncWriteForward, AsyncWriteSeek}; use mp4forge::boxes::iso14496_12::Ftyp; use mp4forge::codec::{marshal_async, unmarshal_async}; use mp4forge::header::BoxInfo; @@ -19,15 +19,21 @@ use tokio::fs::File as TokioFile; fn assert_async_read_seek(_value: &mut T) {} +fn assert_async_read_forward(_value: &mut T) {} + fn assert_async_write_seek(_value: &mut T) {} +fn assert_async_write_forward(_value: &mut T) {} + #[test] fn cursor_satisfies_async_seek_aliases() { let mut reader = Cursor::new(vec![0_u8; 4]); assert_async_read_seek(&mut reader); + assert_async_read_forward(&mut reader); let mut writer = Cursor::new(Vec::::new()); assert_async_write_seek(&mut writer); + assert_async_write_forward(&mut writer); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/tests/box_catalog_3gpp.rs b/tests/box_catalog_3gpp.rs index bb7adb1..38824f8 100644 --- a/tests/box_catalog_3gpp.rs +++ b/tests/box_catalog_3gpp.rs @@ -1,7 +1,7 @@ use std::io::Cursor; use mp4forge::FourCc; -use mp4forge::boxes::threegpp::Udta3gppString; +use mp4forge::boxes::threegpp::{D263, Damr, Devc, Dqcp, Dsmv, Udta3gppString}; use mp4forge::boxes::{AnyTypeBox, default_registry}; use mp4forge::codec::{CodecError, ImmutableBox, marshal, unmarshal, unmarshal_any}; use mp4forge::stringify::stringify; @@ -90,3 +90,136 @@ fn built_in_registry_only_registers_flat_safe_threegpp_types() { } } } + +#[test] +fn damr_roundtrips_and_is_registered() { + let payload = [0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x81, 0x03, 0x01]; + let src = Damr { + vendor: 0, + decoder_version: 2, + mode_set: 0x0081, + mode_change_period: 3, + frames_per_sample: 1, + }; + let expected = "Vendor=0 DecoderVersion=2 ModeSet=0x81 ModeChangePeriod=3 FramesPerSample=1"; + + let mut encoded = Vec::new(); + let written = marshal(&mut encoded, &src, None).unwrap(); + assert_eq!(written, payload.len() as u64); + assert_eq!(encoded, payload); + + let mut decoded = Damr::default(); + let mut reader = Cursor::new(payload.to_vec()); + let read = unmarshal(&mut reader, payload.len() as u64, &mut decoded, None).unwrap(); + assert_eq!(read, payload.len() as u64); + assert_eq!(decoded, src); + + let registry = default_registry(); + assert!(registry.is_registered(FourCc::from_bytes(*b"damr"))); + let mut any_reader = Cursor::new(payload.to_vec()); + let (any_box, any_read) = unmarshal_any( + &mut any_reader, + payload.len() as u64, + FourCc::from_bytes(*b"damr"), + ®istry, + None, + ) + .unwrap(); + assert_eq!(any_read, payload.len() as u64); + assert_eq!(any_box.as_any().downcast_ref::().unwrap(), &src); + assert_eq!(stringify(&src, None).unwrap(), expected); +} + +fn assert_voice_decoder_config_roundtrip( + box_type: FourCc, + src: T, + payload: &[u8], + expected: &str, +) where + T: Default + + PartialEq + + std::fmt::Debug + + ImmutableBox + + mp4forge::codec::MutableBox + + mp4forge::codec::FieldValueRead + + mp4forge::codec::FieldValueWrite + + mp4forge::codec::CodecBox + + 'static, +{ + let mut encoded = Vec::new(); + let written = marshal(&mut encoded, &src, None).unwrap(); + assert_eq!(written, payload.len() as u64); + assert_eq!(encoded, payload); + + let mut decoded = T::default(); + let mut reader = Cursor::new(payload.to_vec()); + let read = unmarshal(&mut reader, payload.len() as u64, &mut decoded, None).unwrap(); + assert_eq!(read, payload.len() as u64); + assert_eq!(decoded, src); + + let registry = default_registry(); + assert!(registry.is_registered(box_type)); + let mut any_reader = Cursor::new(payload.to_vec()); + let (any_box, any_read) = unmarshal_any( + &mut any_reader, + payload.len() as u64, + box_type, + ®istry, + None, + ) + .unwrap(); + assert_eq!(any_read, payload.len() as u64); + assert_eq!(any_box.as_any().downcast_ref::().unwrap(), &src); + assert_eq!(stringify(&src, None).unwrap(), expected); +} + +#[test] +fn voice_decoder_config_boxes_roundtrip_and_are_registered() { + assert_voice_decoder_config_roundtrip( + FourCc::from_bytes(*b"dqcp"), + Dqcp { + vendor: 0, + decoder_version: 1, + frames_per_sample: 1, + }, + &[0, 0, 0, 0, 1, 1], + "Vendor=0 DecoderVersion=1 FramesPerSample=1", + ); + assert_voice_decoder_config_roundtrip( + FourCc::from_bytes(*b"devc"), + Devc { + vendor: 0, + decoder_version: 2, + frames_per_sample: 3, + }, + &[0, 0, 0, 0, 2, 3], + "Vendor=0 DecoderVersion=2 FramesPerSample=3", + ); + assert_voice_decoder_config_roundtrip( + FourCc::from_bytes(*b"dsmv"), + Dsmv { + vendor: 0, + decoder_version: 4, + frames_per_sample: 1, + }, + &[0, 0, 0, 0, 4, 1], + "Vendor=0 DecoderVersion=4 FramesPerSample=1", + ); +} + +#[test] +fn d263_roundtrips_and_is_registered() { + let payload = [0, 0, 0, 0, 1, 10, 0]; + let src = D263 { + vendor: 0, + decoder_version: 1, + h263_level: 10, + h263_profile: 0, + }; + assert_voice_decoder_config_roundtrip( + FourCc::from_bytes(*b"d263"), + src, + &payload, + "Vendor=0 DecoderVersion=1 H263Level=10 H263Profile=0", + ); +} diff --git a/tests/box_catalog_dolby.rs b/tests/box_catalog_dolby.rs new file mode 100644 index 0000000..f0af357 --- /dev/null +++ b/tests/box_catalog_dolby.rs @@ -0,0 +1,181 @@ +use std::any::type_name; +use std::fmt::Debug; +use std::io::Cursor; + +use mp4forge::FourCc; +use mp4forge::boxes::dolby::Dmlp; +use mp4forge::boxes::iso14496_12::{AudioSampleEntry, SampleEntry}; +use mp4forge::boxes::{AnyTypeBox, default_registry}; +use mp4forge::codec::{CodecBox, marshal, unmarshal, unmarshal_any}; +use mp4forge::stringify::stringify; + +fn assert_box_roundtrip(src: T, payload: &[u8], expected: &str) +where + T: CodecBox + Default + PartialEq + Debug + 'static, +{ + let mut encoded = Vec::new(); + let written = marshal(&mut encoded, &src, None).unwrap(); + assert_eq!( + written, + payload.len() as u64, + "marshal length for {}", + type_name::() + ); + assert_eq!(encoded, payload, "marshal bytes for {}", type_name::()); + + let mut decoded = T::default(); + let mut reader = Cursor::new(payload.to_vec()); + let read = unmarshal(&mut reader, payload.len() as u64, &mut decoded, None).unwrap(); + assert_eq!( + read, + payload.len() as u64, + "unmarshal length for {}", + type_name::() + ); + assert_eq!(decoded, src, "unmarshal value for {}", type_name::()); + + let registry = default_registry(); + let mut any_reader = Cursor::new(payload.to_vec()); + let (any_box, any_read) = unmarshal_any( + &mut any_reader, + payload.len() as u64, + src.box_type(), + ®istry, + None, + ) + .unwrap(); + assert_eq!( + any_read, + payload.len() as u64, + "registry unmarshal length for {}", + type_name::() + ); + assert_eq!(any_box.as_any().downcast_ref::().unwrap(), &src); + + assert_eq!(stringify(&src, None).unwrap(), expected); +} + +fn assert_any_box_roundtrip(src: T, payload: &[u8], expected: &str) +where + T: CodecBox + AnyTypeBox + Default + PartialEq + Debug + 'static, +{ + let mut encoded = Vec::new(); + let written = marshal(&mut encoded, &src, None).unwrap(); + assert_eq!( + written, + payload.len() as u64, + "marshal length for {}", + type_name::() + ); + assert_eq!(encoded, payload, "marshal bytes for {}", type_name::()); + + let mut decoded = T::default(); + decoded.set_box_type(src.box_type()); + let mut reader = Cursor::new(payload.to_vec()); + let read = unmarshal(&mut reader, payload.len() as u64, &mut decoded, None).unwrap(); + assert_eq!( + read, + payload.len() as u64, + "unmarshal length for {}", + type_name::() + ); + assert_eq!(decoded, src, "unmarshal value for {}", type_name::()); + + let registry = default_registry(); + let mut any_reader = Cursor::new(payload.to_vec()); + let (any_box, any_read) = unmarshal_any( + &mut any_reader, + payload.len() as u64, + src.box_type(), + ®istry, + None, + ) + .unwrap(); + assert_eq!( + any_read, + payload.len() as u64, + "registry unmarshal length for {}", + type_name::() + ); + assert_eq!(any_box.as_any().downcast_ref::().unwrap(), &src); + + assert_eq!(stringify(&src, None).unwrap(), expected); +} + +#[test] +fn dolby_catalog_roundtrips() { + assert_any_box_roundtrip( + AudioSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"mlpa"), + data_reference_index: 1, + }, + entry_version: 0, + channel_count: 2, + sample_size: 16, + pre_defined: 0, + sample_rate: 48_000 << 16, + quicktime_data: Vec::new(), + }, + &[ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x01, // + 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x02, // + 0x00, 0x10, // + 0x00, 0x00, // + 0x00, 0x00, // + 0xbb, 0x80, 0x00, 0x00, + ], + "DataReferenceIndex=1 EntryVersion=0 ChannelCount=2 SampleSize=16 PreDefined=0 SampleRate=48000", + ); + + assert_box_roundtrip( + Dmlp { + format_info: 0x1234_5678, + peak_data_rate: 0x2345, + }, + &[ + 0x12, 0x34, 0x56, 0x78, // + 0x23, 0x45, // + 0x00, 0x00, 0x00, 0x00, + ], + "FormatInfo=305419896 PeakDataRate=9029", + ); +} + +#[test] +fn built_in_registry_reports_supported_versions_for_landed_dolby_types() { + let registry = default_registry(); + + assert_eq!( + registry.supported_versions(FourCc::from_bytes(*b"mlpa")), + Some(&[][..]) + ); + assert_eq!( + registry.supported_versions(FourCc::from_bytes(*b"dmlp")), + Some(&[][..]) + ); + assert!(registry.is_supported_version(FourCc::from_bytes(*b"mlpa"), 7)); + assert!(registry.is_supported_version(FourCc::from_bytes(*b"dmlp"), 0)); + assert!(registry.is_registered(FourCc::from_bytes(*b"mlpa"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"dmlp"))); +} + +#[test] +fn dmlp_rejects_peak_data_rate_that_does_not_fit_in_15_bits() { + let error = marshal( + &mut Vec::new(), + &Dmlp { + format_info: 0, + peak_data_rate: 0x8000, + }, + None, + ) + .unwrap_err(); + assert_eq!( + error.to_string(), + "invalid field value for PeakDataRate: value does not fit in 15 bits" + ); +} diff --git a/tests/box_catalog_dts.rs b/tests/box_catalog_dts.rs new file mode 100644 index 0000000..9e93a90 --- /dev/null +++ b/tests/box_catalog_dts.rs @@ -0,0 +1,172 @@ +use std::any::type_name; +use std::fmt::Debug; +use std::io::Cursor; + +use mp4forge::boxes::default_registry; +use mp4forge::boxes::dts::{Ddts, Udts}; +use mp4forge::codec::{CodecBox, ImmutableBox, marshal, unmarshal, unmarshal_any}; +#[cfg(feature = "async")] +use mp4forge::codec::{marshal_async, unmarshal_any_async, unmarshal_async}; + +fn assert_box_roundtrip(src: T, payload: &[u8]) +where + T: CodecBox + Default + PartialEq + Debug + 'static, +{ + let mut encoded = Vec::new(); + let written = marshal(&mut encoded, &src, None).unwrap(); + assert_eq!( + written, + payload.len() as u64, + "marshal length for {}", + type_name::() + ); + assert_eq!(encoded, payload, "marshal bytes for {}", type_name::()); + + let mut decoded = T::default(); + let mut reader = Cursor::new(payload.to_vec()); + let read = unmarshal(&mut reader, payload.len() as u64, &mut decoded, None).unwrap(); + assert_eq!( + read, + payload.len() as u64, + "unmarshal length for {}", + type_name::() + ); + assert_eq!(decoded, src, "unmarshal value for {}", type_name::()); + + let registry = default_registry(); + let mut any_reader = Cursor::new(payload.to_vec()); + let (any_box, any_read) = unmarshal_any( + &mut any_reader, + payload.len() as u64, + src.box_type(), + ®istry, + None, + ) + .unwrap(); + assert_eq!( + any_read, + payload.len() as u64, + "registry unmarshal length for {}", + type_name::() + ); + assert_eq!(any_box.as_any().downcast_ref::().unwrap(), &src); +} + +#[test] +fn dts_catalog_roundtrips_ddts() { + assert_box_roundtrip( + Ddts { + sampling_frequency: 48_000, + max_bitrate: 1_536_000, + avg_bitrate: 768_000, + sample_depth: 16, + frame_duration: 1, + core_size: 1_024, + channel_layout: 3, + ..Ddts::default() + }, + &[ + 0x00, 0x00, 0xbb, 0x80, 0x00, 0x17, 0x70, 0x00, 0x00, 0x0b, 0xb8, 0x00, 0x10, 0x40, + 0x00, 0x40, 0x00, 0x00, 0x03, 0x00, + ], + ); +} + +#[test] +fn dts_catalog_roundtrips_udts() { + assert_box_roundtrip( + Udts { + decoder_profile_code: 1, + frame_duration_code: 1, + max_payload_code: 1, + num_presentations_code: 5, + channel_mask: 3, + id_tag_present: vec![false; 6], + ..Udts::default() + }, + &[0x05, 0x25, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00], + ); +} + +#[cfg(feature = "async")] +#[tokio::test] +async fn async_dts_catalog_roundtrips() { + let ddts = Ddts { + sampling_frequency: 48_000, + max_bitrate: 1_536_000, + avg_bitrate: 768_000, + sample_depth: 16, + frame_duration: 1, + core_size: 1_024, + channel_layout: 3, + ..Ddts::default() + }; + let ddts_payload = vec![ + 0x00, 0x00, 0xbb, 0x80, 0x00, 0x17, 0x70, 0x00, 0x00, 0x0b, 0xb8, 0x00, 0x10, 0x40, 0x00, + 0x40, 0x00, 0x00, 0x03, 0x00, + ]; + + let mut ddts_writer = Cursor::new(Vec::new()); + let ddts_written = marshal_async(&mut ddts_writer, &ddts, None).await.unwrap(); + assert_eq!(ddts_written, ddts_payload.len() as u64); + assert_eq!(ddts_writer.into_inner(), ddts_payload); + + let mut ddts_reader = Cursor::new(ddts_payload.clone()); + let mut decoded_ddts = Ddts::default(); + let ddts_read = unmarshal_async( + &mut ddts_reader, + ddts_payload.len() as u64, + &mut decoded_ddts, + None, + ) + .await + .unwrap(); + assert_eq!(ddts_read, ddts_payload.len() as u64); + assert_eq!(decoded_ddts, ddts); + + let registry = default_registry(); + let mut any_ddts_reader = Cursor::new(ddts_payload); + let (any_ddts_box, any_ddts_read) = + unmarshal_any_async(&mut any_ddts_reader, 20, ddts.box_type(), ®istry, None) + .await + .unwrap(); + assert_eq!(any_ddts_read, 20); + assert_eq!(any_ddts_box.as_any().downcast_ref::().unwrap(), &ddts); + + let udts = Udts { + decoder_profile_code: 1, + frame_duration_code: 1, + max_payload_code: 1, + num_presentations_code: 5, + channel_mask: 3, + id_tag_present: vec![false; 6], + ..Udts::default() + }; + let udts_payload = vec![0x05, 0x25, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00]; + + let mut udts_writer = Cursor::new(Vec::new()); + let udts_written = marshal_async(&mut udts_writer, &udts, None).await.unwrap(); + assert_eq!(udts_written, udts_payload.len() as u64); + assert_eq!(udts_writer.into_inner(), udts_payload); + + let mut udts_reader = Cursor::new(udts_payload.clone()); + let mut decoded_udts = Udts::default(); + let udts_read = unmarshal_async( + &mut udts_reader, + udts_payload.len() as u64, + &mut decoded_udts, + None, + ) + .await + .unwrap(); + assert_eq!(udts_read, udts_payload.len() as u64); + assert_eq!(decoded_udts, udts); + + let mut any_udts_reader = Cursor::new(udts_payload); + let (any_udts_box, any_udts_read) = + unmarshal_any_async(&mut any_udts_reader, 8, udts.box_type(), ®istry, None) + .await + .unwrap(); + assert_eq!(any_udts_read, 8); + assert_eq!(any_udts_box.as_any().downcast_ref::().unwrap(), &udts); +} diff --git a/tests/box_catalog_flac.rs b/tests/box_catalog_flac.rs index 6dc3c05..5ceddd1 100644 --- a/tests/box_catalog_flac.rs +++ b/tests/box_catalog_flac.rs @@ -189,17 +189,16 @@ fn dfla_rejects_block_length_mismatch_during_marshal() { } #[test] -fn dfla_rejects_missing_final_metadata_flag_during_unmarshal() { +fn dfla_normalizes_missing_final_metadata_flag_during_unmarshal() { let mut decoded = DfLa::default(); - let error = unmarshal( + let read = unmarshal( &mut Cursor::new(vec![0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), 8, &mut decoded, None, ) - .unwrap_err(); - assert_eq!( - error.to_string(), - "invalid field value for MetadataBlocks: final metadata block flag must be set" - ); + .unwrap(); + assert_eq!(read, 8); + assert_eq!(decoded.metadata_blocks.len(), 1); + assert!(decoded.metadata_blocks[0].last_metadata_block_flag); } diff --git a/tests/box_catalog_iso14496_12.rs b/tests/box_catalog_iso14496_12.rs index 7a0b9f2..9915582 100644 --- a/tests/box_catalog_iso14496_12.rs +++ b/tests/box_catalog_iso14496_12.rs @@ -6,31 +6,32 @@ use std::time::{Duration, UNIX_EPOCH}; use mp4forge::FourCc; use mp4forge::boxes::iso14496_12::{ AVCDecoderConfiguration, AVCParameterSet, AlbumLoudnessInfo, AlternativeStartupEntry, - AlternativeStartupEntryL, AlternativeStartupEntryOpt, AudioSampleEntry, Btrt, Cdat, Cdsc, Clap, - Co64, CoLL, Colr, Cslg, Ctts, CttsEntry, Dinf, Dpnd, Dref, Edts, Elng, Elst, ElstEntry, Emeb, - Emib, Emsg, EventMessageSampleEntry, Fiel, Font, Free, Frma, Ftyp, HEVCDecoderConfiguration, - HEVCNalu, HEVCNaluArray, Hdlr, Hind, Hint, Ipir, Kind, Leva, LevaLevel, LoudnessEntry, - LoudnessMeasurement, Ludt, Mdat, Mdhd, Mdia, Mehd, Meta, Mfhd, Mfra, Mfro, Mime, Minf, Moof, - Moov, Mpod, Mvex, Mvhd, Nmhd, PRFT_NTP_UNIX_EPOCH_OFFSET_SECONDS, - PRFT_TIME_ARBITRARY_CONSISTENT, PRFT_TIME_CAPTURED, PRFT_TIME_ENCODER_INPUT, - PRFT_TIME_ENCODER_OUTPUT, PRFT_TIME_MOOF_FINALIZED, PRFT_TIME_MOOF_WRITTEN, Pasp, Prft, Saio, - Saiz, SampleEntry, Sbgp, SbgpEntry, Schi, Schm, Sdtp, SdtpSampleElem, SeigEntry, SeigEntryL, - Sgpd, Sidx, SidxReference, Silb, SilbEntry, Sinf, Skip, SmDm, Smhd, SphericalVideoV1Metadata, - Ssix, SsixRange, SsixSubsegment, Stbl, Stco, Sthd, Stsc, StscEntry, Stsd, Stss, Stsz, Stts, - SttsEntry, Styp, Subs, SubsEntry, SubsSample, Subt, Sync, TFHD_BASE_DATA_OFFSET_PRESENT, - TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, TRUN_DATA_OFFSET_PRESENT, - TRUN_FIRST_SAMPLE_FLAGS_PRESENT, TRUN_SAMPLE_COMPOSITION_TIME_OFFSET_PRESENT, - TRUN_SAMPLE_DURATION_PRESENT, TRUN_SAMPLE_SIZE_PRESENT, TemporalLevelEntry, - TextSubtitleSampleEntry, Tfdt, Tfhd, Tfra, TfraEntry, Tkhd, TrackLoudnessInfo, Traf, Trak, - Tref, Trep, Trex, Trun, TrunEntry, UUID_FRAGMENT_ABSOLUTE_TIMING, UUID_FRAGMENT_RUN_TABLE, - UUID_SAMPLE_ENCRYPTION, UUID_SPHERICAL_VIDEO_V1, Udta, Uuid, UuidFragmentAbsoluteTiming, - UuidFragmentRunEntry, UuidFragmentRunTable, UuidPayload, Vdep, VisualRandomAccessEntry, - VisualSampleEntry, Vmhd, Vplx, Wave, XMLSubtitleSampleEntry, + AlternativeStartupEntryL, AlternativeStartupEntryOpt, AudioSampleEntry, Btrt, Cdat, Cdsc, Chnl, + Clap, Co64, CoLL, Colr, Cslg, Ctts, CttsEntry, Dinf, Dpnd, Dref, DvsC, Edts, Elng, Elst, + ElstEntry, Emeb, Emib, Emsg, EventMessageSampleEntry, Fiel, Font, Free, Frma, Ftyp, + GenericMediaSampleEntry, HEVCDecoderConfiguration, HEVCNalu, HEVCNaluArray, Hdlr, Hind, Hint, + Ipir, Kind, Leva, LevaLevel, LoudnessEntry, LoudnessMeasurement, Ludt, Mdat, Mdhd, Mdia, Mehd, + Meta, Mfhd, Mfra, Mfro, Mime, Minf, Moof, Moov, Mpod, Mvex, Mvhd, Nmhd, + PRFT_NTP_UNIX_EPOCH_OFFSET_SECONDS, PRFT_TIME_ARBITRARY_CONSISTENT, PRFT_TIME_CAPTURED, + PRFT_TIME_ENCODER_INPUT, PRFT_TIME_ENCODER_OUTPUT, PRFT_TIME_MOOF_FINALIZED, + PRFT_TIME_MOOF_WRITTEN, Padb, Pasp, Prft, Saio, Saiz, SampleEntry, Sbgp, SbgpEntry, Schi, Schm, + Sdtp, SdtpSampleElem, SeigEntry, SeigEntryL, Sgpd, Sidx, SidxReference, Silb, SilbEntry, Sinf, + Skip, SmDm, Smhd, SphericalVideoV1Metadata, Ssix, SsixRange, SsixSubsegment, Stbl, Stco, Sthd, + Stsc, StscEntry, Stsd, Stss, Stsz, Stts, SttsEntry, Styp, Subs, SubsEntry, SubsSample, Subt, + Sync, TFHD_BASE_DATA_OFFSET_PRESENT, TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, + TRUN_DATA_OFFSET_PRESENT, TRUN_FIRST_SAMPLE_FLAGS_PRESENT, + TRUN_SAMPLE_COMPOSITION_TIME_OFFSET_PRESENT, TRUN_SAMPLE_DURATION_PRESENT, + TRUN_SAMPLE_SIZE_PRESENT, TemporalLevelEntry, TextSubtitleSampleEntry, Tfdt, Tfhd, Tfra, + TfraEntry, Tkhd, TrackLoudnessInfo, Traf, Trak, Tref, Trep, Trex, Trun, TrunEntry, + UUID_FRAGMENT_ABSOLUTE_TIMING, UUID_FRAGMENT_RUN_TABLE, UUID_SAMPLE_ENCRYPTION, + UUID_SPHERICAL_VIDEO_V1, Udta, Uuid, UuidFragmentAbsoluteTiming, UuidFragmentRunEntry, + UuidFragmentRunTable, UuidPayload, Vdep, VisualRandomAccessEntry, VisualSampleEntry, Vmhd, + Vplx, Wave, XMLSubtitleSampleEntry, }; use mp4forge::boxes::iso23001_7::{SENC_USE_SUBSAMPLE_ENCRYPTION, Senc, SencSample, SencSubsample}; use mp4forge::boxes::{AnyTypeBox, default_registry}; use mp4forge::codec::{ - CodecBox, CodecError, ImmutableBox, MutableBox, marshal, unmarshal, unmarshal_any, + CodecBox, CodecError, FieldValue, ImmutableBox, MutableBox, marshal, unmarshal, unmarshal_any, }; #[cfg(feature = "async")] use mp4forge::codec::{marshal_async, unmarshal_any_async, unmarshal_async}; @@ -348,6 +349,11 @@ fn core_iso14496_12_catalog_roundtrips() { }, ]; + let mut padb = Padb::default(); + padb.set_version(0); + padb.sample_count = 3; + padb.padding_bits = b"AB".to_vec(); + let mut tfdt_v0 = Tfdt::default(); tfdt_v0.set_version(0); tfdt_v0.base_media_decode_time_v0 = 0x01234567; @@ -773,6 +779,11 @@ fn core_iso14496_12_catalog_roundtrips() { ], "Version=0 Flags=0x000000 EntryCount=2 Entries=[{SampleCount=19088743 SampleDelta=591751049}, {SampleCount=1164413355 SampleDelta=1737075661}]", ); + assert_box_roundtrip( + padb, + &[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, b'A', b'B'], + "Version=0 Flags=0x000000 SampleCount=3 PaddingBits=\"AB\"", + ); assert_box_roundtrip( tfdt_v0, &[0x00, 0x00, 0x00, 0x00, 0x01, 0x23, 0x45, 0x67], @@ -1697,7 +1708,8 @@ fn sample_entry_and_leaf_iso14496_12_catalog_roundtrips() { general_level_idc: 0x78, min_spatial_segmentation_idc: 0x0000, chroma_format_idc: 0x01, - temporal_id_nested: 0x03, + num_temporal_layers: 0x01, + temporal_id_nested: 0x01, length_size_minus_one: 0x03, num_of_nalu_arrays: 4, nalu_arrays: vec![ @@ -1777,6 +1789,33 @@ fn sample_entry_and_leaf_iso14496_12_catalog_roundtrips() { }, }; + let dvbs = GenericMediaSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"dvbs"), + data_reference_index: 0x1234, + }, + }; + + let dvbt = GenericMediaSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"dvbt"), + data_reference_index: 0x1234, + }, + }; + + let mp4s = GenericMediaSampleEntry { + sample_entry: SampleEntry { + box_type: FourCc::from_bytes(*b"mp4s"), + data_reference_index: 0x1234, + }, + }; + + let dvsc = DvsC { + composition_page_id: 0x0123, + ancillary_page_id: 0x0456, + subtitle_type: 0x10, + }; + let mut silb = Silb::default(); silb.set_version(0); silb.scheme_count = 2; @@ -1982,7 +2021,7 @@ fn sample_entry_and_leaf_iso14496_12_catalog_roundtrips() { assert_box_roundtrip( hvcc, &[ - 0x01, 0x01, 0x60, 0x00, 0x00, 0x00, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0xe0, + 0x01, 0x01, 0x60, 0x00, 0x00, 0x00, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0xf0, 0x00, 0xfc, 0xfd, 0xf8, 0xf8, 0x00, 0x00, 0x0f, 0x04, 0x20, 0x00, 0x01, 0x00, 0x18, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x78, 0x99, 0x98, 0x09, 0x21, 0x00, 0x01, 0x00, @@ -1999,7 +2038,7 @@ fn sample_entry_and_leaf_iso14496_12_catalog_roundtrips() { "GeneralConstraintIndicator=[0x90, 0x0, 0x0, 0x0, 0x0, 0x0] GeneralLevelIdc=0x78 ", "MinSpatialSegmentationIdc=0 ParallelismType=0x0 ChromaFormatIdc=0x1 ", "BitDepthLumaMinus8=0x0 BitDepthChromaMinus8=0x0 AvgFrameRate=0 ConstantFrameRate=0x0 ", - "NumTemporalLayers=0x0 TemporalIdNested=0x3 LengthSizeMinusOne=0x3 NumOfNaluArrays=0x4 ", + "NumTemporalLayers=0x1 TemporalIdNested=0x1 LengthSizeMinusOne=0x3 NumOfNaluArrays=0x4 ", "NaluArrays=[{Completeness=false Reserved=false NaluType=0x20 NumNalus=1 Nalus=[{Length=24 NALUnit=[0x40, 0x1, 0xc, 0x1, 0xff, 0xff, 0x1, 0x60, 0x0, 0x0, 0x3, 0x0, 0x90, 0x0, 0x0, 0x3, 0x0, 0x0, 0x3, 0x0, 0x78, 0x99, 0x98, 0x9]}]}, ", "{Completeness=false Reserved=false NaluType=0x21 NumNalus=1 Nalus=[{Length=42 NALUnit=[0x6, 0x1, 0x1, 0x1, 0x60, 0x0, 0x0, 0x3, 0x0, 0x90, 0x0, 0x0, 0x3, 0x0, 0x0, 0x3, 0x0, 0x78, 0xa0, 0x3, 0xc0, 0x80, 0x10, 0xe5, 0x96, 0x66, 0x69, 0x24, 0xca, 0xe0, 0x10, 0x0, 0x0, 0x3, 0x0, 0x10, 0x0, 0x0, 0x3, 0x1, 0xe0, 0x80]}]}, ", "{Completeness=false Reserved=false NaluType=0x22 NumNalus=1 Nalus=[{Length=7 NALUnit=[0x44, 0x1, 0xc1, 0x72, 0xb4, 0x62, 0x40]}]}, ", @@ -2031,6 +2070,26 @@ fn sample_entry_and_leaf_iso14496_12_catalog_roundtrips() { &[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x34], "DataReferenceIndex=4660", ); + assert_any_box_roundtrip( + dvbs, + &[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x34], + "DataReferenceIndex=4660", + ); + assert_any_box_roundtrip( + dvbt, + &[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x34], + "DataReferenceIndex=4660", + ); + assert_any_box_roundtrip( + mp4s, + &[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x34], + "DataReferenceIndex=4660", + ); + assert_box_roundtrip( + dvsc, + &[0x01, 0x23, 0x04, 0x56, 0x10], + "CompositionPageID=291 AncillaryPageID=1110 SubtitleType=16", + ); assert_box_roundtrip( silb, &[ @@ -2249,6 +2308,13 @@ fn compact_track_payload_metadata_iso14496_12_catalog_roundtrips() { &[0xde, 0xad, 0xbe, 0xef], "Data=[0xde, 0xad, 0xbe, 0xef]", ); + assert_box_roundtrip( + Chnl { + data: vec![0x01, 0x02, 0x03, 0x04], + }, + &[0x01, 0x02, 0x03, 0x04], + "Data=[0x1, 0x2, 0x3, 0x4]", + ); } #[test] @@ -2320,6 +2386,28 @@ fn elng_preserves_payloads_without_full_box_header_bytes() { assert_eq!(encoded, payload); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_elng_preserves_payloads_without_full_box_header_bytes() { + let payload = [b'd', b'k', 0x00]; + let mut decoded = Elng::default(); + let mut reader = Cursor::new(payload.to_vec()); + let read = unmarshal_async(&mut reader, payload.len() as u64, &mut decoded, None) + .await + .unwrap(); + assert_eq!(read, payload.len() as u64); + assert_eq!(decoded.extended_language, "dk"); + assert_eq!( + stringify(&decoded, None).unwrap(), + "Version=0 Flags=0x000000 ExtendedLanguage=\"dk\"" + ); + + let mut encoded = Cursor::new(Vec::new()); + let written = marshal_async(&mut encoded, &decoded, None).await.unwrap(); + assert_eq!(written, payload.len() as u64); + assert_eq!(encoded.into_inner(), payload); +} + #[cfg(feature = "async")] #[tokio::test] async fn async_meta_and_prft_roundtrips_preserve_typed_behavior() { @@ -2377,6 +2465,207 @@ async fn async_meta_and_prft_roundtrips_preserve_typed_behavior() { assert_eq!(any_box.as_any().downcast_ref::().unwrap(), &prft); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_uuid_and_decoder_configs_roundtrip_reader_first_paths() { + let avcc = AVCDecoderConfiguration { + configuration_version: 1, + profile: 100, + profile_compatibility: 0, + level: 31, + length_size_minus_one: 3, + num_of_sequence_parameter_sets: 1, + sequence_parameter_sets: vec![AVCParameterSet { + length: 3, + nal_unit: vec![0x67, 0x64, 0x00], + }], + num_of_picture_parameter_sets: 1, + picture_parameter_sets: vec![AVCParameterSet { + length: 2, + nal_unit: vec![0x68, 0xee], + }], + high_profile_fields_enabled: false, + chroma_format: 0, + bit_depth_luma_minus8: 0, + bit_depth_chroma_minus8: 0, + num_of_sequence_parameter_set_ext: 0, + sequence_parameter_sets_ext: Vec::new(), + }; + + let hvcc = HEVCDecoderConfiguration { + configuration_version: 1, + general_profile_space: 0, + general_tier_flag: false, + general_profile_idc: 1, + general_profile_compatibility: [false; 32], + general_constraint_indicator: [0; 6], + general_level_idc: 120, + min_spatial_segmentation_idc: 0, + parallelism_type: 0, + chroma_format_idc: 1, + bit_depth_luma_minus8: 0, + bit_depth_chroma_minus8: 0, + avg_frame_rate: 0, + constant_frame_rate: 0, + num_temporal_layers: 1, + temporal_id_nested: 1, + length_size_minus_one: 3, + num_of_nalu_arrays: 1, + nalu_arrays: vec![HEVCNaluArray { + completeness: true, + reserved: false, + nalu_type: 32, + num_nalus: 1, + nalus: vec![HEVCNalu { + length: 2, + nal_unit: vec![0xaa, 0xbb], + }], + }], + }; + + let mut legacy_sample_encryption = Senc::default(); + legacy_sample_encryption.set_version(0); + legacy_sample_encryption.set_flags(SENC_USE_SUBSAMPLE_ENCRYPTION); + legacy_sample_encryption.sample_count = 1; + legacy_sample_encryption.samples = vec![SencSample { + initialization_vector: vec![0x10, 0x20, 0x30, 0x40], + subsamples: vec![SencSubsample { + bytes_of_clear_data: 2, + bytes_of_protected_data: 4, + }], + }]; + + let uuid = Uuid { + user_type: UUID_SAMPLE_ENCRYPTION, + payload: UuidPayload::SampleEncryption(legacy_sample_encryption), + }; + + async fn assert_async_roundtrip(src: T) + where + T: CodecBox + Default + PartialEq + std::fmt::Debug + 'static, + { + let mut encoded = Cursor::new(Vec::new()); + let written = marshal_async(&mut encoded, &src, None).await.unwrap(); + let payload = encoded.into_inner(); + assert_eq!(written, payload.len() as u64); + + let mut decoded = T::default(); + let mut reader = Cursor::new(payload.clone()); + let read = unmarshal_async(&mut reader, payload.len() as u64, &mut decoded, None) + .await + .unwrap(); + assert_eq!(read, payload.len() as u64); + assert_eq!(decoded, src); + + let registry = default_registry(); + let mut any_reader = Cursor::new(payload); + let payload_len = any_reader.get_ref().len() as u64; + let (any_box, any_read) = unmarshal_any_async( + &mut any_reader, + payload_len, + src.box_type(), + ®istry, + None, + ) + .await + .unwrap(); + assert_eq!(any_read, payload_len); + assert_eq!(any_box.as_any().downcast_ref::().unwrap(), &src); + } + + assert_async_roundtrip(avcc).await; + assert_async_roundtrip(hvcc).await; + assert_async_roundtrip(uuid).await; +} + +#[cfg(feature = "async")] +#[tokio::test] +async fn async_loudness_and_elng_roundtrips_reader_first_paths() { + let mut elng = Elng::default(); + elng.extended_language = "en-US".into(); + + let mut tlou = TrackLoudnessInfo::default(); + tlou.set_version(1); + tlou.entries = vec![LoudnessEntry { + eq_set_id: 7, + downmix_id: 12, + drc_set_id: 18, + bs_sample_peak_level: 528, + bs_true_peak_level: 801, + measurement_system_for_tp: 4, + reliability_for_tp: 6, + measurements: vec![ + LoudnessMeasurement { + method_definition: 7, + method_value: 8, + measurement_system: 9, + reliability: 10, + }, + LoudnessMeasurement { + method_definition: 11, + method_value: 12, + measurement_system: 13, + reliability: 14, + }, + ], + }]; + + let mut alou = AlbumLoudnessInfo::default(); + alou.set_version(0); + alou.entries = vec![LoudnessEntry { + downmix_id: 9, + drc_set_id: 17, + bs_sample_peak_level: 274, + bs_true_peak_level: 291, + measurement_system_for_tp: 2, + reliability_for_tp: 3, + measurements: vec![LoudnessMeasurement { + method_definition: 1, + method_value: 2, + measurement_system: 4, + reliability: 5, + }], + ..LoudnessEntry::default() + }]; + + async fn assert_async_roundtrip(src: T) + where + T: CodecBox + Default + PartialEq + std::fmt::Debug + 'static, + { + let mut encoded = Cursor::new(Vec::new()); + let written = marshal_async(&mut encoded, &src, None).await.unwrap(); + let payload = encoded.into_inner(); + assert_eq!(written, payload.len() as u64); + + let mut decoded = T::default(); + let mut reader = Cursor::new(payload.clone()); + let read = unmarshal_async(&mut reader, payload.len() as u64, &mut decoded, None) + .await + .unwrap(); + assert_eq!(read, payload.len() as u64); + assert_eq!(decoded, src); + + let registry = default_registry(); + let mut any_reader = Cursor::new(payload); + let payload_len = any_reader.get_ref().len() as u64; + let (any_box, any_read) = unmarshal_any_async( + &mut any_reader, + payload_len, + src.box_type(), + ®istry, + None, + ) + .await + .unwrap(); + assert_eq!(any_read, payload_len); + assert_eq!(any_box.as_any().downcast_ref::().unwrap(), &src); + } + + assert_async_roundtrip(elng).await; + assert_async_roundtrip(tlou).await; + assert_async_roundtrip(alou).await; +} + #[test] fn counted_payload_validation_rejects_truncated_sbgp_entries() { let payload = [ @@ -3006,6 +3295,10 @@ fn built_in_registry_reports_supported_versions_for_landed_types() { registry.supported_versions(FourCc::from_bytes(*b"cdat")), Some(&[][..]) ); + assert_eq!( + registry.supported_versions(FourCc::from_bytes(*b"chnl")), + Some(&[][..]) + ); assert_eq!( registry.supported_versions(FourCc::from_bytes(*b"leva")), Some(&[0][..]) @@ -3091,6 +3384,7 @@ fn built_in_registry_reports_supported_versions_for_landed_types() { assert!(registry.is_registered(FourCc::from_bytes(*b"avcC"))); assert!(registry.is_registered(FourCc::from_bytes(*b"btrt"))); assert!(registry.is_registered(FourCc::from_bytes(*b"cdat"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"chnl"))); assert!(registry.is_registered(FourCc::from_bytes(*b"clap"))); assert!(registry.is_registered(FourCc::from_bytes(*b"colr"))); assert!(registry.is_registered(FourCc::from_bytes(*b"CoLL"))); @@ -3104,10 +3398,30 @@ fn built_in_registry_reports_supported_versions_for_landed_types() { assert!(registry.is_registered(FourCc::from_bytes(*b"leva"))); assert!(registry.is_registered(FourCc::from_bytes(*b"ludt"))); assert!(registry.is_registered(FourCc::from_bytes(*b"avc1"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"avc2"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"avc3"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"avc4"))); assert!(registry.is_registered(FourCc::from_bytes(*b"mime"))); assert!(registry.is_registered(FourCc::from_bytes(*b"mp4a"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"dvbs"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"dvbt"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"text"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"tx3g"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"mp4s"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"dvsC"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"divx"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"jpeg"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"png "))); + assert!(registry.is_registered(FourCc::from_bytes(*b"SVQ1"))); assert!(registry.is_registered(FourCc::from_bytes(*b"pasp"))); assert!(registry.is_registered(FourCc::from_bytes(*b"prft"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"samr"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"sawb"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"sqcp"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"sevc"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"ssmv"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"dts-"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"QDM2"))); assert!(registry.is_registered(FourCc::from_bytes(*b"schm"))); assert!(registry.is_registered(FourCc::from_bytes(*b"sbtt"))); assert!(registry.is_registered(FourCc::from_bytes(*b"sidx"))); @@ -3119,6 +3433,7 @@ fn built_in_registry_reports_supported_versions_for_landed_types() { assert!(registry.is_registered(FourCc::from_bytes(*b"sync"))); assert!(registry.is_registered(FourCc::from_bytes(*b"subt"))); assert!(registry.is_registered(FourCc::from_bytes(*b"subs"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"s263"))); assert!(registry.is_registered(FourCc::from_bytes(*b"nmhd"))); assert!(registry.is_registered(FourCc::from_bytes(*b"tref"))); assert!(registry.is_registered(FourCc::from_bytes(*b"tlou"))); @@ -3134,6 +3449,25 @@ fn built_in_registry_reports_supported_versions_for_landed_types() { assert!(registry.is_registered(FourCc::from_bytes(*b"hint"))); assert!(registry.is_registered(FourCc::from_bytes(*b"ipir"))); assert!(registry.is_registered(FourCc::from_bytes(*b"mpod"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"swre"))); +} + +#[test] +fn swre_boxes_roundtrip_as_registered_opaque_payloads() { + let registry = default_registry(); + let box_type = FourCc::from_bytes(*b"swre"); + let payload = b"\x00\x00\x00\x00mp4forge apple fps retained metadata\x00".to_vec(); + + let mut reader = Cursor::new(payload.clone()); + let (decoded, read) = + unmarshal_any(&mut reader, payload.len() as u64, box_type, ®istry, None).unwrap(); + + assert_eq!(read, payload.len() as u64); + assert_eq!(decoded.box_type(), box_type); + assert_eq!( + decoded.field_value("Data").unwrap(), + FieldValue::Bytes(payload) + ); } #[test] @@ -3303,7 +3637,7 @@ fn avcc_rejects_inconsistent_high_profile_state() { #[test] fn hvcc_rejects_truncated_nalu_array_payloads() { let payload = [ - 0x01, 0x01, 0x60, 0x00, 0x00, 0x00, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0xe0, 0x00, + 0x01, 0x01, 0x60, 0x00, 0x00, 0x00, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0xf0, 0x00, 0xfc, 0xfd, 0xf8, 0xf8, 0x00, 0x00, 0x0f, 0x04, 0x20, 0x00, 0x01, 0x00, 0x18, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x78, 0x99, 0x98, 0x09, 0x21, 0x00, 0x01, 0x00, 0x2a, 0x06, 0x01, 0x01, diff --git a/tests/box_catalog_iso14496_14.rs b/tests/box_catalog_iso14496_14.rs index 7e2abd7..a2ef2cd 100644 --- a/tests/box_catalog_iso14496_14.rs +++ b/tests/box_catalog_iso14496_14.rs @@ -13,6 +13,8 @@ use mp4forge::boxes::iso14496_14::{ parse_descriptor_commands, }; use mp4forge::codec::{CodecBox, MutableBox, marshal, unmarshal, unmarshal_any}; +#[cfg(feature = "async")] +use mp4forge::codec::{marshal_async, unmarshal_any_async, unmarshal_async}; use mp4forge::stringify::stringify; fn assert_box_roundtrip(src: T, payload: &[u8], expected: &str) @@ -127,14 +129,149 @@ fn descriptor_catalog_roundtrips() { 0x00, 0x00, 0x00, 0x00, 0x03, 0x89, 0x8d, 0x8a, 0x67, 0x12, 0x34, 0xa3, 0x23, 0x45, 0x34, 0x56, 0x03, 0x89, 0x8d, 0x8a, 0x67, 0x12, 0x34, 0x43, 0x0b, b'h', b't', b't', b'p', b':', b'/', b'/', b'h', b'o', b'g', b'e', 0x04, 0x89, 0x8d, 0x8a, 0x67, 0x12, - 0x56, 0x12, 0x34, 0x56, 0x12, 0x34, 0x56, 0x78, 0x23, 0x45, 0x67, 0x89, 0x05, 0x80, - 0x80, 0x80, 0x03, 0x11, 0x22, 0x33, 0x06, 0x80, 0x80, 0x80, 0x05, 0x11, 0x22, 0x33, - 0x44, 0x55, + 0x56, 0x12, 0x34, 0x56, 0x12, 0x34, 0x56, 0x78, 0x23, 0x45, 0x67, 0x89, 0x05, 0x03, + 0x11, 0x22, 0x33, 0x06, 0x05, 0x11, 0x22, 0x33, 0x44, 0x55, ], "Version=0 Flags=0x000000 Descriptors=[{Tag=ESDescr Size=19088743 ESID=4660 StreamDependenceFlag=true UrlFlag=false OcrStreamFlag=true StreamPriority=3 DependsOnESID=9029 OCRESID=13398}, {Tag=ESDescr Size=19088743 ESID=4660 StreamDependenceFlag=false UrlFlag=true OcrStreamFlag=false StreamPriority=3 URLLength=0xb URLString=\"http://hoge\"}, {Tag=DecoderConfigDescr Size=19088743 ObjectTypeIndication=0x12 StreamType=21 UpStream=true Reserved=false BufferSizeDB=1193046 MaxBitrate=305419896 AvgBitrate=591751049}, {Tag=DecSpecificInfo Size=3 Data=[0x11, 0x22, 0x33]}, {Tag=SLConfigDescr Size=5 Data=[0x11, 0x22, 0x33, 0x44, 0x55]}]", ); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_descriptor_catalog_roundtrips() { + let mut esds = Esds::default(); + esds.set_version(0); + esds.descriptors = vec![ + Descriptor { + tag: ES_DESCRIPTOR_TAG, + size: 0x1234567, + es_descriptor: Some(EsDescriptor { + es_id: 0x1234, + stream_dependence_flag: true, + ocr_stream_flag: true, + stream_priority: 0x03, + depends_on_es_id: 0x2345, + ocr_es_id: 0x3456, + ..EsDescriptor::default() + }), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_CONFIG_DESCRIPTOR_TAG, + size: 0x1234567, + decoder_config_descriptor: Some(DecoderConfigDescriptor { + object_type_indication: 0x12, + stream_type: 0x15, + up_stream: true, + reserved: false, + buffer_size_db: 0x123456, + max_bitrate: 0x12345678, + avg_bitrate: 0x23456789, + }), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_SPECIFIC_INFO_TAG, + size: 0x03, + data: vec![0x11, 0x22, 0x33], + ..Descriptor::default() + }, + ]; + + let expected_esds = vec![ + 0x00, 0x00, 0x00, 0x00, 0x03, 0x89, 0x8d, 0x8a, 0x67, 0x12, 0x34, 0xa3, 0x23, 0x45, 0x34, + 0x56, 0x04, 0x89, 0x8d, 0x8a, 0x67, 0x12, 0x56, 0x12, 0x34, 0x56, 0x12, 0x34, 0x56, 0x78, + 0x23, 0x45, 0x67, 0x89, 0x05, 0x03, 0x11, 0x22, 0x33, + ]; + + let mut esds_writer = Cursor::new(Vec::new()); + let written = marshal_async(&mut esds_writer, &esds, None).await.unwrap(); + assert_eq!(written, expected_esds.len() as u64); + assert_eq!(esds_writer.into_inner(), expected_esds); + + let mut esds_reader = Cursor::new(expected_esds.clone()); + let mut decoded_esds = Esds::default(); + let read = unmarshal_async( + &mut esds_reader, + expected_esds.len() as u64, + &mut decoded_esds, + None, + ) + .await + .unwrap(); + assert_eq!(read, expected_esds.len() as u64); + assert_eq!(decoded_esds, esds); + + let registry = default_registry(); + let mut any_esds_reader = Cursor::new(expected_esds); + let (any_esds_box, any_esds_read) = unmarshal_any_async( + &mut any_esds_reader, + 39, + FourCc::from_bytes(*b"esds"), + ®istry, + None, + ) + .await + .unwrap(); + assert_eq!(any_esds_read, 39); + assert_eq!(any_esds_box.as_any().downcast_ref::().unwrap(), &esds); + + let mut iods = Iods::default(); + iods.set_version(0); + iods.descriptor = Some( + Descriptor::from_initial_object_descriptor(InitialObjectDescriptor { + object_descriptor_id: 18, + include_inline_profile_level_flag: true, + od_profile_level_indication: 0x11, + scene_profile_level_indication: 0x22, + audio_profile_level_indication: 0x33, + visual_profile_level_indication: 0x44, + graphics_profile_level_indication: 0x55, + sub_descriptors: vec![Descriptor::from_es_id_inc_descriptor(EsIdIncDescriptor { + track_id: 2, + })], + ..InitialObjectDescriptor::default() + }) + .unwrap(), + ); + + let expected_iods = vec![ + 0x00, 0x00, 0x00, 0x00, 0x10, 0x0d, 0x04, 0x9f, 0x11, 0x22, 0x33, 0x44, 0x55, 0x0e, 0x04, + 0x00, 0x00, 0x00, 0x02, + ]; + + let mut iods_writer = Cursor::new(Vec::new()); + let written = marshal_async(&mut iods_writer, &iods, None).await.unwrap(); + assert_eq!(written, expected_iods.len() as u64); + assert_eq!(iods_writer.into_inner(), expected_iods); + + let mut iods_reader = Cursor::new(expected_iods.clone()); + let mut decoded_iods = Iods::default(); + let read = unmarshal_async( + &mut iods_reader, + expected_iods.len() as u64, + &mut decoded_iods, + None, + ) + .await + .unwrap(); + assert_eq!(read, expected_iods.len() as u64); + assert_eq!(decoded_iods, iods); + + let mut any_iods_reader = Cursor::new(expected_iods); + let (any_iods_box, any_iods_read) = unmarshal_any_async( + &mut any_iods_reader, + 19, + FourCc::from_bytes(*b"iods"), + ®istry, + None, + ) + .await + .unwrap(); + assert_eq!(any_iods_read, 19); + assert_eq!(any_iods_box.as_any().downcast_ref::().unwrap(), &iods); +} + #[test] fn iods_catalog_roundtrips() { let mut iods = Iods::default(); @@ -170,12 +307,11 @@ fn iods_catalog_roundtrips() { assert_box_roundtrip( iods, &[ - 0x00, 0x00, 0x00, 0x00, 0x10, 0x80, 0x80, 0x80, 0x27, 0x04, 0x9f, 0x11, 0x22, 0x33, - 0x44, 0x55, 0x0e, 0x80, 0x80, 0x80, 0x04, 0x00, 0x00, 0x00, 0x02, 0x0f, 0x80, 0x80, - 0x80, 0x02, 0x00, 0x03, 0x0a, 0x80, 0x80, 0x80, 0x01, 0x01, 0x0b, 0x80, 0x80, 0x80, - 0x05, 0x01, 0xa5, 0x51, 0xaa, 0xbb, + 0x00, 0x00, 0x00, 0x00, 0x10, 0x1b, 0x04, 0x9f, 0x11, 0x22, 0x33, 0x44, 0x55, 0x0e, + 0x04, 0x00, 0x00, 0x00, 0x02, 0x0f, 0x02, 0x00, 0x03, 0x0a, 0x01, 0x01, 0x0b, 0x05, + 0x01, 0xa5, 0x51, 0xaa, 0xbb, ], - "Version=0 Flags=0x000000 Descriptor={Tag=MP4InitialObjectDescr Size=39 ObjectDescriptorID=18 UrlFlag=false IncludeInlineProfileLevelFlag=true ODProfileLevelIndication=0x11 SceneProfileLevelIndication=0x22 AudioProfileLevelIndication=0x33 VisualProfileLevelIndication=0x44 GraphicsProfileLevelIndication=0x55 SubDescriptors=[{Tag=ES_ID_Inc Size=4 TrackID=2}, {Tag=ES_ID_Ref Size=2 RefIndex=3}, {Tag=IPMPDescrPointer Size=1 DescriptorID=0x1}, {Tag=IPMPDescr Size=5 DescriptorID=0x1 IPMPSType=0xa551 Data=[0xaa, 0xbb]}]}", + "Version=0 Flags=0x000000 Descriptor={Tag=MP4InitialObjectDescr Size=27 ObjectDescriptorID=18 UrlFlag=false IncludeInlineProfileLevelFlag=true ODProfileLevelIndication=0x11 SceneProfileLevelIndication=0x22 AudioProfileLevelIndication=0x33 VisualProfileLevelIndication=0x44 GraphicsProfileLevelIndication=0x55 SubDescriptors=[{Tag=ES_ID_Inc Size=4 TrackID=2}, {Tag=ES_ID_Ref Size=2 RefIndex=3}, {Tag=IPMPDescrPointer Size=1 DescriptorID=0x1}, {Tag=IPMPDescr Size=5 DescriptorID=0x1 IPMPSType=0xa551 Data=[0xaa, 0xbb]}]}", ); } diff --git a/tests/box_catalog_iso23001_7.rs b/tests/box_catalog_iso23001_7.rs index 8a15353..3e4c5fc 100644 --- a/tests/box_catalog_iso23001_7.rs +++ b/tests/box_catalog_iso23001_7.rs @@ -7,7 +7,9 @@ use mp4forge::boxes::default_registry; use mp4forge::boxes::iso23001_7::{ Pssh, PsshKid, SENC_USE_SUBSAMPLE_ENCRYPTION, Senc, SencSample, SencSubsample, Tenc, }; -use mp4forge::codec::{CodecBox, MutableBox, marshal, unmarshal, unmarshal_any}; +use mp4forge::codec::{CodecBox, ImmutableBox, MutableBox, marshal, unmarshal, unmarshal_any}; +#[cfg(feature = "async")] +use mp4forge::codec::{marshal_async, unmarshal_any_async, unmarshal_async}; use mp4forge::stringify::stringify; fn assert_box_roundtrip(src: T, payload: &[u8], expected: &str) @@ -235,6 +237,59 @@ fn protection_catalog_roundtrips() { ); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_senc_roundtrip_preserves_inferred_sample_layout() { + let mut senc = Senc::default(); + senc.set_version(0); + senc.set_flags(SENC_USE_SUBSAMPLE_ENCRYPTION); + senc.sample_count = 2; + senc.samples = vec![ + SencSample { + initialization_vector: vec![0x11, 0x22, 0x33, 0x44], + subsamples: vec![SencSubsample { + bytes_of_clear_data: 7, + bytes_of_protected_data: 9, + }], + }, + SencSample { + initialization_vector: vec![0xaa, 0xbb, 0xcc, 0xdd], + subsamples: vec![SencSubsample { + bytes_of_clear_data: 3, + bytes_of_protected_data: 5, + }], + }, + ]; + + let mut encoded = Cursor::new(Vec::new()); + let written = marshal_async(&mut encoded, &senc, None).await.unwrap(); + let payload = encoded.into_inner(); + assert_eq!(written, payload.len() as u64); + + let mut decoded = Senc::default(); + let mut reader = Cursor::new(payload.clone()); + let read = unmarshal_async(&mut reader, payload.len() as u64, &mut decoded, None) + .await + .unwrap(); + assert_eq!(read, payload.len() as u64); + assert_eq!(decoded, senc); + + let registry = default_registry(); + let mut any_reader = Cursor::new(payload); + let payload_len = any_reader.get_ref().len() as u64; + let (any_box, any_read) = unmarshal_any_async( + &mut any_reader, + payload_len, + senc.box_type(), + ®istry, + None, + ) + .await + .unwrap(); + assert_eq!(any_read, payload_len); + assert_eq!(any_box.as_any().downcast_ref::().unwrap(), &senc); +} + #[test] fn built_in_registry_reports_supported_versions_for_landed_protection_types() { let registry = default_registry(); diff --git a/tests/box_catalog_metadata.rs b/tests/box_catalog_metadata.rs index 5d1eb12..d0152ea 100644 --- a/tests/box_catalog_metadata.rs +++ b/tests/box_catalog_metadata.rs @@ -16,7 +16,11 @@ use mp4forge::boxes::metadata::{ TempoData, TrackNumberData, TvEpisodeData, TvEpisodeIdData, TvNetworkNameData, TvSeasonData, TvShowNameData, WriterData, }; -use mp4forge::boxes::{AnyTypeBox, default_registry}; +use mp4forge::boxes::{AnyTypeBox, BoxLookupContext, default_registry}; +#[cfg(feature = "async")] +use mp4forge::codec::marshal_async; +#[cfg(feature = "async")] +use mp4forge::codec::unmarshal_async; use mp4forge::codec::{CodecBox, ImmutableBox, MutableBox, marshal, unmarshal, unmarshal_any}; use mp4forge::stringify::stringify; @@ -332,6 +336,54 @@ fn metadata_catalog_roundtrips() { ); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_nested_numbered_metadata_item_marshal_matches_sync_layout() { + let nested_numbered_payload = [ + 0x00, 0x00, 0x00, 0x15, b'd', b'a', b't', b'a', 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x31, 0x2e, 0x30, 0x2e, 0x30, + ]; + let mut decoded_nested_numbered = NumberedMetadataItem::default(); + decoded_nested_numbered.set_box_type(FourCc::from_u32(1)); + let mut reader = Cursor::new(nested_numbered_payload); + let read = unmarshal( + &mut reader, + nested_numbered_payload.len() as u64, + &mut decoded_nested_numbered, + None, + ) + .unwrap(); + assert_eq!(read, nested_numbered_payload.len() as u64); + + let mut async_encoded_nested_numbered = Cursor::new(Vec::new()); + let nested_written = marshal_async( + &mut async_encoded_nested_numbered, + &decoded_nested_numbered, + None, + ) + .await + .unwrap(); + assert_eq!(nested_written, nested_numbered_payload.len() as u64); + assert_eq!( + async_encoded_nested_numbered.into_inner(), + nested_numbered_payload + ); + + let mut async_reader = Cursor::new(nested_numbered_payload); + let mut async_decoded_nested_numbered = NumberedMetadataItem::default(); + async_decoded_nested_numbered.set_box_type(FourCc::from_u32(1)); + let async_read = unmarshal_async( + &mut async_reader, + nested_numbered_payload.len() as u64, + &mut async_decoded_nested_numbered, + None, + ) + .await + .unwrap(); + assert_eq!(async_read, nested_numbered_payload.len() as u64); + assert_eq!(async_decoded_nested_numbered, decoded_nested_numbered); +} + #[test] fn tuple_metadata_value_boxes_roundtrip() { assert_box_roundtrip( @@ -972,6 +1024,10 @@ fn built_in_registry_reports_context_free_metadata_types() { assert!(registry.is_registered(FourCc::from_bytes(*b"ilst"))); assert!(registry.is_registered(FourCc::from_bytes(*b"ID32"))); assert!(registry.is_registered(FourCc::from_bytes(*b"keys"))); + assert!(registry.is_registered_with_context( + FourCc::from_bytes([0xa9, b'e', b'n', b'c']), + BoxLookupContext::new().enter(FourCc::from_bytes(*b"ilst")) + )); assert!(!registry.is_registered(FourCc::from_bytes(*b"data"))); assert!(!registry.is_registered(FourCc::from_bytes(*b"----"))); assert!(!registry.is_registered(FourCc::from_bytes(*b"mean"))); diff --git a/tests/cli_decrypt.rs b/tests/cli_decrypt.rs index d0b34e6..c7dbf29 100644 --- a/tests/cli_decrypt.rs +++ b/tests/cli_decrypt.rs @@ -55,9 +55,6 @@ fn decrypt_command_writes_clear_output_via_dispatch() { .map(|track| (track.summary.track_id, track)) .collect::>(); - let _ = fs::remove_file(&input_path); - let _ = fs::remove_file(&output_path); - assert_eq!(exit_code, 0, "stderr={}", String::from_utf8_lossy(&stderr)); assert_eq!(String::from_utf8(stdout).unwrap(), ""); assert_eq!(String::from_utf8(stderr).unwrap(), ""); @@ -97,10 +94,6 @@ fn decrypt_command_supports_fragments_info_files() { ) .unwrap(); - let _ = fs::remove_file(&init_path); - let _ = fs::remove_file(&input_path); - let _ = fs::remove_file(&output_path); - assert_eq!(exit_code, 0, "stderr={}", String::from_utf8_lossy(&stderr)); assert_eq!(String::from_utf8(stderr).unwrap(), ""); assert_eq!(mdat_payloads.len(), 1); @@ -132,8 +125,44 @@ fn decrypt_command_writes_stable_progress_lines() { let mut stderr = Vec::new(); let exit_code = decrypt::run(&args, &mut stderr); - let _ = fs::remove_file(&input_path); - let _ = fs::remove_file(&output_path); + assert_eq!(exit_code, 0, "stderr={}", String::from_utf8_lossy(&stderr)); + assert_eq!( + String::from_utf8(stderr).unwrap(), + concat!( + "OpenInput 0/1\n", + "OpenInput 1/1\n", + "InspectStructure 0/1\n", + "InspectStructure 1/1\n", + "ProcessSamples 0/1\n", + "ProcessSamples 1/1\n", + "OpenOutput 0/1\n", + "OpenOutput 1/1\n", + "FinalizeOutput 0/1\n", + "FinalizeOutput 1/1\n", + ) + ); +} + +#[test] +fn decrypt_command_writes_stable_progress_lines_for_media_segments() { + let fixture = build_decrypt_rewrite_fixture(); + let init_path = write_temp_file("cli-decrypt-progress-init", &fixture.init_segment); + let input_path = write_temp_file("cli-decrypt-progress-media", &fixture.media_segment); + let output_path = write_temp_file("cli-decrypt-progress-media-output", &[]); + let args = vec![ + "--show-progress".to_string(), + "--key".to_string(), + fixture.all_keys[0].to_spec(), + "--key".to_string(), + fixture.all_keys[1].to_spec(), + "--fragments-info".to_string(), + init_path.to_string_lossy().into_owned(), + input_path.to_string_lossy().into_owned(), + output_path.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = decrypt::run(&args, &mut stderr); assert_eq!(exit_code, 0, "stderr={}", String::from_utf8_lossy(&stderr)); assert_eq!( @@ -142,6 +171,8 @@ fn decrypt_command_writes_stable_progress_lines() { "OpenInput 0/1\n", "OpenInput 1/1\n", "InspectStructure 0/1\n", + "OpenFragmentsInfo 0/1\n", + "OpenFragmentsInfo 1/1\n", "InspectStructure 1/1\n", "ProcessSamples 0/1\n", "ProcessSamples 1/1\n", @@ -185,7 +216,7 @@ fn decrypt_command_rejects_invalid_arguments() { ); assert_eq!( String::from_utf8(stderr).unwrap(), - "Error: at least one --key is required\n" + "Error [stage=request category=input]: at least one --key is required\n" ); let mut stderr = Vec::new(); @@ -203,7 +234,85 @@ fn decrypt_command_rejects_invalid_arguments() { ); assert_eq!( String::from_utf8(stderr).unwrap(), - "Error: invalid decryption key spec \"bad\": expected :\n" + "Error [stage=request category=input]: invalid decryption key spec \"bad\": expected :\n" + ); +} + +#[test] +fn decrypt_command_rejects_same_input_and_output_path() { + let fixture = build_decrypt_rewrite_fixture(); + let input_path = write_temp_file("cli-decrypt-same-path-input", &fixture.single_file); + let args = vec![ + "--key".to_string(), + fixture.all_keys[0].to_spec(), + input_path.to_string_lossy().into_owned(), + input_path.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = decrypt::run(&args, &mut stderr); + + let message = String::from_utf8(stderr).unwrap(); + assert_eq!(exit_code, 1); + assert!( + message.contains("invalid decrypt file arguments"), + "{message}" + ); + assert!(message.contains("conflicts with input"), "{message}"); +} + +#[test] +fn decrypt_command_rejects_output_path_conflicting_with_fragments_info_path() { + let fixture = build_decrypt_rewrite_fixture(); + let init_path = write_temp_file("cli-decrypt-fragments-conflict-init", &fixture.init_segment); + let media_path = write_temp_file( + "cli-decrypt-fragments-conflict-media", + &fixture.media_segment, + ); + let args = vec![ + "--key".to_string(), + fixture.all_keys[0].to_spec(), + "--key".to_string(), + fixture.all_keys[1].to_spec(), + "--fragments-info".to_string(), + init_path.to_string_lossy().into_owned(), + media_path.to_string_lossy().into_owned(), + init_path.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = decrypt::run(&args, &mut stderr); + + let message = String::from_utf8(stderr).unwrap(); + assert_eq!(exit_code, 1); + assert!( + message.contains("invalid decrypt file arguments"), + "{message}" + ); + assert!(message.contains("fragments-info path"), "{message}"); +} + +#[test] +fn decrypt_command_reports_missing_input_path_with_context() { + let args = vec![ + "--key".to_string(), + "1:00112233445566778899aabbccddeeff".to_string(), + "this-file-does-not-exist.mp4".to_string(), + "cli-decrypt-missing-output.mp4".to_string(), + ]; + + let mut stderr = Vec::new(); + let exit_code = decrypt::run(&args, &mut stderr); + + let message = String::from_utf8(stderr).unwrap(); + assert_eq!(exit_code, 1); + assert!( + message.contains("failed to open decrypt input"), + "{message}" + ); + assert!( + message.contains("this-file-does-not-exist.mp4"), + "{message}" ); } @@ -226,8 +335,6 @@ fn assert_retained_file_fixture_cli_decrypts( let exit_code = cli::dispatch(&args, &mut stdout, &mut stderr); let output = fs::read(&output_path).unwrap(); - let _ = fs::remove_file(&output_path); - assert_eq!(exit_code, 0, "stderr={}", String::from_utf8_lossy(&stderr)); assert_eq!(String::from_utf8(stdout).unwrap(), ""); assert_eq!(String::from_utf8(stderr).unwrap(), ""); @@ -260,8 +367,6 @@ fn assert_retained_fragmented_fixture_cli_decrypts( let exit_code = cli::dispatch(&args, &mut stdout, &mut stderr); let output = fs::read(&output_path).unwrap(); - let _ = fs::remove_file(&output_path); - assert_eq!(exit_code, 0, "stderr={}", String::from_utf8_lossy(&stderr)); assert_eq!(String::from_utf8(stdout).unwrap(), ""); assert_eq!(String::from_utf8(stderr).unwrap(), ""); @@ -287,9 +392,6 @@ fn assert_generated_topology_fixture_cli_decrypts( let exit_code = cli::dispatch(&args, &mut stdout, &mut stderr); let output = fs::read(&output_path).unwrap(); - let _ = fs::remove_file(&input_path); - let _ = fs::remove_file(&output_path); - assert_eq!(exit_code, 0, "stderr={}", String::from_utf8_lossy(&stderr)); assert_eq!(String::from_utf8(stdout).unwrap(), ""); assert_eq!(String::from_utf8(stderr).unwrap(), ""); @@ -315,9 +417,6 @@ fn assert_generated_topology_fixture_cli_rejects_first_sample_description_limit( let exit_code = cli::dispatch(&args, &mut stdout, &mut stderr); let stderr_text = String::from_utf8(stderr).unwrap(); - let _ = fs::remove_file(&input_path); - let _ = fs::remove_file(&output_path); - assert_eq!(exit_code, 1, "stderr={stderr_text}"); assert_eq!(String::from_utf8(stdout).unwrap(), ""); assert!( @@ -502,8 +601,6 @@ fn decrypt_command_supports_multi_sample_entry_fragmented_tracks() { assert_eq!(String::from_utf8(stderr).unwrap(), ""); let output = fs::read(&output_path).unwrap(); - let _ = fs::remove_file(&input_path); - let _ = fs::remove_file(&output_path); assert_eq!(output, fixture.decrypted_single_file); } @@ -531,8 +628,6 @@ fn decrypt_command_supports_zero_kid_multi_sample_entry_fragmented_tracks() { assert_eq!(String::from_utf8(stderr).unwrap(), ""); let output = fs::read(&output_path).unwrap(); - let _ = fs::remove_file(&input_path); - let _ = fs::remove_file(&output_path); assert_eq!(output, fixture.decrypted_single_file); } diff --git a/tests/cli_dispatch.rs b/tests/cli_dispatch.rs index 6432e63..71c085a 100644 --- a/tests/cli_dispatch.rs +++ b/tests/cli_dispatch.rs @@ -43,34 +43,32 @@ fn dispatch_keeps_decrypt_unavailable_without_feature() { assert_eq!(String::from_utf8(stderr).unwrap(), top_level_usage()); } -fn top_level_usage() -> &'static str { +#[cfg(not(feature = "mux"))] +#[test] +fn dispatch_keeps_mux_unavailable_without_feature() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + cli::dispatch(&["mux".to_string()], &mut stdout, &mut stderr), + 1 + ); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!(String::from_utf8(stderr).unwrap(), top_level_usage()); +} + +fn top_level_usage() -> String { + let mut usage = String::from("USAGE: mp4forge COMMAND [ARGS]\n\nCOMMAND:\n"); + usage.push_str(" divide split a fragmented MP4 into track playlists\n"); #[cfg(feature = "decrypt")] - { - concat!( - "USAGE: mp4forge COMMAND [ARGS]\n", - "\n", - "COMMAND:\n", - " divide split a fragmented MP4 into track playlists\n", - " decrypt decrypt protected MP4-family content\n", - " dump display the MP4 box tree\n", - " edit rewrite selected boxes\n", - " extract extract raw boxes by type or path\n", - " psshdump summarize pssh boxes\n", - " probe summarize an MP4 file\n" - ) - } - #[cfg(not(feature = "decrypt"))] - { - concat!( - "USAGE: mp4forge COMMAND [ARGS]\n", - "\n", - "COMMAND:\n", - " divide split a fragmented MP4 into track playlists\n", - " dump display the MP4 box tree\n", - " edit rewrite selected boxes\n", - " extract extract raw boxes by type or path\n", - " psshdump summarize pssh boxes\n", - " probe summarize an MP4 file\n" - ) - } + usage.push_str(" decrypt decrypt protected MP4-family content\n"); + usage.push_str(" dump display the MP4 box tree\n"); + usage.push_str(" edit rewrite selected boxes\n"); + usage.push_str(" extract extract raw boxes by type or path\n"); + #[cfg(feature = "mux")] + usage.push_str(" inspect inspect one direct-ingest input without writing an MP4\n"); + #[cfg(feature = "mux")] + usage.push_str(" mux merge one video track plus audio tracks into one MP4\n"); + usage.push_str(" psshdump summarize pssh boxes\n"); + usage.push_str(" probe summarize an MP4 file\n"); + usage } diff --git a/tests/cli_divide.rs b/tests/cli_divide.rs index 5a425c7..2c4d4ed 100644 --- a/tests/cli_divide.rs +++ b/tests/cli_divide.rs @@ -1,3 +1,4 @@ +#![cfg(feature = "mux")] #![allow(clippy::field_reassign_with_default)] mod support; @@ -6,24 +7,34 @@ use std::fs; use std::path::Path; use mp4forge::boxes::AnyTypeBox; +use mp4forge::boxes::av1::AV1CodecConfiguration; +use mp4forge::boxes::etsi_ts_102_366::Dac3; use mp4forge::boxes::iso14496_12::{ - AVCDecoderConfiguration, AudioSampleEntry, Ftyp, HEVCDecoderConfiguration, Mdhd, SampleEntry, - Stco, Stsc, StscEntry, Stsd, Stsz, Stts, SttsEntry, TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, - TFHD_DEFAULT_SAMPLE_SIZE_PRESENT, Tfdt, Tfhd, Tkhd, Trun, VisualSampleEntry, + AVCDecoderConfiguration, AVCParameterSet, AudioSampleEntry, Frma, Ftyp, + HEVCDecoderConfiguration, Mdhd, SampleEntry, Schm, Sinf, Stco, Stsc, StscEntry, Stsd, Stsz, + Stts, SttsEntry, TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, TFHD_DEFAULT_SAMPLE_SIZE_PRESENT, + TRUN_SAMPLE_DURATION_PRESENT, TRUN_SAMPLE_SIZE_PRESENT, Tfdt, Tfhd, Tkhd, Trun, TrunEntry, + VisualSampleEntry, XMLSubtitleSampleEntry, }; use mp4forge::boxes::iso14496_14::{ DECODER_CONFIG_DESCRIPTOR_TAG, DECODER_SPECIFIC_INFO_TAG, DecoderConfigDescriptor, Descriptor, Esds, }; +use mp4forge::boxes::iso23001_5::PcmC; +use mp4forge::boxes::opus::DOps; +use mp4forge::boxes::vp::VpCodecConfiguration; use mp4forge::cli::divide; use mp4forge::codec::MutableBox; -use mp4forge::probe::{TrackCodec, probe}; +use mp4forge::mux::{MuxRequest, MuxTrackSpec, mux_to_path}; +use mp4forge::probe::{TrackCodec, probe, probe_detailed}; use support::{ encode_raw_box, encode_supported_box, fixture_path, fourcc, read_golden, read_text, temp_output_dir, write_temp_file, }; +const DIVIDE_SCOPE_MESSAGE: &str = "divide currently supports fragmented inputs with at most one video track from AVC, HEVC, Dolby Vision on HEVC, AV1, VP8, or VP9 and one or more audio tracks from MP4A-based audio, Opus, AC-3, E-AC-3, AC-4, ALAC, DTS-family entries, FLAC, IAMF, MPEG-H, or PCM; subtitle and text tracks remain unsupported"; + #[test] fn divide_command_writes_playlists_and_segments() { let input = build_divide_input_file(); @@ -42,13 +53,11 @@ fn divide_command_writes_playlists_and_segments() { let master_playlist = fs::read_to_string(output_dir.join("playlist.m3u8")).unwrap(); let video_playlist = fs::read_to_string(output_dir.join("video").join("playlist.m3u8")).unwrap(); + let manifest = fs::read_to_string(output_dir.join("manifest.mpd")).unwrap(); let init = fs::read(output_dir.join("video").join("init.mp4")).unwrap(); let segment0 = fs::read(output_dir.join("video").join("0.mp4")).unwrap(); let segment1 = fs::read(output_dir.join("video").join("1.mp4")).unwrap(); - let _ = fs::remove_file(&input_path); - let _ = fs::remove_dir_all(&output_dir); - assert_eq!( master_playlist, concat!( @@ -72,6 +81,9 @@ fn divide_command_writes_playlists_and_segments() { "#EXT-X-ENDLIST\n" ) ); + assert!(manifest.contains(">(); + assert!(lines[5].starts_with("#EXT-X-PROGRAM-DATE-TIME:")); + assert_eq!(lines[6], "#EXTINF:1.000000,"); + assert!(lines[5].ends_with('Z')); } #[test] -fn divide_command_rejects_multiple_video_tracks_with_clear_message() { - let input = build_two_video_track_divide_input_file(); - let input_path = write_temp_file("divide-multi-video-input", &input); - let output_dir = temp_output_dir("divide-multi-video-output"); +fn divide_command_writes_manifest_name_overrides() { + let input = build_divide_input_file(); + let input_path = write_temp_file("divide-manifest-name-input", &input); + let output_dir = temp_output_dir("divide-manifest-name-output"); let args = vec![ + "-hls-master-playlist-name".to_string(), + "master.m3u8".to_string(), + "-hls-media-playlist-name".to_string(), + "media.m3u8".to_string(), + "-dash-manifest-name".to_string(), + "stream.mpd".to_string(), input_path.to_string_lossy().into_owned(), output_dir.to_string_lossy().into_owned(), ]; let mut stderr = Vec::new(); let exit_code = divide::run(&args, &mut stderr); + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + + assert!(output_dir.join("master.m3u8").is_file()); + assert!(!output_dir.join("playlist.m3u8").exists()); + assert!(output_dir.join("stream.mpd").is_file()); + assert!(!output_dir.join("manifest.mpd").exists()); + assert!(output_dir.join("video").join("media.m3u8").is_file()); + assert!(!output_dir.join("video").join("playlist.m3u8").exists()); - assert_eq!(exit_code, 1); assert_eq!( - String::from_utf8(stderr).unwrap(), + read_text(&output_dir.join("master.m3u8")), concat!( - "Error: divide currently supports fragmented inputs with at most one AVC video track and one MP4A audio track; ", - "found multiple fragmented video tracks (1 and 2).\n" + "#EXTM3U\n", + "#EXT-X-STREAM-INF:BANDWIDTH=128,CODECS=\"avc1.64001f\",RESOLUTION=1920x1080\n", + "video/media.m3u8\n" ) ); - - let _ = fs::remove_file(&input_path); - let _ = fs::remove_dir_all(&output_dir); + let manifest = read_text(&output_dir.join("stream.mpd")); + assert!(manifest.contains("initialization=\"video/init.mp4\"")); } #[test] -fn divide_command_matches_shared_fragmented_fixture_outputs() { - let input_path = fixture_path("sample_fragmented.mp4"); - let mut input = fs::File::open(&input_path).unwrap(); - let input_summary = probe(&mut input).unwrap(); +fn divide_command_writes_dynamic_dash_only_manifest_when_requested() { + let input = build_divide_input_file(); + let input_path = write_temp_file("divide-dynamic-dash-input", &input); + let output_dir = temp_output_dir("divide-dynamic-dash-output"); + let args = vec![ + "-manifest".to_string(), + "dash".to_string(), + "-dash-mode".to_string(), + "dynamic".to_string(), + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; - let output_dir = temp_output_dir("divide-fixture-output"); + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let manifest = fs::read_to_string(output_dir.join("manifest.mpd")).unwrap(); + assert!(manifest.contains("type=\"dynamic\"")); + assert!(manifest.contains("minimumUpdatePeriod=\"PT5S\"")); + assert!(manifest.contains("availabilityStartTime=\"")); + assert!(manifest.contains("publishTime=\"")); + assert!(!manifest.contains("availabilityStartTime=\"1970-01-01T00:00:00Z\"")); + assert!(!manifest.contains("publishTime=\"1970-01-01T00:00:00Z\"")); + assert!(!manifest.contains("timeShiftBufferDepth=")); + assert!(!manifest.contains("suggestedPresentationDelay=")); + assert!(!manifest.contains("mediaPresentationDuration=")); + assert!(!output_dir.join("playlist.m3u8").exists()); +} + +#[test] +fn divide_command_writes_dynamic_dash_manifest_with_defaults_and_repeated_base_urls() { + let input = build_divide_input_file(); + let input_path = write_temp_file("divide-dynamic-dash-defaults-input", &input); + let output_dir = temp_output_dir("divide-dynamic-dash-defaults-output"); let args = vec![ + "-manifest".to_string(), + "dash".to_string(), + "-dash-mode".to_string(), + "dynamic".to_string(), + "-dash-profile".to_string(), + "live".to_string(), + "-dash-base-url".to_string(), + "https://cdn.example.invalid/root/".to_string(), + "-dash-base-url".to_string(), + "https://cdn-backup.example.invalid/root/".to_string(), input_path.to_string_lossy().into_owned(), output_dir.to_string_lossy().into_owned(), ]; @@ -169,188 +279,1270 @@ fn divide_command_matches_shared_fragmented_fixture_outputs() { let mut stderr = Vec::new(); let exit_code = divide::run(&args, &mut stderr); assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); - assert_eq!(String::from_utf8(stderr.clone()).unwrap(), ""); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let manifest = fs::read_to_string(output_dir.join("manifest.mpd")).unwrap(); + assert!(manifest.contains("type=\"dynamic\"")); + assert!(manifest.contains("profiles=\"urn:mpeg:dash:profile:isoff-live:2011\"")); + assert!(manifest.contains("https://cdn.example.invalid/root/")); + assert!(manifest.contains("https://cdn-backup.example.invalid/root/")); + assert!(manifest.contains("minBufferTime=\"PT2S\"")); + assert!(manifest.contains("minimumUpdatePeriod=\"PT5S\"")); + assert!(manifest.contains("availabilityStartTime=\"")); + assert!(manifest.contains("publishTime=\"")); + assert!(manifest.contains("")); + assert!(!manifest.contains("")); + assert!(!manifest.contains("")); + assert!(manifest.contains("https://cdn.example.invalid/root/")); + assert!(manifest.contains("https://cdn-backup.example.invalid/root/")); + assert!(manifest.contains("")); + assert!(manifest.contains("")); + assert!(manifest.contains("")); + assert!(second_output_dir.join("video").join("2.mp4").is_file()); + assert!(second_output_dir.join("video").join("3.mp4").is_file()); + assert!(!second_output_dir.join("video").join("0.mp4").exists()); + assert!(!second_output_dir.join("playlist.m3u8").exists()); + assert!( + !second_output_dir + .join("video") + .join("playlist.m3u8") + .exists() ); +} + +#[test] +fn divide_command_session_reload_still_allows_manifest_override_with_continuity() { + let input = build_divide_input_file(); + let input_path = write_temp_file("divide-session-override-input", &input); + let first_output_dir = temp_output_dir("divide-session-override-first-output"); + let session_path = first_output_dir.join("dash.session"); + let first_args = vec![ + "-manifest".to_string(), + "dash".to_string(), + "-dash-mode".to_string(), + "dynamic".to_string(), + "-dash-layout".to_string(), + "list".to_string(), + "-dash-profile".to_string(), + "live".to_string(), + "-dash-session-save".to_string(), + session_path.to_string_lossy().into_owned(), + input_path.to_string_lossy().into_owned(), + first_output_dir.to_string_lossy().into_owned(), + ]; + let mut first_stderr = Vec::new(); + let first_exit_code = divide::run(&first_args, &mut first_stderr); assert_eq!( - sorted_file_names(&output_dir.join("video")), - [ - "0.mp4", - "1.mp4", - "2.mp4", - "3.mp4", - "init.mp4", - "playlist.m3u8" - ] + first_exit_code, + 0, + "{}", + String::from_utf8_lossy(&first_stderr) ); + assert_eq!(String::from_utf8(first_stderr).unwrap(), ""); + + let second_output_dir = temp_output_dir("divide-session-override-second-output"); + let second_args = vec![ + "-manifest".to_string(), + "both".to_string(), + "-dash-session-load".to_string(), + session_path.to_string_lossy().into_owned(), + input_path.to_string_lossy().into_owned(), + second_output_dir.to_string_lossy().into_owned(), + ]; + + let mut second_stderr = Vec::new(); + let second_exit_code = divide::run(&second_args, &mut second_stderr); assert_eq!( - sorted_file_names(&output_dir.join("audio")), - [ - "0.mp4", - "1.mp4", - "2.mp4", - "3.mp4", - "init.mp4", - "playlist.m3u8" - ] + second_exit_code, + 0, + "{}", + String::from_utf8_lossy(&second_stderr) ); - - let video_init = probe_file(&output_dir.join("video").join("init.mp4")); - assert_eq!(video_init.tracks.len(), 1); - assert_eq!(video_init.tracks[0].track_id, 1); - assert_eq!(video_init.tracks[0].codec, TrackCodec::Avc1); - assert_eq!(video_init.tracks[0].avc.as_ref().unwrap().width, 1280); - assert_eq!(video_init.tracks[0].avc.as_ref().unwrap().height, 720); - assert!(video_init.segments.is_empty()); - - let audio_init = probe_file(&output_dir.join("audio").join("init.mp4")); - assert_eq!(audio_init.tracks.len(), 1); - assert_eq!(audio_init.tracks[0].track_id, 2); - assert_eq!(audio_init.tracks[0].codec, TrackCodec::Mp4a); - assert!(audio_init.segments.is_empty()); - - let expected_video = input_summary - .segments - .iter() - .filter(|segment| segment.track_id == 1) - .collect::>(); - let expected_audio = input_summary - .segments - .iter() - .filter(|segment| segment.track_id == 2) - .collect::>(); - - for (index, expected) in expected_video.iter().enumerate() { - assert_segment_matches( - expected, - &output_dir.join("video").join(format!("{index}.mp4")), - ); - } - for (index, expected) in expected_audio.iter().enumerate() { - assert_segment_matches( - expected, - &output_dir.join("audio").join(format!("{index}.mp4")), - ); - } - - let _ = fs::remove_dir_all(&output_dir); + assert_eq!(String::from_utf8(second_stderr).unwrap(), ""); + let manifest = fs::read_to_string(second_output_dir.join("manifest.mpd")).unwrap(); + let video_playlist = + fs::read_to_string(second_output_dir.join("video").join("playlist.m3u8")).unwrap(); + assert!(manifest.contains("type=\"dynamic\"")); + assert!(manifest.contains("")); + assert!(manifest.contains("")); + assert!(manifest.contains("")); + assert!(manifest.contains("")); + assert!(video_playlist.contains("#EXT-X-MEDIA-SEQUENCE:2")); + assert!(video_playlist.contains("\n2.mp4\n")); + assert!(video_playlist.contains("\n3.mp4\n")); + assert!(second_output_dir.join("playlist.m3u8").is_file()); } #[test] -fn divide_validate_reports_supported_layout_without_writing_files() { - let input = build_video_and_audio_divide_input_file(); - let input_path = write_temp_file("divide-validate-supported-input", &input); +fn divide_command_writes_dash_segment_list_when_requested() { + let input = build_divide_input_file(); + let input_path = write_temp_file("divide-dash-list-input", &input); + let output_dir = temp_output_dir("divide-dash-list-output"); let args = vec![ - "-validate".to_string(), + "-manifest".to_string(), + "dash".to_string(), + "-dash-layout".to_string(), + "list".to_string(), input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), ]; - let mut stdout = Vec::new(); let mut stderr = Vec::new(); - let exit_code = divide::run_with_output(&args, &mut stdout, &mut stderr); - - let _ = fs::remove_file(&input_path); - + let exit_code = divide::run(&args, &mut stderr); assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let manifest = fs::read_to_string(output_dir.join("manifest.mpd")).unwrap(); + assert!(manifest.contains("")); + assert!(manifest.contains("")); + assert!(manifest.contains("")); + assert!(!manifest.contains(" Manifest families to write (default: both)\n", + " -default-language Prefer this audio language in HLS defaults and DASH main-role signaling\n", + " -hls-base-url Prefix HLS playlist, init, and media segment URIs\n", + " -hls-playlist-type HLS playlist style (default: vod)\n", + " -hls-start-time-offset Add EXT-X-START with a signed seconds offset\n", + " -hls-program-date-time Add EXT-X-PROGRAM-DATE-TIME to HLS media playlists\n", + " -hls-master-playlist-name Override the root HLS master playlist file name\n", + " -hls-media-playlist-name Override per-track HLS media playlist file names\n", + " -dash-mode DASH manifest mode (default: static)\n", + " -dash-layout DASH manifest layout (default: template)\n", + " -dash-profile DASH profile signaling (default: main)\n", + " -dash-base-url Add one DASH BaseURL element (repeatable)\n", + " -dash-manifest-name Override the root DASH manifest file name\n", + " -dash-session-load Reload saved DASH session controls and next-period continuity\n", + " -dash-session-save Save DASH session controls and next-period continuity\n", + "\n", + "Successful output writes the selected retained HLS playlist tree and/or additive MPD manifest.\n", + "DASH metadata such as Period ids, timing descriptors, and dynamic refresh attributes use built-in defaults.\n", + "\n", + "Currently supports fragmented inputs with up to one video track from AVC, HEVC, Dolby Vision on HEVC, AV1, VP8, or VP9\n", + "and one or more audio tracks from MP4A-based audio, Opus, AC-3, E-AC-3, AC-4, ALAC, DTS-family entries, FLAC, IAMF, MPEG-H, or PCM,\n", + "including encrypted wrappers that preserve those original sample-entry formats. Subtitle and text tracks remain unsupported.\n", ) ); } #[test] -fn divide_validate_rejects_duplicate_video_layouts_before_writing_output() { - let input = build_two_video_track_divide_input_file(); - let input_path = write_temp_file("divide-validate-duplicate-video-input", &input); +fn divide_command_rejects_removed_dash_override_options() { + let input = build_divide_input_file(); + let input_path = write_temp_file("divide-removed-dash-option-input", &input); + let output_dir = temp_output_dir("divide-removed-dash-option-output"); let args = vec![ - "--validate".to_string(), + "-manifest".to_string(), + "dash".to_string(), + "-dash-minimum-update-period".to_string(), + "5".to_string(), input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), ]; - let mut stdout = Vec::new(); let mut stderr = Vec::new(); - let exit_code = divide::run_with_output(&args, &mut stdout, &mut stderr); - - let _ = fs::remove_file(&input_path); + let exit_code = divide::run(&args, &mut stderr); assert_eq!(exit_code, 1); - assert_eq!(String::from_utf8(stdout).unwrap(), ""); assert_eq!( String::from_utf8(stderr).unwrap(), - concat!( - "Error: divide currently supports fragmented inputs with at most one AVC video track and one MP4A audio track; ", - "found multiple fragmented video tracks (1 and 2).\n" - ) + "Error [stage=request category=input]: divide option `-dash-minimum-update-period` was removed; mp4forge now uses built-in DASH minimumUpdatePeriod defaults\n" ); } #[test] -fn divide_validate_rejects_unsupported_hevc_layout_with_clear_message() { - let input = build_hevc_divide_input_file(); - let input_path = write_temp_file("divide-validate-hevc-input", &input); +fn divide_command_rejects_dash_only_options_when_manifest_selection_is_hls() { + let input = build_divide_input_file(); + let input_path = write_temp_file("divide-hls-dash-option-invalid-input", &input); + let output_dir = temp_output_dir("divide-hls-dash-option-invalid-output"); let args = vec![ - "-validate".to_string(), + "-manifest".to_string(), + "hls".to_string(), + "-dash-mode".to_string(), + "dynamic".to_string(), input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), ]; - let mut stdout = Vec::new(); let mut stderr = Vec::new(); - let exit_code = divide::run_with_output(&args, &mut stdout, &mut stderr); - - let _ = fs::remove_file(&input_path); + let exit_code = divide::run(&args, &mut stderr); assert_eq!(exit_code, 1); - assert_eq!(String::from_utf8(stdout).unwrap(), ""); assert_eq!( String::from_utf8(stderr).unwrap(), - concat!( - "Error: track 1 uses unsupported codec `hvc1`; ", - "divide currently supports fragmented inputs with at most one AVC video track and one MP4A audio track\n" - ) + "Error [stage=request category=input]: divide manifest selection `hls` does not support `-dash-mode`; use `-manifest dash` or `-manifest both`\n" ); } #[test] -fn validate_divide_reader_reports_supported_tracks() { - let input = build_video_and_audio_divide_input_file(); - let report = divide::validate_divide_reader(&mut std::io::Cursor::new(input)).unwrap(); +fn divide_command_rejects_hls_only_options_when_manifest_selection_is_dash() { + let input = build_divide_input_file(); + let input_path = write_temp_file("divide-dash-hls-option-invalid-input", &input); + let output_dir = temp_output_dir("divide-dash-hls-option-invalid-output"); + let args = vec![ + "-manifest".to_string(), + "dash".to_string(), + "-hls-base-url".to_string(), + "https://cdn.example.invalid/hls/".to_string(), + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; - assert_eq!(report.tracks.len(), 2); - assert_eq!(report.tracks[0].track_id, 1); - assert_eq!(report.tracks[0].role, divide::DivideTrackRole::Video); - assert_eq!(report.tracks[0].sample_entry_type, Some(fourcc("avc1"))); - assert_eq!(report.tracks[0].segment_count, 1); - assert_eq!(report.tracks[1].track_id, 2); - assert_eq!(report.tracks[1].role, divide::DivideTrackRole::Audio); - assert_eq!(report.tracks[1].sample_entry_type, Some(fourcc("mp4a"))); - assert_eq!(report.tracks[1].segment_count, 1); + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error [stage=request category=input]: divide manifest selection `dash` does not support `-hls-base-url`; use `-manifest hls` or `-manifest both`\n" + ); } -fn build_divide_input_file() -> Vec { - build_fragmented_input_file( - vec![build_video_trak_with_profile( - 1, 1_920, 1_080, 0x64, 0x00, 0x1f, - )], - vec![ - build_track_segment(1, 0, 1_000, 8), - build_track_segment(1, 1_000, 1_000, 8), +#[test] +fn divide_command_rejects_dynamic_only_dash_options_in_static_mode() { + let input = build_divide_input_file(); + let input_path = write_temp_file("divide-static-dynamic-option-invalid-input", &input); + let output_dir = temp_output_dir("divide-static-dynamic-option-invalid-output"); + let args = vec![ + "-manifest".to_string(), + "dash".to_string(), + "-dash-profile".to_string(), + "live".to_string(), + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error [stage=request category=input]: divide DASH profile `live` requires `-dash-mode dynamic`\n" + ); +} + +#[test] +fn divide_command_rejects_session_state_options_in_validate_mode() { + let input = build_divide_input_file(); + let input_path = write_temp_file("divide-validate-session-invalid-input", &input); + let session_dir = temp_output_dir("divide-validate-session-state"); + let session_path = session_dir.join("dash.session"); + let args = vec![ + "-validate".to_string(), + "-dash-session-save".to_string(), + session_path.to_string_lossy().into_owned(), + input_path.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error [stage=request category=input]: divide validation mode does not support `-dash-session-save`\n" + ); +} + +#[test] +fn divide_command_rejects_reusing_the_same_session_state_path() { + let input = build_divide_input_file(); + let input_path = write_temp_file("divide-session-reuse-invalid-input", &input); + let output_dir = temp_output_dir("divide-session-reuse-invalid-output"); + let session_path = output_dir.join("dash.session"); + let args = vec![ + "-manifest".to_string(), + "dash".to_string(), + "-dash-session-load".to_string(), + session_path.to_string_lossy().into_owned(), + "-dash-session-save".to_string(), + session_path.to_string_lossy().into_owned(), + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + format!( + "Error [stage=request category=input]: divide DASH session load and save paths must differ: `{}`\n", + session_path.display() + ) + ); +} + +#[test] +fn divide_command_derives_master_playlist_signaling_from_probe_metadata() { + let input = build_video_and_audio_divide_input_file(); + let input_path = write_temp_file("divide-signaling-input", &input); + let output_dir = temp_output_dir("divide-signaling-output"); + let args = vec![ + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!( + read_text(&output_dir.join("playlist.m3u8")), + concat!( + "#EXTM3U\n", + "#EXT-X-MEDIA:TYPE=AUDIO,URI=\"audio/playlist.m3u8\",GROUP-ID=\"audio\",NAME=\"audio\",AUTOSELECT=YES,CHANNELS=\"6\"\n", + "#EXT-X-STREAM-INF:BANDWIDTH=128,CODECS=\"avc1.4d401f,mp4a.40.5\",RESOLUTION=640x360,AUDIO=\"audio\"\n", + "video/playlist.m3u8\n" + ) + ); + let manifest = read_text(&output_dir.join("manifest.mpd")); + assert!(manifest.contains("contentType=\"video\"")); + assert!(manifest.contains("contentType=\"audio\"")); + assert!(manifest.contains("codecs=\"avc1.4d401f\"")); + assert!(manifest.contains("codecs=\"mp4a.40.5\"")); + assert!(manifest.contains("audioSamplingRate=\"48000\"")); + assert!(manifest.contains("initialization=\"video/init.mp4\"")); + assert!(manifest.contains("initialization=\"audio/init.mp4\"")); +} + +#[test] +fn divide_command_writes_multi_audio_group_outputs() { + let input = build_avc_with_aac_and_ac3_divide_input_file(); + let input_path = write_temp_file("divide-multi-audio-input", &input); + let output_dir = temp_output_dir("divide-multi-audio-output"); + let args = vec![ + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!( + read_text(&output_dir.join("playlist.m3u8")), + concat!( + "#EXTM3U\n", + "#EXT-X-MEDIA:TYPE=AUDIO,URI=\"audio_2/playlist.m3u8\",GROUP-ID=\"audio\",NAME=\"audio-2\",AUTOSELECT=YES,DEFAULT=YES,CHANNELS=\"2\"\n", + "#EXT-X-MEDIA:TYPE=AUDIO,URI=\"audio_3/playlist.m3u8\",GROUP-ID=\"audio\",NAME=\"audio-3\",AUTOSELECT=YES,DEFAULT=NO,CHANNELS=\"6\"\n", + "#EXT-X-STREAM-INF:BANDWIDTH=128,CODECS=\"avc1.4d401f,mp4a.40.2,ac-3\",RESOLUTION=640x360,AUDIO=\"audio\"\n", + "video/playlist.m3u8\n" + ) + ); + assert_eq!( + read_text(&output_dir.join("audio_2").join("playlist.m3u8")), + concat!( + "#EXTM3U\n", + "#EXT-X-VERSION:7\n", + "#EXT-X-TARGETDURATION:1\n", + "#EXT-X-PLAYLIST-TYPE:VOD\n", + "#EXT-X-MAP:URI=\"init.mp4\"\n", + "#EXTINF:1.000000,\n", + "0.mp4\n", + "#EXT-X-ENDLIST\n" + ) + ); + assert_eq!( + read_text(&output_dir.join("audio_3").join("playlist.m3u8")), + concat!( + "#EXTM3U\n", + "#EXT-X-VERSION:7\n", + "#EXT-X-TARGETDURATION:1\n", + "#EXT-X-PLAYLIST-TYPE:VOD\n", + "#EXT-X-MAP:URI=\"init.mp4\"\n", + "#EXTINF:1.000000,\n", + "0.mp4\n", + "#EXT-X-ENDLIST\n" + ) + ); + let manifest = read_text(&output_dir.join("manifest.mpd")); + assert!(manifest.contains("contentType=\"video\"")); + assert!(manifest.contains("contentType=\"audio\"")); + assert!(manifest.contains("codecs=\"mp4a.40.2\"")); + assert!(manifest.contains("codecs=\"ac-3\"")); + assert!(manifest.contains("initialization=\"audio_2/init.mp4\"")); + assert!(manifest.contains("initialization=\"audio_3/init.mp4\"")); +} + +#[test] +fn divide_command_prefers_default_language_for_hls_and_dash() { + let input = build_avc_with_aac_and_ac3_divide_input_file_with_languages(*b"eng", *b"fra"); + let input_path = write_temp_file("divide-default-language-input", &input); + let output_dir = temp_output_dir("divide-default-language-output"); + let args = vec![ + "-default-language".to_string(), + "fra".to_string(), + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!( + read_text(&output_dir.join("playlist.m3u8")), + concat!( + "#EXTM3U\n", + "#EXT-X-MEDIA:TYPE=AUDIO,URI=\"audio_2/playlist.m3u8\",GROUP-ID=\"audio\",NAME=\"audio-2\",AUTOSELECT=YES,DEFAULT=NO,LANGUAGE=\"eng\",CHANNELS=\"2\"\n", + "#EXT-X-MEDIA:TYPE=AUDIO,URI=\"audio_3/playlist.m3u8\",GROUP-ID=\"audio\",NAME=\"audio-3\",AUTOSELECT=YES,DEFAULT=YES,LANGUAGE=\"fra\",CHANNELS=\"6\"\n", + "#EXT-X-STREAM-INF:BANDWIDTH=128,CODECS=\"avc1.4d401f,mp4a.40.2,ac-3\",RESOLUTION=640x360,AUDIO=\"audio\"\n", + "video/playlist.m3u8\n" + ) + ); + let manifest = read_text(&output_dir.join("manifest.mpd")); + assert!(!manifest.contains(concat!( + "\n", + " \n" + ))); + assert!(manifest.contains(concat!( + "\n", + " \n" + ))); +} + +#[test] +fn divide_command_rejects_multiple_video_tracks_with_clear_message() { + let input = build_two_video_track_divide_input_file(); + let input_path = write_temp_file("divide-multi-video-input", &input); + let output_dir = temp_output_dir("divide-multi-video-output"); + let args = vec![ + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + format!( + "Error [stage=request category=input]: {DIVIDE_SCOPE_MESSAGE}; found multiple fragmented video tracks (1 and 2).\n" + ) + ); +} + +#[test] +fn divide_command_matches_shared_fragmented_fixture_outputs() { + let input_path = fixture_path("sample_fragmented.mp4"); + let mut input = fs::File::open(&input_path).unwrap(); + let input_summary = probe(&mut input).unwrap(); + + let output_dir = temp_output_dir("divide-fixture-output"); + let args = vec![ + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr.clone()).unwrap(), ""); + + assert_eq!( + read_text(&output_dir.join("playlist.m3u8")), + read_golden("cli_divide/sample_fragmented/master.m3u8") + ); + assert_eq!( + read_text(&output_dir.join("video").join("playlist.m3u8")), + read_golden("cli_divide/sample_fragmented/video.m3u8") + ); + assert_eq!( + read_text(&output_dir.join("audio").join("playlist.m3u8")), + read_golden("cli_divide/sample_fragmented/audio.m3u8") + ); + + assert_eq!( + sorted_file_names(&output_dir.join("video")), + [ + "0.mp4", + "1.mp4", + "2.mp4", + "3.mp4", + "init.mp4", + "playlist.m3u8" + ] + ); + assert_eq!( + sorted_file_names(&output_dir.join("audio")), + [ + "0.mp4", + "1.mp4", + "2.mp4", + "3.mp4", + "init.mp4", + "playlist.m3u8" + ] + ); + + let video_init = probe_file(&output_dir.join("video").join("init.mp4")); + assert_eq!(video_init.tracks.len(), 1); + assert_eq!(video_init.tracks[0].track_id, 1); + assert_eq!(video_init.tracks[0].codec, TrackCodec::Avc1); + assert_eq!(video_init.tracks[0].avc.as_ref().unwrap().width, 1280); + assert_eq!(video_init.tracks[0].avc.as_ref().unwrap().height, 720); + assert!(video_init.segments.is_empty()); + + let audio_init = probe_file(&output_dir.join("audio").join("init.mp4")); + assert_eq!(audio_init.tracks.len(), 1); + assert_eq!(audio_init.tracks[0].track_id, 2); + assert_eq!(audio_init.tracks[0].codec, TrackCodec::Mp4a); + assert!(audio_init.segments.is_empty()); + + let expected_video = input_summary + .segments + .iter() + .filter(|segment| segment.track_id == 1) + .collect::>(); + let expected_audio = input_summary + .segments + .iter() + .filter(|segment| segment.track_id == 2) + .collect::>(); + + for (index, expected) in expected_video.iter().enumerate() { + assert_segment_matches( + expected, + &output_dir.join("video").join(format!("{index}.mp4")), + ); + } + for (index, expected) in expected_audio.iter().enumerate() { + assert_segment_matches( + expected, + &output_dir.join("audio").join(format!("{index}.mp4")), + ); + } +} + +#[test] +fn divide_dash_list_manifest_round_trips_through_mux_input() { + let input = build_video_and_audio_divide_input_file(); + let input_path = write_temp_file("divide-dash-list-import-input", &input); + let output_dir = temp_output_dir("divide-dash-list-import-output"); + let args = vec![ + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + "-manifest".to_string(), + "dash".to_string(), + "-dash-layout".to_string(), + "list".to_string(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + + let manifest_path = output_dir.join("manifest.mpd"); + let output_path = write_temp_file("divide-dash-list-import-remux-output", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]), + &output_path, + ) + .unwrap(); + + let remuxed = probe_file(&output_path); + assert_eq!(remuxed.segments.len(), 0); + assert_eq!(remuxed.tracks.len(), 2); + assert_eq!(remuxed.tracks[0].codec, TrackCodec::Avc1); + assert_eq!(remuxed.tracks[1].codec, TrackCodec::Mp4a); +} + +#[test] +fn divide_dash_template_manifest_round_trips_through_mux_input() { + let input = build_video_and_audio_divide_input_file(); + let input_path = write_temp_file("divide-dash-template-import-input", &input); + let output_dir = temp_output_dir("divide-dash-template-import-output"); + let args = vec![ + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + "-manifest".to_string(), + "dash".to_string(), + "-dash-layout".to_string(), + "template".to_string(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + + let manifest_path = output_dir.join("manifest.mpd"); + let output_path = write_temp_file("divide-dash-template-import-remux-output", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]), + &output_path, + ) + .unwrap(); + + let remuxed = probe_file(&output_path); + assert_eq!(remuxed.segments.len(), 0); + assert_eq!(remuxed.tracks.len(), 2); + assert_eq!(remuxed.tracks[0].codec, TrackCodec::Avc1); + assert_eq!(remuxed.tracks[1].codec, TrackCodec::Mp4a); +} + +#[test] +fn divide_validate_reports_supported_layout_without_writing_files() { + let input = build_video_and_audio_divide_input_file(); + let input_path = write_temp_file("divide-validate-supported-input", &input); + let args = vec![ + "-validate".to_string(), + input_path.to_string_lossy().into_owned(), + ]; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = divide::run_with_output(&args, &mut stdout, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!( + String::from_utf8(stdout).unwrap(), + concat!( + "supported fragmented divide layout\n", + "track 1: role=video codec=avc1 segments=1\n", + "track 2: role=audio codec=mp4a segments=1\n", + ) + ); +} + +#[test] +fn validate_divide_reader_accepts_supported_broader_video_families() { + let cases = [ + ("hevc", build_hevc_divide_input_file(), "hvc1"), + ("av1", build_av1_divide_input_file(), "av01"), + ("vp8", build_vp8_divide_input_file(), "vp08"), + ("vp9", build_vp9_divide_input_file(), "vp09"), + ("dvh1", build_dvh1_divide_input_file(), "dvh1"), + ("dvhe", build_dvhe_divide_input_file(), "dvhe"), + ]; + + for (name, input, sample_entry_type) in cases { + let report = divide::validate_divide_reader(&mut std::io::Cursor::new(input)).unwrap(); + assert_eq!(report.tracks.len(), 1, "case={name}"); + assert_eq!(report.tracks[0].track_id, 1, "case={name}"); + assert_eq!( + report.tracks[0].role, + divide::DivideTrackRole::Video, + "case={name}" + ); + assert_eq!( + report.tracks[0].sample_entry_type, + Some(fourcc(sample_entry_type)), + "case={name}" + ); + assert_eq!(report.tracks[0].segment_count, 1, "case={name}"); + } +} + +#[test] +fn validate_divide_reader_accepts_supported_broader_audio_families() { + let cases = [ + ("mp3", build_mp3_divide_input_file(), "mp4a"), + ("opus", build_opus_divide_input_file(), "Opus"), + ("ac3", build_ac3_divide_input_file(), "ac-3"), + ("ec3", build_ec3_divide_input_file(), "ec-3"), + ("ac4", build_ac4_divide_input_file(), "ac-4"), + ("alac", build_alac_divide_input_file(), "alac"), + ("dtsc", build_dtsc_divide_input_file(), "dtsc"), + ("dtse", build_dtse_divide_input_file(), "dtse"), + ("dtsh", build_dtsh_divide_input_file(), "dtsh"), + ("dtsl", build_dtsl_divide_input_file(), "dtsl"), + ("dtsm", build_dtsm_divide_input_file(), "dtsm"), + ("dtsx", build_dtsx_divide_input_file(), "dtsx"), + ("dtsy", build_dtsy_divide_input_file(), "dtsy"), + ("flac", build_flac_divide_input_file(), "fLaC"), + ("iamf", build_iamf_divide_input_file(), "iamf"), + ("mha1", build_mha1_divide_input_file(), "mha1"), + ("mhm1", build_mhm1_divide_input_file(), "mhm1"), + ("pcm", build_pcm_divide_input_file(), "ipcm"), + ]; + + for (name, input, sample_entry_type) in cases { + let report = divide::validate_divide_reader(&mut std::io::Cursor::new(input)).unwrap(); + assert_eq!(report.tracks.len(), 1, "case={name}"); + assert_eq!(report.tracks[0].track_id, 1, "case={name}"); + assert_eq!( + report.tracks[0].role, + divide::DivideTrackRole::Audio, + "case={name}" + ); + assert_eq!( + report.tracks[0].sample_entry_type, + Some(fourcc(sample_entry_type)), + "case={name}" + ); + assert_eq!(report.tracks[0].segment_count, 1, "case={name}"); + } +} + +#[test] +fn validate_divide_reader_accepts_encrypted_hevc_tracks_with_original_format() { + let report = divide::validate_divide_reader(&mut std::io::Cursor::new( + build_encrypted_hevc_divide_input_file(), + )) + .unwrap(); + + assert_eq!(report.tracks.len(), 1); + assert_eq!(report.tracks[0].track_id, 1); + assert_eq!(report.tracks[0].role, divide::DivideTrackRole::Video); + assert!(report.tracks[0].encrypted); + assert_eq!(report.tracks[0].sample_entry_type, Some(fourcc("encv"))); + assert_eq!(report.tracks[0].original_format, Some(fourcc("hvc1"))); + assert_eq!(report.tracks[0].segment_count, 1); +} + +#[test] +fn divide_command_derives_master_playlist_signaling_from_broader_codec_metadata() { + let cases = [ + ( + "hevc-opus", + build_hevc_and_opus_divide_input_file(), + "hvc1,Opus", + 2_u16, + ), + ( + "avc-mp3", + build_avc_and_mp3_divide_input_file(), + "avc1.4d401f,mp4a.6b", + 2_u16, + ), + ( + "avc-ec3", + build_avc_and_ec3_divide_input_file(), + "avc1.4d401f,ec-3", + 6_u16, + ), + ( + "avc-ac4", + build_avc_and_ac4_divide_input_file(), + "avc1.4d401f,ac-4", + 2_u16, + ), + ( + "avc-alac", + build_avc_and_alac_divide_input_file(), + "avc1.4d401f,alac", + 2_u16, + ), + ( + "avc-dtsc", + build_avc_and_dtsc_divide_input_file(), + "avc1.4d401f,dtsc", + 6_u16, + ), + ( + "avc-flac", + build_avc_and_flac_divide_input_file(), + "avc1.4d401f,fLaC", + 2_u16, + ), + ( + "avc-iamf", + build_avc_and_iamf_divide_input_file(), + "avc1.4d401f,iamf", + 2_u16, + ), + ( + "avc-mha1", + build_avc_and_mha1_divide_input_file(), + "avc1.4d401f,mha1", + 2_u16, + ), + ( + "avc-mhm1", + build_avc_and_mhm1_divide_input_file(), + "avc1.4d401f,mhm1", + 2_u16, + ), + ]; + + for (name, input, codecs, channels) in cases { + let input_path = write_temp_file(&format!("divide-{name}-signaling-input"), &input); + let output_dir = temp_output_dir(&format!("divide-{name}-signaling-output")); + let args = vec![ + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!( + exit_code, + 0, + "case={name}: {}", + String::from_utf8_lossy(&stderr) + ); + assert_eq!(String::from_utf8(stderr).unwrap(), "", "case={name}"); + assert_eq!( + read_text(&output_dir.join("playlist.m3u8")), + format!( + "#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,URI=\"audio/playlist.m3u8\",GROUP-ID=\"audio\",NAME=\"audio\",AUTOSELECT=YES,CHANNELS=\"{channels}\"\n#EXT-X-STREAM-INF:BANDWIDTH=128,CODECS=\"{codecs}\",RESOLUTION=640x360,AUDIO=\"audio\"\nvideo/playlist.m3u8\n" + ), + "case={name}" + ); + } +} + +#[test] +fn divide_command_writes_supported_broader_video_family_outputs() { + let cases = [ + ("hevc", build_hevc_divide_input_file(), "hvc1"), + ("av1", build_av1_divide_input_file(), "av01"), + ("vp8", build_vp8_divide_input_file(), "vp08"), + ("vp9", build_vp9_divide_input_file(), "vp09"), + ("dvh1", build_dvh1_divide_input_file(), "dvh1"), + ("dvhe", build_dvhe_divide_input_file(), "dvhe"), + ]; + + for (name, input, codec) in cases { + let mut input_summary_reader = std::io::Cursor::new(input.clone()); + let input_summary = probe(&mut input_summary_reader).unwrap(); + let input_path = write_temp_file(&format!("divide-{name}-video-input"), &input); + let output_dir = temp_output_dir(&format!("divide-{name}-video-output")); + let args = vec![ + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!( + exit_code, + 0, + "case={name}: {}", + String::from_utf8_lossy(&stderr) + ); + assert_eq!(String::from_utf8(stderr).unwrap(), "", "case={name}"); + assert_eq!( + read_text(&output_dir.join("playlist.m3u8")), + format!( + "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=128,CODECS=\"{codec}\",RESOLUTION=640x360\nvideo/playlist.m3u8\n" + ), + "case={name}" + ); + assert_eq!( + read_text(&output_dir.join("video").join("playlist.m3u8")), + concat!( + "#EXTM3U\n", + "#EXT-X-VERSION:7\n", + "#EXT-X-TARGETDURATION:1\n", + "#EXT-X-PLAYLIST-TYPE:VOD\n", + "#EXT-X-MAP:URI=\"init.mp4\"\n", + "#EXTINF:1.000000,\n", + "0.mp4\n", + "#EXT-X-ENDLIST\n" + ), + "case={name}" + ); + + let init = probe_detailed_file(&output_dir.join("video").join("init.mp4")); + assert_eq!(init.tracks.len(), 1, "case={name}"); + assert_eq!(init.tracks[0].summary.track_id, 1, "case={name}"); + assert_eq!( + init.tracks[0].sample_entry_type, + Some(fourcc(codec)), + "case={name}" + ); + assert!(init.segments.is_empty(), "case={name}"); + assert_segment_matches( + &input_summary.segments[0], + &output_dir.join("video").join("0.mp4"), + ); + } +} + +#[test] +fn divide_command_writes_supported_broader_audio_family_outputs() { + let cases = [ + ("mp3", build_mp3_divide_input_file(), "mp4a"), + ("opus", build_opus_divide_input_file(), "Opus"), + ("ac3", build_ac3_divide_input_file(), "ac-3"), + ("ec3", build_ec3_divide_input_file(), "ec-3"), + ("ac4", build_ac4_divide_input_file(), "ac-4"), + ("alac", build_alac_divide_input_file(), "alac"), + ("dtsc", build_dtsc_divide_input_file(), "dtsc"), + ("dtse", build_dtse_divide_input_file(), "dtse"), + ("dtsh", build_dtsh_divide_input_file(), "dtsh"), + ("dtsl", build_dtsl_divide_input_file(), "dtsl"), + ("dtsm", build_dtsm_divide_input_file(), "dtsm"), + ("dtsx", build_dtsx_divide_input_file(), "dtsx"), + ("dtsy", build_dtsy_divide_input_file(), "dtsy"), + ("flac", build_flac_divide_input_file(), "fLaC"), + ("iamf", build_iamf_divide_input_file(), "iamf"), + ("mha1", build_mha1_divide_input_file(), "mha1"), + ("mhm1", build_mhm1_divide_input_file(), "mhm1"), + ("pcm", build_pcm_divide_input_file(), "ipcm"), + ]; + + for (name, input, codec) in cases { + let mut input_summary_reader = std::io::Cursor::new(input.clone()); + let input_summary = probe(&mut input_summary_reader).unwrap(); + let input_path = write_temp_file(&format!("divide-{name}-audio-input"), &input); + let output_dir = temp_output_dir(&format!("divide-{name}-audio-output")); + let args = vec![ + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!( + exit_code, + 0, + "case={name}: {}", + String::from_utf8_lossy(&stderr) + ); + assert_eq!(String::from_utf8(stderr).unwrap(), "", "case={name}"); + assert!(!output_dir.join("playlist.m3u8").exists(), "case={name}"); + assert_eq!( + read_text(&output_dir.join("audio").join("playlist.m3u8")), + concat!( + "#EXTM3U\n", + "#EXT-X-VERSION:7\n", + "#EXT-X-TARGETDURATION:1\n", + "#EXT-X-PLAYLIST-TYPE:VOD\n", + "#EXT-X-MAP:URI=\"init.mp4\"\n", + "#EXTINF:1.000000,\n", + "0.mp4\n", + "#EXT-X-ENDLIST\n" + ), + "case={name}" + ); + + let init = probe_detailed_file(&output_dir.join("audio").join("init.mp4")); + assert_eq!(init.tracks.len(), 1, "case={name}"); + assert_eq!(init.tracks[0].summary.track_id, 1, "case={name}"); + assert_eq!( + init.tracks[0].sample_entry_type, + Some(fourcc(codec)), + "case={name}" + ); + assert!(init.segments.is_empty(), "case={name}"); + assert_segment_matches( + &input_summary.segments[0], + &output_dir.join("audio").join("0.mp4"), + ); + } +} + +#[test] +fn divide_validate_rejects_duplicate_video_layouts_before_writing_output() { + let input = build_two_video_track_divide_input_file(); + let input_path = write_temp_file("divide-validate-duplicate-video-input", &input); + let args = vec![ + "--validate".to_string(), + input_path.to_string_lossy().into_owned(), + ]; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = divide::run_with_output(&args, &mut stdout, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!( + String::from_utf8(stderr).unwrap(), + format!( + "Error [stage=request category=input]: {DIVIDE_SCOPE_MESSAGE}; found multiple fragmented video tracks (1 and 2).\n" + ) + ); +} + +#[test] +fn divide_validate_rejects_subtitle_layout_with_clear_message() { + let input = build_stpp_divide_input_file(); + let input_path = write_temp_file("divide-validate-stpp-input", &input); + let args = vec![ + "-validate".to_string(), + input_path.to_string_lossy().into_owned(), + ]; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = divide::run_with_output(&args, &mut stdout, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!( + String::from_utf8(stderr).unwrap(), + format!( + "Error [stage=request category=input]: track 1 uses unsupported codec `stpp`; {DIVIDE_SCOPE_MESSAGE}\n" + ) + ); +} + +#[test] +fn divide_command_can_emit_warning_mode_for_audio_only_outputs() { + let input = build_opus_divide_input_file(); + let input_path = write_temp_file("divide-warning-audio-only-input", &input); + let output_dir = temp_output_dir("divide-warning-audio-only-output"); + let args = vec![ + "-warnings".to_string(), + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Warning: divide output is audio-only; no fragmented video track was selected\n" + ); +} + +#[test] +fn divide_command_warning_mode_reports_fragmented_decode_gap_and_duration_shift() { + let input = build_fragmented_input_file( + vec![build_video_trak_with_profile(1, 640, 360, 0x64, 0x00, 0x1f)], + vec![ + build_track_segment(1, 0, 1_000, 8), + build_track_segment(1, 3_000, 500, 8), + ], + ); + let input_path = write_temp_file("divide-warning-gap-input", &input); + let output_dir = temp_output_dir("divide-warning-gap-output"); + let args = vec![ + "-warnings".to_string(), + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!( + String::from_utf8(stderr).unwrap(), + concat!( + "Warning: track 1 changes segment duration 1 time(s)\n", + "Warning: track 1 fragmented segment duration spans 0.500000s to 1.000000s\n", + "Warning: track 1 changes authored fragmented sample duration 1 time(s)\n", + "Warning: track 1 authored fragmented sample duration spans 0.500000s (500 tick(s)) to 1.000000s (1000 tick(s))\n", + "Warning: track 1 changes average fragmented sample duration 1 time(s)\n", + "Warning: track 1 fragmented average sample duration spans 0.500000s to 1.000000s\n", + "Warning: track 1 has 1 fragmented decode-timeline gap(s)\n", + "Warning: track 1 has a largest fragmented decode-timeline gap of 2.000000s (2000 tick(s))\n", + ) + ); +} + +#[test] +fn divide_command_warning_mode_reports_fragmented_decode_regression_and_zero_duration_samples() { + let input = build_fragmented_input_file( + vec![build_video_trak_with_profile(1, 640, 360, 0x64, 0x00, 0x1f)], + vec![ + build_track_segment(1, 0, 1_000, 8), + build_track_segment(1, 500, 0, 8), + ], + ); + let input_path = write_temp_file("divide-warning-regression-input", &input); + let output_dir = temp_output_dir("divide-warning-regression-output"); + let args = vec![ + "-warnings".to_string(), + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!( + String::from_utf8(stderr).unwrap(), + concat!( + "Warning: track 1 has 1 zero-duration fragmented segment(s)\n", + "Warning: track 1 changes segment duration 1 time(s)\n", + "Warning: track 1 fragmented segment duration spans 0.000000s to 1.000000s\n", + "Warning: track 1 carries 1 sample(s) inside zero-duration fragmented segment(s)\n", + "Warning: track 1 carries 1 zero-duration fragmented sample(s)\n", + "Warning: track 1 changes average fragmented sample duration 1 time(s)\n", + "Warning: track 1 fragmented average sample duration spans 0.000000s to 1.000000s\n", + "Warning: track 1 has 1 fragmented decode-timeline regression(s)\n", + "Warning: track 1 has a largest fragmented decode-timeline regression of 0.500000s (500 tick(s))\n", + ) + ); +} + +#[test] +fn divide_command_warning_mode_reports_zero_duration_samples_inside_nonzero_duration_segment() { + let input = build_fragmented_input_file( + vec![build_video_trak_with_profile(1, 640, 360, 0x64, 0x00, 0x1f)], + vec![build_track_segment_with_explicit_sample_durations( + 1, + 0, + &[0, 1_000], + 8, + )], + ); + let input_path = write_temp_file("divide-warning-zero-duration-sample-input", &input); + let output_dir = temp_output_dir("divide-warning-zero-duration-sample-output"); + let args = vec![ + "-warnings".to_string(), + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Warning: track 1 carries 1 zero-duration fragmented sample(s)\n" + ); +} + +#[test] +fn divide_command_warning_mode_reports_authored_fragmented_sample_duration_jitter() { + let input = build_fragmented_input_file( + vec![build_video_trak_with_profile(1, 640, 360, 0x64, 0x00, 0x1f)], + vec![build_track_segment_with_explicit_sample_durations( + 1, + 0, + &[500, 1_000], + 8, + )], + ); + let input_path = write_temp_file("divide-warning-sample-jitter-input", &input); + let output_dir = temp_output_dir("divide-warning-sample-jitter-output"); + let args = vec![ + "-warnings".to_string(), + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!( + String::from_utf8(stderr).unwrap(), + concat!( + "Warning: track 1 changes authored fragmented sample duration 1 time(s)\n", + "Warning: track 1 authored fragmented sample duration spans 0.500000s (500 tick(s)) to 1.000000s (1000 tick(s))\n", + ) + ); +} + +#[test] +fn validate_divide_reader_reports_supported_tracks() { + let input = build_video_and_audio_divide_input_file(); + let report = divide::validate_divide_reader(&mut std::io::Cursor::new(input)).unwrap(); + + assert_eq!(report.tracks.len(), 2); + assert_eq!(report.tracks[0].track_id, 1); + assert_eq!(report.tracks[0].role, divide::DivideTrackRole::Video); + assert_eq!(report.tracks[0].sample_entry_type, Some(fourcc("avc1"))); + assert_eq!(report.tracks[0].segment_count, 1); + assert_eq!(report.tracks[1].track_id, 2); + assert_eq!(report.tracks[1].role, divide::DivideTrackRole::Audio); + assert_eq!(report.tracks[1].sample_entry_type, Some(fourcc("mp4a"))); + assert_eq!(report.tracks[1].segment_count, 1); +} + +fn build_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_video_trak_with_profile( + 1, 1_920, 1_080, 0x64, 0x00, 0x1f, + )], + vec![ + build_track_segment(1, 0, 1_000, 8), + build_track_segment(1, 1_000, 1_000, 8), ], ) } @@ -368,23 +1560,277 @@ fn build_video_and_audio_divide_input_file() -> Vec { ) } -fn build_two_video_track_divide_input_file() -> Vec { +fn build_two_video_track_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![ + build_video_trak_with_profile(1, 640, 360, 0x64, 0x00, 0x1f), + build_video_trak_with_profile(2, 320, 180, 0x42, 0x00, 0x1e), + ], + vec![ + build_track_segment(1, 0, 1_000, 8), + build_track_segment(2, 0, 1_000, 8), + ], + ) +} + +fn build_hevc_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_hevc_trak(1, 640, 360)], + vec![build_track_segment(1, 0, 1_000, 8)], + ) +} + +fn build_av1_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_av1_trak(1, 640, 360)], + vec![build_track_segment(1, 0, 1_000, 8)], + ) +} + +fn build_vp8_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_vp8_trak(1, 640, 360)], + vec![build_track_segment(1, 0, 1_000, 8)], + ) +} + +fn build_vp9_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_vp9_trak(1, 640, 360)], + vec![build_track_segment(1, 0, 1_000, 8)], + ) +} + +fn build_hevc_and_opus_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_hevc_trak(1, 640, 360), build_opus_trak(2, 2)], + vec![ + build_track_segment(1, 0, 1_000, 8), + build_track_segment(2, 0, 1_000, 6), + ], + ) +} + +fn build_avc_and_mp3_divide_input_file() -> Vec { + build_video_and_custom_audio_divide_input_file(build_mp3_trak(2, 2)) +} + +fn build_avc_and_ec3_divide_input_file() -> Vec { + build_video_and_custom_audio_divide_input_file(build_ec3_trak(2, 6)) +} + +fn build_avc_and_ac4_divide_input_file() -> Vec { + build_video_and_custom_audio_divide_input_file(build_ac4_trak(2, 2)) +} + +fn build_avc_and_alac_divide_input_file() -> Vec { + build_video_and_custom_audio_divide_input_file(build_alac_trak(2, 2)) +} + +fn build_avc_and_dtsc_divide_input_file() -> Vec { + build_video_and_custom_audio_divide_input_file(build_dtsc_trak(2, 6)) +} + +fn build_avc_and_flac_divide_input_file() -> Vec { + build_video_and_custom_audio_divide_input_file(build_flac_trak(2, 2)) +} + +fn build_avc_and_iamf_divide_input_file() -> Vec { + build_video_and_custom_audio_divide_input_file(build_iamf_trak(2, 2)) +} + +fn build_avc_and_mha1_divide_input_file() -> Vec { + build_video_and_custom_audio_divide_input_file(build_mha1_trak(2, 2)) +} + +fn build_avc_and_mhm1_divide_input_file() -> Vec { + build_video_and_custom_audio_divide_input_file(build_mhm1_trak(2, 2)) +} + +fn build_video_and_custom_audio_divide_input_file(audio_trak: Vec) -> Vec { + build_video_and_custom_audio_tracks_divide_input_file(vec![audio_trak]) +} + +fn build_avc_with_aac_and_ac3_divide_input_file() -> Vec { + build_video_and_custom_audio_tracks_divide_input_file(vec![ + build_audio_trak(2, 2, 0x40, &[0x11, 0x90]), + build_ac3_trak(3, 6), + ]) +} + +fn build_avc_with_aac_and_ac3_divide_input_file_with_languages( + aac_language: [u8; 3], + ac3_language: [u8; 3], +) -> Vec { + build_video_and_custom_audio_tracks_divide_input_file(vec![ + build_audio_trak_with_language(2, 2, 0x40, &[0x11, 0x90], aac_language), + build_ac3_trak_with_language(3, 6, ac3_language), + ]) +} + +fn build_video_and_custom_audio_tracks_divide_input_file(audio_traks: Vec>) -> Vec { + let mut traks = vec![build_video_trak_with_profile(1, 640, 360, 0x4d, 0x40, 0x1f)]; + traks.extend(audio_traks); + + let mut segments = vec![build_track_segment(1, 0, 1_000, 8)]; + for track_id in 2..=traks.len() as u32 { + segments.push(build_track_segment(track_id, 0, 1_000, 6)); + } + + build_fragmented_input_file(traks, segments) +} + +fn build_opus_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_opus_trak(1, 2)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_mp3_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_mp3_trak(1, 2)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_ac3_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_ac3_trak(1, 6)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_ac4_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_ac4_trak(1, 2)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_alac_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_alac_trak(1, 2)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_pcm_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_pcm_trak(1, 2)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_ec3_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_ec3_trak(1, 6)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_encrypted_hevc_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_encrypted_hevc_trak(1, 640, 360)], + vec![build_track_segment(1, 0, 1_000, 8)], + ) +} + +fn build_dvh1_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_dvh1_trak(1, 640, 360)], + vec![build_track_segment(1, 0, 1_000, 8)], + ) +} + +fn build_dvhe_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_dvhe_trak(1, 640, 360)], + vec![build_track_segment(1, 0, 1_000, 8)], + ) +} + +fn build_dtsc_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_dtsc_trak(1, 6)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_dtse_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_dtse_trak(1, 6)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_dtsh_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_dtsh_trak(1, 6)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_dtsl_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_dtsl_trak(1, 6)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_dtsm_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_dtsm_trak(1, 6)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_dtsx_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_dtsx_trak(1, 6)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_dtsy_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_dtsy_trak(1, 6)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_flac_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_flac_trak(1, 2)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_iamf_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_iamf_trak(1, 2)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_mha1_divide_input_file() -> Vec { build_fragmented_input_file( - vec![ - build_video_trak_with_profile(1, 640, 360, 0x64, 0x00, 0x1f), - build_video_trak_with_profile(2, 320, 180, 0x42, 0x00, 0x1e), - ], - vec![ - build_track_segment(1, 0, 1_000, 8), - build_track_segment(2, 0, 1_000, 8), - ], + vec![build_mha1_trak(1, 2)], + vec![build_track_segment(1, 0, 1_000, 6)], ) } -fn build_hevc_divide_input_file() -> Vec { +fn build_mhm1_divide_input_file() -> Vec { build_fragmented_input_file( - vec![build_hevc_trak(1, 640, 360)], - vec![build_track_segment(1, 0, 1_000, 8)], + vec![build_mhm1_trak(1, 2)], + vec![build_track_segment(1, 0, 1_000, 6)], + ) +} + +fn build_stpp_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_stpp_trak(1)], + vec![build_track_segment(1, 0, 1_000, 5)], ) } @@ -399,56 +1845,450 @@ fn build_fragmented_input_file(traks: Vec>, segments: Vec>) -> V ); let moov = encode_raw_box(fourcc("moov"), &traks.concat()); - let mut file = [ftyp, moov].concat(); - for segment in segments { - file.extend_from_slice(&segment); - } - file + let mut file = [ftyp, moov].concat(); + for segment in segments { + file.extend_from_slice(&segment); + } + file +} + +fn build_video_trak_with_profile( + track_id: u32, + width: u16, + height: u16, + profile: u8, + profile_compatibility: u8, + level: u8, +) -> Vec { + let mut tkhd = Tkhd::default(); + tkhd.track_id = track_id; + tkhd.width = u32::from(width) << 16; + tkhd.height = u32::from(height) << 16; + + let mut mdhd = Mdhd::default(); + mdhd.timescale = 1_000; + mdhd.duration_v0 = 1_000; + + let high_profile_fields_enabled = matches!( + profile, + 44 | 83 | 86 | 100 | 110 | 118 | 122 | 128 | 134 | 135 | 138 | 139 | 144 | 244 + ); + let avcc = encode_supported_box( + &AVCDecoderConfiguration { + configuration_version: 1, + profile, + profile_compatibility, + level, + length_size_minus_one: 3, + num_of_sequence_parameter_sets: 1, + sequence_parameter_sets: vec![AVCParameterSet { + length: 24, + nal_unit: vec![ + 0x67, 0x64, 0x00, 0x0d, 0xac, 0x34, 0xe5, 0x05, 0x06, 0x7e, 0x78, 0x40, 0x00, + 0x00, 0x19, 0x00, 0x00, 0x05, 0xda, 0xa3, 0xc5, 0x0a, 0x45, 0x80, + ], + }], + num_of_picture_parameter_sets: 1, + picture_parameter_sets: vec![AVCParameterSet { + length: 5, + nal_unit: vec![0x68, 0xee, 0xb2, 0xc8, 0xb0], + }], + high_profile_fields_enabled, + chroma_format: if high_profile_fields_enabled { 1 } else { 0 }, + bit_depth_luma_minus8: 0, + bit_depth_chroma_minus8: 0, + num_of_sequence_parameter_set_ext: 0, + sequence_parameter_sets_ext: Vec::new(), + }, + &[], + ); + + let mut avc1 = VisualSampleEntry::default(); + avc1.set_box_type(fourcc("avc1")); + avc1.sample_entry.data_reference_index = 1; + avc1.width = width; + avc1.height = height; + avc1.horizresolution = 0x0048_0000; + avc1.vertresolution = 0x0048_0000; + avc1.frame_count = 1; + avc1.depth = 0x0018; + avc1.pre_defined3 = -1; + + let mut stsd = Stsd::default(); + stsd.entry_count = 1; + let stsd = encode_supported_box(&stsd, &encode_supported_box(&avc1, &avcc)); + + let mut stts = Stts::default(); + stts.entry_count = 1; + stts.entries = vec![SttsEntry { + sample_count: 1, + sample_delta: 1_000, + }]; + let stts = encode_supported_box(&stts, &[]); + + let mut stsc = Stsc::default(); + stsc.entry_count = 1; + stsc.entries = vec![StscEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + let stsc = encode_supported_box(&stsc, &[]); + + let mut stsz = Stsz::default(); + stsz.sample_size = 8; + stsz.sample_count = 1; + let stsz = encode_supported_box(&stsz, &[]); + + let mut stco = Stco::default(); + stco.entry_count = 0; + let stco = encode_supported_box(&stco, &[]); + + let stbl = encode_raw_box(fourcc("stbl"), &[stsd, stts, stsc, stsz, stco].concat()); + let minf = encode_raw_box(fourcc("minf"), &stbl); + let mdia = encode_raw_box( + fourcc("mdia"), + &[encode_supported_box(&mdhd, &[]), minf].concat(), + ); + encode_raw_box( + fourcc("trak"), + &[encode_supported_box(&tkhd, &[]), mdia].concat(), + ) +} + +fn build_audio_trak( + track_id: u32, + channel_count: u16, + object_type_indication: u8, + decoder_specific_info: &[u8], +) -> Vec { + build_audio_trak_with_language( + track_id, + channel_count, + object_type_indication, + decoder_specific_info, + *b"und", + ) +} + +fn build_audio_trak_with_language( + track_id: u32, + channel_count: u16, + object_type_indication: u8, + decoder_specific_info: &[u8], + language: [u8; 3], +) -> Vec { + let mut tkhd = Tkhd::default(); + tkhd.track_id = track_id; + + let mut mdhd = Mdhd::default(); + mdhd.timescale = 1_000; + mdhd.duration_v0 = 1_000; + mdhd.language = encode_mdhd_language(language); + + let mut mp4a = AudioSampleEntry::default(); + mp4a.set_box_type(fourcc("mp4a")); + mp4a.sample_entry = SampleEntry { + box_type: fourcc("mp4a"), + data_reference_index: 1, + }; + mp4a.channel_count = channel_count; + mp4a.sample_size = 16; + mp4a.sample_rate = 48_000_u32 << 16; + + let mut stsd = Stsd::default(); + stsd.entry_count = 1; + let mp4a = encode_supported_box( + &mp4a, + &encode_supported_box( + &aac_profile_esds(object_type_indication, decoder_specific_info), + &[], + ), + ); + let stsd = encode_supported_box(&stsd, &mp4a); + + let mut stts = Stts::default(); + stts.entry_count = 1; + stts.entries = vec![SttsEntry { + sample_count: 1, + sample_delta: 1_000, + }]; + let stts = encode_supported_box(&stts, &[]); + + let mut stsc = Stsc::default(); + stsc.entry_count = 1; + stsc.entries = vec![StscEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + let stsc = encode_supported_box(&stsc, &[]); + + let mut stsz = Stsz::default(); + stsz.sample_size = 6; + stsz.sample_count = 1; + let stsz = encode_supported_box(&stsz, &[]); + + let mut stco = Stco::default(); + stco.entry_count = 0; + let stco = encode_supported_box(&stco, &[]); + + let stbl = encode_raw_box(fourcc("stbl"), &[stsd, stts, stsc, stsz, stco].concat()); + let minf = encode_raw_box(fourcc("minf"), &stbl); + let mdia = encode_raw_box( + fourcc("mdia"), + &[encode_supported_box(&mdhd, &[]), minf].concat(), + ); + encode_raw_box( + fourcc("trak"), + &[encode_supported_box(&tkhd, &[]), mdia].concat(), + ) +} + +fn build_hevc_trak(track_id: u32, width: u16, height: u16) -> Vec { + build_video_trak_with_type_and_children( + track_id, + width, + height, + "hvc1", + &encode_supported_box( + &HEVCDecoderConfiguration { + configuration_version: 1, + general_profile_idc: 1, + length_size_minus_one: 3, + ..HEVCDecoderConfiguration::default() + }, + &[], + ), + ) +} + +fn build_av1_trak(track_id: u32, width: u16, height: u16) -> Vec { + build_video_trak_with_type_and_children( + track_id, + width, + height, + "av01", + &encode_supported_box(&av1_config(), &[]), + ) +} + +fn build_vp8_trak(track_id: u32, width: u16, height: u16) -> Vec { + build_video_trak_with_type_and_children( + track_id, + width, + height, + "vp08", + &encode_supported_box(&vp8_config(), &[]), + ) +} + +fn build_vp9_trak(track_id: u32, width: u16, height: u16) -> Vec { + build_video_trak_with_type_and_children( + track_id, + width, + height, + "vp09", + &encode_supported_box(&vp9_config(), &[]), + ) +} + +fn build_encrypted_hevc_trak(track_id: u32, width: u16, height: u16) -> Vec { + let mut schm = Schm::default(); + schm.set_version(0); + schm.scheme_type = fourcc("cenc"); + schm.scheme_version = 0x0001_0000; + let sinf = encode_supported_box( + &Sinf, + &[ + encode_supported_box( + &Frma { + data_format: fourcc("hvc1"), + }, + &[], + ), + encode_supported_box(&schm, &[]), + ] + .concat(), + ); + build_video_trak_with_type_and_children( + track_id, + width, + height, + "encv", + &[ + encode_supported_box( + &HEVCDecoderConfiguration { + configuration_version: 1, + general_profile_idc: 1, + length_size_minus_one: 3, + ..HEVCDecoderConfiguration::default() + }, + &[], + ), + sinf, + ] + .concat(), + ) +} + +fn build_dvh1_trak(track_id: u32, width: u16, height: u16) -> Vec { + build_video_trak_with_type_and_children( + track_id, + width, + height, + "dvh1", + &encode_supported_box( + &HEVCDecoderConfiguration { + configuration_version: 1, + general_profile_idc: 1, + length_size_minus_one: 3, + ..HEVCDecoderConfiguration::default() + }, + &[], + ), + ) +} + +fn build_dvhe_trak(track_id: u32, width: u16, height: u16) -> Vec { + build_video_trak_with_type_and_children( + track_id, + width, + height, + "dvhe", + &encode_supported_box( + &HEVCDecoderConfiguration { + configuration_version: 1, + general_profile_idc: 1, + length_size_minus_one: 3, + ..HEVCDecoderConfiguration::default() + }, + &[], + ), + ) +} + +fn build_opus_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children( + track_id, + "Opus", + channel_count, + 48_000, + 6, + &encode_supported_box(&opus_config(), &[]), + ) +} + +fn build_mp3_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak(track_id, channel_count, 0x6b, &[]) +} + +fn build_ac3_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children( + track_id, + "ac-3", + channel_count, + 48_000, + 6, + &encode_supported_box(&ac3_config(), &[]), + ) +} + +fn build_ac3_trak_with_language(track_id: u32, channel_count: u16, language: [u8; 3]) -> Vec { + build_audio_trak_with_type_and_children_and_language( + track_id, + "ac-3", + channel_count, + 48_000, + 6, + &encode_supported_box(&ac3_config(), &[]), + language, + ) +} + +fn build_ac4_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children(track_id, "ac-4", channel_count, 48_000, 6, &[]) +} + +fn build_alac_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children(track_id, "alac", channel_count, 48_000, 6, &[]) +} + +fn build_pcm_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children( + track_id, + "ipcm", + channel_count, + 48_000, + 6, + &encode_supported_box(&pcm_config(), &[]), + ) +} + +fn build_ec3_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children(track_id, "ec-3", channel_count, 48_000, 6, &[]) +} + +fn build_dtsc_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children(track_id, "dtsc", channel_count, 48_000, 6, &[]) +} + +fn build_dtse_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children(track_id, "dtse", channel_count, 48_000, 6, &[]) +} + +fn build_dtsh_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children(track_id, "dtsh", channel_count, 48_000, 6, &[]) +} + +fn build_dtsl_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children(track_id, "dtsl", channel_count, 48_000, 6, &[]) +} + +fn build_dtsm_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children(track_id, "dtsm", channel_count, 48_000, 6, &[]) +} + +fn build_dtsx_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children(track_id, "dtsx", channel_count, 48_000, 6, &[]) } -fn build_video_trak_with_profile( - track_id: u32, - width: u16, - height: u16, - profile: u8, - profile_compatibility: u8, - level: u8, -) -> Vec { +fn build_dtsy_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children(track_id, "dtsy", channel_count, 48_000, 6, &[]) +} + +fn build_flac_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children(track_id, "fLaC", channel_count, 48_000, 6, &[]) +} + +fn build_iamf_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children(track_id, "iamf", channel_count, 48_000, 6, &[]) +} + +fn build_mha1_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children(track_id, "mha1", channel_count, 48_000, 6, &[]) +} + +fn build_mhm1_trak(track_id: u32, channel_count: u16) -> Vec { + build_audio_trak_with_type_and_children(track_id, "mhm1", channel_count, 48_000, 6, &[]) +} + +fn build_stpp_trak(track_id: u32) -> Vec { let mut tkhd = Tkhd::default(); tkhd.track_id = track_id; - tkhd.width = u32::from(width) << 16; - tkhd.height = u32::from(height) << 16; let mut mdhd = Mdhd::default(); mdhd.timescale = 1_000; mdhd.duration_v0 = 1_000; - let avcc = encode_supported_box( - &AVCDecoderConfiguration { - configuration_version: 1, - profile, - profile_compatibility, - level, - length_size_minus_one: 3, - ..AVCDecoderConfiguration::default() - }, - &[], - ); - - let mut avc1 = VisualSampleEntry::default(); - avc1.set_box_type(fourcc("avc1")); - avc1.sample_entry.data_reference_index = 1; - avc1.width = width; - avc1.height = height; - avc1.horizresolution = 0x0048_0000; - avc1.vertresolution = 0x0048_0000; - avc1.frame_count = 1; - avc1.depth = 0x0018; - avc1.pre_defined3 = -1; + let mut stpp = XMLSubtitleSampleEntry::default(); + stpp.sample_entry.data_reference_index = 1; + stpp.namespace = "urn:ttml".to_string(); + stpp.auxiliary_mime_types = "application/ttml+xml".to_string(); let mut stsd = Stsd::default(); stsd.entry_count = 1; - let stsd = encode_supported_box(&stsd, &encode_supported_box(&avc1, &avcc)); + let stsd = encode_supported_box(&stsd, &encode_supported_box(&stpp, &[])); let mut stts = Stts::default(); stts.entry_count = 1; @@ -468,7 +2308,7 @@ fn build_video_trak_with_profile( let stsc = encode_supported_box(&stsc, &[]); let mut stsz = Stsz::default(); - stsz.sample_size = 8; + stsz.sample_size = 5; stsz.sample_count = 1; let stsz = encode_supported_box(&stsz, &[]); @@ -488,39 +2328,136 @@ fn build_video_trak_with_profile( ) } -fn build_audio_trak( +fn build_track_segment( track_id: u32, - channel_count: u16, - object_type_indication: u8, - decoder_specific_info: &[u8], + base_media_decode_time: u32, + sample_duration: u32, + sample_size: u32, +) -> Vec { + let mut tfhd = Tfhd::default(); + tfhd.track_id = track_id; + tfhd.default_sample_duration = sample_duration; + tfhd.default_sample_size = sample_size; + tfhd.set_flags(TFHD_DEFAULT_SAMPLE_DURATION_PRESENT | TFHD_DEFAULT_SAMPLE_SIZE_PRESENT); + + let mut tfdt = Tfdt::default(); + tfdt.base_media_decode_time_v0 = base_media_decode_time; + + let mut trun = Trun::default(); + trun.sample_count = 1; + let trun = encode_supported_box(&trun, &[]); + let traf = encode_raw_box( + fourcc("traf"), + &[ + encode_supported_box(&tfhd, &[]), + encode_supported_box(&tfdt, &[]), + trun, + ] + .concat(), + ); + let moof = encode_raw_box(fourcc("moof"), &traf); + let mdat = encode_raw_box(fourcc("mdat"), &vec![0_u8; sample_size as usize]); + [moof, mdat].concat() +} + +fn build_track_segment_with_explicit_sample_durations( + track_id: u32, + base_media_decode_time: u32, + sample_durations: &[u32], + sample_size: u32, +) -> Vec { + let mut tfhd = Tfhd::default(); + tfhd.track_id = track_id; + tfhd.default_sample_size = sample_size; + tfhd.set_flags(TFHD_DEFAULT_SAMPLE_SIZE_PRESENT); + + let mut tfdt = Tfdt::default(); + tfdt.base_media_decode_time_v0 = base_media_decode_time; + + let mut trun = Trun::default(); + trun.sample_count = sample_durations.len() as u32; + trun.entries = sample_durations + .iter() + .copied() + .map(|sample_duration| TrunEntry { + sample_duration, + sample_size, + ..TrunEntry::default() + }) + .collect(); + trun.set_flags(TRUN_SAMPLE_DURATION_PRESENT | TRUN_SAMPLE_SIZE_PRESENT); + + let trun = encode_supported_box(&trun, &[]); + let traf = encode_raw_box( + fourcc("traf"), + &[ + encode_supported_box(&tfhd, &[]), + encode_supported_box(&tfdt, &[]), + trun, + ] + .concat(), + ); + let moof = encode_raw_box(fourcc("moof"), &traf); + let mdat = encode_raw_box( + fourcc("mdat"), + &vec![0_u8; sample_size as usize * sample_durations.len()], + ); + [moof, mdat].concat() +} + +fn aac_profile_esds(object_type_indication: u8, decoder_specific_info: &[u8]) -> Esds { + let mut esds = Esds::default(); + esds.descriptors = vec![ + Descriptor { + tag: DECODER_CONFIG_DESCRIPTOR_TAG, + size: 13, + decoder_config_descriptor: Some(DecoderConfigDescriptor { + object_type_indication, + stream_type: 5, + reserved: true, + ..DecoderConfigDescriptor::default() + }), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_SPECIFIC_INFO_TAG, + size: decoder_specific_info.len() as u32, + data: decoder_specific_info.to_vec(), + ..Descriptor::default() + }, + ]; + esds +} + +fn encode_mdhd_language(language: [u8; 3]) -> [u8; 3] { + [language[0] - b'`', language[1] - b'`', language[2] - b'`'] +} + +fn build_video_trak_with_type_and_children( + track_id: u32, + width: u16, + height: u16, + sample_entry_type: &str, + sample_entry_children: &[u8], ) -> Vec { let mut tkhd = Tkhd::default(); tkhd.track_id = track_id; + tkhd.width = u32::from(width) << 16; + tkhd.height = u32::from(height) << 16; let mut mdhd = Mdhd::default(); mdhd.timescale = 1_000; mdhd.duration_v0 = 1_000; - let mut mp4a = AudioSampleEntry::default(); - mp4a.set_box_type(fourcc("mp4a")); - mp4a.sample_entry = SampleEntry { - box_type: fourcc("mp4a"), - data_reference_index: 1, - }; - mp4a.channel_count = channel_count; - mp4a.sample_size = 16; - mp4a.sample_rate = 48_000_u32 << 16; - let mut stsd = Stsd::default(); stsd.entry_count = 1; - let mp4a = encode_supported_box( - &mp4a, + let stsd = encode_supported_box( + &stsd, &encode_supported_box( - &aac_profile_esds(object_type_indication, decoder_specific_info), - &[], + &video_sample_entry_with_type(sample_entry_type, width, height), + sample_entry_children, ), ); - let stsd = encode_supported_box(&stsd, &mp4a); let mut stts = Stts::default(); stts.entry_count = 1; @@ -540,7 +2477,7 @@ fn build_audio_trak( let stsc = encode_supported_box(&stsc, &[]); let mut stsz = Stsz::default(); - stsz.sample_size = 6; + stsz.sample_size = 8; stsz.sample_count = 1; let stsz = encode_supported_box(&stsz, &[]); @@ -560,40 +2497,51 @@ fn build_audio_trak( ) } -fn build_hevc_trak(track_id: u32, width: u16, height: u16) -> Vec { +fn build_audio_trak_with_type_and_children( + track_id: u32, + sample_entry_type: &str, + channel_count: u16, + sample_rate: u16, + sample_size: u32, + sample_entry_children: &[u8], +) -> Vec { + build_audio_trak_with_type_and_children_and_language( + track_id, + sample_entry_type, + channel_count, + sample_rate, + sample_size, + sample_entry_children, + *b"und", + ) +} + +fn build_audio_trak_with_type_and_children_and_language( + track_id: u32, + sample_entry_type: &str, + channel_count: u16, + sample_rate: u16, + sample_size: u32, + sample_entry_children: &[u8], + language: [u8; 3], +) -> Vec { let mut tkhd = Tkhd::default(); tkhd.track_id = track_id; - tkhd.width = u32::from(width) << 16; - tkhd.height = u32::from(height) << 16; let mut mdhd = Mdhd::default(); mdhd.timescale = 1_000; mdhd.duration_v0 = 1_000; - - let hvcc = encode_supported_box( - &HEVCDecoderConfiguration { - configuration_version: 1, - general_profile_idc: 1, - length_size_minus_one: 3, - ..HEVCDecoderConfiguration::default() - }, - &[], - ); - - let mut hvc1 = VisualSampleEntry::default(); - hvc1.set_box_type(fourcc("hvc1")); - hvc1.sample_entry.data_reference_index = 1; - hvc1.width = width; - hvc1.height = height; - hvc1.horizresolution = 0x0048_0000; - hvc1.vertresolution = 0x0048_0000; - hvc1.frame_count = 1; - hvc1.depth = 0x0018; - hvc1.pre_defined3 = -1; + mdhd.language = encode_mdhd_language(language); let mut stsd = Stsd::default(); stsd.entry_count = 1; - let stsd = encode_supported_box(&stsd, &encode_supported_box(&hvc1, &hvcc)); + let stsd = encode_supported_box( + &stsd, + &encode_supported_box( + &audio_sample_entry_with_type(sample_entry_type, channel_count, sample_rate), + sample_entry_children, + ), + ); let mut stts = Stts::default(); stts.entry_count = 1; @@ -613,7 +2561,7 @@ fn build_hevc_trak(track_id: u32, width: u16, height: u16) -> Vec { let stsc = encode_supported_box(&stsc, &[]); let mut stsz = Stsz::default(); - stsz.sample_size = 8; + stsz.sample_size = sample_size; stsz.sample_count = 1; let stsz = encode_supported_box(&stsz, &[]); @@ -633,60 +2581,116 @@ fn build_hevc_trak(track_id: u32, width: u16, height: u16) -> Vec { ) } -fn build_track_segment( - track_id: u32, - base_media_decode_time: u32, - sample_duration: u32, - sample_size: u32, -) -> Vec { - let mut tfhd = Tfhd::default(); - tfhd.track_id = track_id; - tfhd.default_sample_duration = sample_duration; - tfhd.default_sample_size = sample_size; - tfhd.set_flags(TFHD_DEFAULT_SAMPLE_DURATION_PRESENT | TFHD_DEFAULT_SAMPLE_SIZE_PRESENT); +fn video_sample_entry_with_type( + sample_entry_type: &str, + width: u16, + height: u16, +) -> VisualSampleEntry { + let mut sample_entry = VisualSampleEntry::default(); + sample_entry.set_box_type(fourcc(sample_entry_type)); + sample_entry.sample_entry.data_reference_index = 1; + sample_entry.width = width; + sample_entry.height = height; + sample_entry.horizresolution = 0x0048_0000; + sample_entry.vertresolution = 0x0048_0000; + sample_entry.frame_count = 1; + sample_entry.depth = 0x0018; + sample_entry.pre_defined3 = -1; + sample_entry +} - let mut tfdt = Tfdt::default(); - tfdt.base_media_decode_time_v0 = base_media_decode_time; +fn audio_sample_entry_with_type( + sample_entry_type: &str, + channel_count: u16, + sample_rate: u16, +) -> AudioSampleEntry { + let mut sample_entry = AudioSampleEntry::default(); + sample_entry.set_box_type(fourcc(sample_entry_type)); + sample_entry.sample_entry = SampleEntry { + box_type: fourcc(sample_entry_type), + data_reference_index: 1, + }; + sample_entry.channel_count = channel_count; + sample_entry.sample_size = 16; + sample_entry.sample_rate = u32::from(sample_rate) << 16; + sample_entry +} - let mut trun = Trun::default(); - trun.sample_count = 1; - let trun = encode_supported_box(&trun, &[]); - let traf = encode_raw_box( - fourcc("traf"), - &[ - encode_supported_box(&tfhd, &[]), - encode_supported_box(&tfdt, &[]), - trun, - ] - .concat(), - ); - let moof = encode_raw_box(fourcc("moof"), &traf); - let mdat = encode_raw_box(fourcc("mdat"), &vec![0_u8; sample_size as usize]); - [moof, mdat].concat() +fn av1_config() -> AV1CodecConfiguration { + AV1CodecConfiguration { + seq_profile: 0, + seq_level_idx_0: 13, + seq_tier_0: 1, + high_bitdepth: 1, + twelve_bit: 0, + monochrome: 0, + chroma_subsampling_x: 1, + chroma_subsampling_y: 0, + chroma_sample_position: 2, + initial_presentation_delay_present: 1, + initial_presentation_delay_minus_one: 3, + config_obus: vec![0x12, 0x34, 0x56], + } } -fn aac_profile_esds(object_type_indication: u8, decoder_specific_info: &[u8]) -> Esds { - let mut esds = Esds::default(); - esds.descriptors = vec![ - Descriptor { - tag: DECODER_CONFIG_DESCRIPTOR_TAG, - size: 13, - decoder_config_descriptor: Some(DecoderConfigDescriptor { - object_type_indication, - stream_type: 5, - reserved: true, - ..DecoderConfigDescriptor::default() - }), - ..Descriptor::default() - }, - Descriptor { - tag: DECODER_SPECIFIC_INFO_TAG, - size: decoder_specific_info.len() as u32, - data: decoder_specific_info.to_vec(), - ..Descriptor::default() - }, - ]; - esds +fn vp8_config() -> VpCodecConfiguration { + let mut config = VpCodecConfiguration::default(); + config.profile = 0; + config.level = 10; + config.bit_depth = 8; + config.chroma_subsampling = 1; + config.video_full_range_flag = 0; + config.colour_primaries = 1; + config.transfer_characteristics = 1; + config.matrix_coefficients = 1; + config +} + +fn vp9_config() -> VpCodecConfiguration { + let mut config = VpCodecConfiguration::default(); + config.profile = 2; + config.level = 31; + config.bit_depth = 10; + config.chroma_subsampling = 1; + config.video_full_range_flag = 1; + config.colour_primaries = 9; + config.transfer_characteristics = 16; + config.matrix_coefficients = 9; + config.codec_initialization_data_size = 3; + config.codec_initialization_data = vec![0x01, 0x02, 0x03]; + config +} + +fn opus_config() -> DOps { + DOps { + version: 0, + output_channel_count: 2, + pre_skip: 312, + input_sample_rate: 48_000, + output_gain: 0, + channel_mapping_family: 1, + stream_count: 2, + coupled_count: 1, + channel_mapping: vec![0, 1], + } +} + +fn ac3_config() -> Dac3 { + Dac3 { + fscod: 1, + bsid: 8, + bsmod: 3, + acmod: 7, + lfe_on: 1, + bit_rate_code: 10, + } +} + +fn pcm_config() -> PcmC { + let mut config = PcmC::default(); + config.format_flags = 1; + config.pcm_sample_size = 24; + config } fn sorted_file_names(path: &Path) -> Vec { @@ -703,6 +2707,11 @@ fn probe_file(path: &Path) -> mp4forge::probe::ProbeInfo { probe(&mut file).unwrap() } +fn probe_detailed_file(path: &Path) -> mp4forge::probe::DetailedProbeInfo { + let mut file = fs::File::open(path).unwrap(); + probe_detailed(&mut file).unwrap() +} + fn assert_segment_matches(expected: &mp4forge::probe::SegmentInfo, path: &Path) { let summary = probe_file(path); assert!( diff --git a/tests/cli_dump.rs b/tests/cli_dump.rs index 64bf52e..0c5e3aa 100644 --- a/tests/cli_dump.rs +++ b/tests/cli_dump.rs @@ -282,8 +282,6 @@ fn dump_command_renders_supported_and_unsupported_boxes() { let mut stderr = Vec::new(); let exit_code = dump::run(&args, &mut stdout, &mut stderr); - let _ = fs::remove_file(&path); - assert_eq!(exit_code, 0); assert_eq!(String::from_utf8(stderr).unwrap(), ""); assert_eq!( @@ -338,6 +336,51 @@ fn dump_command_matches_shared_fixture_goldens() { } } +#[test] +fn dump_command_usage_lists_only_supported_options() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + let exit_code = dump::run(&[], &mut stdout, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!( + String::from_utf8(stderr).unwrap(), + concat!( + "USAGE: mp4forge dump [OPTIONS] INPUT.mp4\n", + "\n", + "OPTIONS:\n", + " -full Show full content for the listed box types\n", + " -a Show full content for supported boxes\n", + " -format Output format (default: text)\n", + " -path Dump only matched parsed subtrees (repeatable)\n", + " -offset Show box offsets\n", + " -hex Use hexadecimal size and offset values\n", + ) + ); +} + +#[test] +fn dump_command_rejects_removed_deprecated_shorthand_options() { + let fixture = fixture_path("sample.mp4"); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + let exit_code = dump::run( + &["-mdat".to_string(), fixture.to_string_lossy().into_owned()], + &mut stdout, + &mut stderr, + ); + + assert_eq!(exit_code, 1); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error: unknown dump option: -mdat\n" + ); +} + #[test] fn structured_dump_report_respects_full_payload_controls() { let mut default_reader = Cursor::new(build_dump_input_file()); @@ -495,8 +538,6 @@ fn dump_command_scopes_text_output_to_selected_subtrees() { let mut stderr = Vec::new(); let exit_code = dump::run(&args, &mut stdout, &mut stderr); - let _ = fs::remove_file(&path); - assert_eq!(exit_code, 0); assert_eq!(String::from_utf8(stderr).unwrap(), ""); assert_eq!( @@ -523,8 +564,6 @@ fn dump_command_scopes_structured_output_to_selected_subtrees() { let mut stderr = Vec::new(); let exit_code = dump::run(&args, &mut stdout, &mut stderr); - let _ = fs::remove_file(&path); - assert_eq!(exit_code, 0); assert_eq!(String::from_utf8(stderr).unwrap(), ""); diff --git a/tests/cli_edit.rs b/tests/cli_edit.rs index acd0f88..bcc6b18 100644 --- a/tests/cli_edit.rs +++ b/tests/cli_edit.rs @@ -38,9 +38,6 @@ fn edit_command_updates_tfdt_and_can_drop_boxes() { let mut reader = Cursor::new(output.clone()); let summary = probe(&mut reader).unwrap(); - let _ = fs::remove_file(&input_path); - let _ = fs::remove_file(&output_path); - assert_eq!(exit_code, 0); assert_eq!(String::from_utf8(stderr).unwrap(), ""); assert_eq!(summary.segments.len(), 1); @@ -86,9 +83,6 @@ fn edit_command_accepts_double_dash_long_options() { let mut reader = Cursor::new(output.clone()); let summary = probe(&mut reader).unwrap(); - let _ = fs::remove_file(&input_path); - let _ = fs::remove_file(&output_path); - assert_eq!(exit_code, 0); assert_eq!(String::from_utf8(stderr).unwrap(), ""); assert_eq!(summary.segments.len(), 1); @@ -126,8 +120,6 @@ fn edit_command_matches_shared_fragmented_fixture_behavior() { ) .unwrap(); - let _ = fs::remove_file(&output_path); - assert_eq!(exit_code, 0); assert_eq!(String::from_utf8(stderr).unwrap(), ""); assert_eq!(edited_summary.tracks, original_summary.tracks); @@ -197,9 +189,6 @@ fn edit_command_scopes_tfdt_rewrites_to_matching_paths() { ) .unwrap(); - let _ = fs::remove_file(&input_path); - let _ = fs::remove_file(&output_path); - assert_eq!(exit_code, 0); assert_eq!(String::from_utf8(stderr).unwrap(), ""); assert_eq!(scoped_tfdt.len(), 1); @@ -226,9 +215,6 @@ fn edit_command_rejects_path_scoped_type_mismatches() { let exit_code = edit::run(&args, &mut stderr); let stderr = String::from_utf8(stderr).unwrap(); - let _ = fs::remove_file(&input_path); - let _ = fs::remove_file(&output_path); - assert_eq!(exit_code, 1); assert!( stderr.contains( @@ -277,9 +263,6 @@ fn edit_command_rejects_unsupported_path_only_rewrites() { let mut stderr = Vec::new(); let exit_code = edit::run(&args, &mut stderr); - let _ = fs::remove_file(&input_path); - let _ = fs::remove_file(&output_path); - assert_eq!(exit_code, 1); assert_eq!( String::from_utf8(stderr).unwrap(), @@ -305,9 +288,6 @@ fn edit_command_preserves_bytes_when_scoped_path_matches_nothing() { let exit_code = edit::run(&args, &mut stderr); let output = fs::read(&output_path).unwrap(); - let _ = fs::remove_file(&input_path); - let _ = fs::remove_file(&output_path); - assert_eq!(exit_code, 0); assert_eq!(String::from_utf8(stderr).unwrap(), ""); assert_eq!(output, input); diff --git a/tests/cli_extract.rs b/tests/cli_extract.rs index fed52de..43f4944 100644 --- a/tests/cli_extract.rs +++ b/tests/cli_extract.rs @@ -2,7 +2,6 @@ mod support; -use std::fs; use std::io::Cursor; use mp4forge::BoxInfo; @@ -21,8 +20,6 @@ fn extract_command_writes_matching_raw_boxes() { let mut stderr = Vec::new(); let exit_code = extract::run(&args, &mut stdout, &mut stderr); - let _ = fs::remove_file(&path); - assert_eq!(exit_code, 0); assert_eq!(String::from_utf8(stderr).unwrap(), ""); assert_eq!(stdout.len(), 108); @@ -43,8 +40,6 @@ fn extract_command_writes_matching_raw_boxes_by_path() { let mut stderr = Vec::new(); let exit_code = extract::run(&args, &mut stdout, &mut stderr); - let _ = fs::remove_file(&path); - assert_eq!(exit_code, 0); assert_eq!(String::from_utf8(stderr).unwrap(), ""); assert_eq!(stdout.len(), 108); diff --git a/tests/cli_inspect.rs b/tests/cli_inspect.rs new file mode 100644 index 0000000..47f96b8 --- /dev/null +++ b/tests/cli_inspect.rs @@ -0,0 +1,238 @@ +#![cfg(feature = "mux")] + +mod support; + +use mp4forge::cli::inspect; + +use support::write_test_ogg_speex_file; + +#[test] +fn inspect_command_validates_argument_shape() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!(inspect::run::<_, Vec>(&[], &mut stdout, &mut stderr), 1); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!(String::from_utf8(stderr).unwrap(), inspect_usage()); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + inspect::run( + &[ + "-format".to_string(), + "toml".to_string(), + "input.bin".to_string() + ], + &mut stdout, + &mut stderr + ), + 1 + ); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error [stage=request category=input]: unsupported inspect format: toml\n" + ); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + inspect::run( + &[ + "-view".to_string(), + "frames".to_string(), + "input.bin".to_string() + ], + &mut stdout, + &mut stderr + ), + 1 + ); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error [stage=request category=input]: unsupported inspect view: frames\n" + ); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + inspect::run( + &[ + "-format".to_string(), + "nhnt".to_string(), + "input.bin".to_string() + ], + &mut stdout, + &mut stderr + ), + 1 + ); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error [stage=request category=input]: NHNT output requires `-view packets`\n" + ); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + inspect::run( + &[ + "-view".to_string(), + "packets".to_string(), + "-format".to_string(), + "nhml".to_string(), + "input.bin".to_string() + ], + &mut stdout, + &mut stderr + ), + 1 + ); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error [stage=request category=input]: NHML output requires `-view tracks`\n" + ); +} + +#[test] +fn inspect_command_writes_real_json_report_for_path_first_ogg_speex_input() { + let input = write_test_ogg_speex_file("cli-inspect-ogg-speex-input", &[b"abc", b"def"]); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + inspect::run( + &[ + "-format".to_string(), + "json".to_string(), + input.display().to_string() + ], + &mut stdout, + &mut stderr + ), + 0 + ); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output = String::from_utf8(stdout).unwrap(); + assert!(output.contains("\"SupportsFlatMux\": true")); + assert!(output.contains("\"Kind\": \"raw\"")); + assert!(output.contains("\"Codec\": \"speex\"")); + assert!(output.contains("\"TrackCount\": 1")); + assert!(output.contains("\"SampleCount\": 2")); +} + +#[test] +fn inspect_command_writes_packet_view_when_requested() { + let input = write_test_ogg_speex_file("cli-inspect-packets-ogg-speex-input", &[b"abc", b"def"]); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + inspect::run( + &[ + "-view".to_string(), + "packets".to_string(), + "-format".to_string(), + "json".to_string(), + input.display().to_string() + ], + &mut stdout, + &mut stderr + ), + 0 + ); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output = String::from_utf8(stdout).unwrap(); + assert!(output.contains("\"Packets\": [")); + assert!(output.contains("\"PacketCount\": 2")); + assert!(output.contains("\"TrackKind\": \"audio\"")); + assert!(output.contains("\"PacketIndex\": 1")); + assert!(output.contains("\"PayloadCrc32\":")); + assert!(output.contains("\"PreviousDecodeDelta\":")); +} + +#[test] +fn inspect_command_can_emit_warning_mode_when_track_diagnostics_exist() { + let input = + write_test_ogg_speex_file("cli-inspect-warnings-ogg-speex-input", &[b"abc", b"def"]); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + inspect::run( + &[ + "-warnings".to_string(), + "-format".to_string(), + "json".to_string(), + input.display().to_string() + ], + &mut stdout, + &mut stderr + ), + 0 + ); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Warning: track 1 (audio) changes decode duration 1 time(s)\n" + ); + let output = String::from_utf8(stdout).unwrap(); + assert!(output.contains("\"SupportsFlatMux\": true")); +} + +#[test] +fn inspect_command_writes_nhml_and_nhnt_sidecars() { + let input = + write_test_ogg_speex_file("cli-inspect-sidecars-ogg-speex-input", &[b"abc", b"def"]); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + inspect::run( + &[ + "-format".to_string(), + "nhml".to_string(), + input.display().to_string() + ], + &mut stdout, + &mut stderr + ), + 0 + ); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output = String::from_utf8(stdout).unwrap(); + assert!(output.starts_with("\n\n String { + String::from( + "USAGE: mp4forge inspect [OPTIONS] INPUT\n\nOPTIONS:\n -format Output format (default: json)\n -view Inspection view (default: tracks)\n -warnings Emit warning-grade diagnostics to stderr after a successful report\n", + ) +} diff --git a/tests/cli_mux.rs b/tests/cli_mux.rs new file mode 100644 index 0000000..1947fc6 --- /dev/null +++ b/tests/cli_mux.rs @@ -0,0 +1,4413 @@ +#![cfg(feature = "mux")] + +mod support; + +use std::fs; +use std::io::Cursor; + +use mp4forge::BoxInfo; +use mp4forge::boxes::avs3::Av3c; +use mp4forge::boxes::dolby::Dmlp; +use mp4forge::boxes::iamf::Iacb; +use mp4forge::boxes::iso14496_12::{ + AudioSampleEntry, Btrt, DvsC, GenericMediaSampleEntry, Hdlr, Mdhd, Nmhd, SampleEntry, Sthd, + Stss, VisualSampleEntry, XMLSubtitleSampleEntry, +}; +use mp4forge::boxes::iso14496_14::Esds; +use mp4forge::boxes::iso14496_15::VVCDecoderConfiguration; +use mp4forge::boxes::iso14496_30::{WVTTSampleEntry, WebVTTConfigurationBox, WebVTTSourceLabelBox}; +use mp4forge::boxes::threegpp::{Damr, Dqcp}; +use mp4forge::boxes::vp::VpCodecConfiguration; +use mp4forge::cli::{self, mux}; +use mp4forge::mux::{MuxFileConfig, MuxTrackConfig}; + +use support::{ + TestAviAvc1Stream, TestAviH264Stream, TestAviMp4vStream, TestAviPcmStream, TestMuxSample, + TestQcpCodecKind, TestTempPath, build_test_av1_sequence_header_obu, + build_test_mp4v_decoder_specific_info, build_test_vp10_keyframe, encode_supported_box, + fixture_path, fourcc, temp_output_dir, write_single_track_mp4_input, write_temp_file, + write_test_ac4_file, write_test_adts_file, write_test_aifc_pcm_file, write_test_aiff_pcm_file, + write_test_amr_file, write_test_amr_wb_file, write_test_av1_annex_b_file, + write_test_av1_ivf_file, write_test_av1_obu_file, write_test_avi_ac3_file, + write_test_avi_avc1_file, write_test_avi_h263_file, write_test_avi_h264_file, + write_test_avi_jpeg_file, write_test_avi_mp3_file, write_test_avi_mp4v_file, + write_test_avi_pcm_file, write_test_avi_png_file, write_test_caf_alac_file, + write_test_caf_alac_variable_packet_file, write_test_dts_file, + write_test_dts_little_endian_file, write_test_flac_file, write_test_h263_file, + write_test_h265_annexb_file, write_test_iamf_file, write_test_jpeg_file, write_test_latm_file, + write_test_mhas_file, write_test_mp3_file, write_test_mp4v_file, write_test_ogg_flac_file, + write_test_ogg_flac_mapping_file, write_test_ogg_flac_split_header_file, + write_test_ogg_opus_file, write_test_ogg_speex_file, write_test_ogg_theora_file, + write_test_ogg_vorbis_file, write_test_png_file, write_test_program_stream_ac3_file, + write_test_program_stream_h264_file, write_test_program_stream_h264_open_ended_file, + write_test_program_stream_h265_file, write_test_program_stream_lpcm_file, + write_test_program_stream_mp3_file, write_test_program_stream_mp4v_file, + write_test_program_stream_mpeg2v_file, write_test_program_stream_vobsub_file, + write_test_program_stream_vvc_file, write_test_qcp_constant_file, + write_test_transport_stream_ac3_file, write_test_transport_stream_ac4_file, + write_test_transport_stream_av1_file, write_test_transport_stream_avs3_file, + write_test_transport_stream_dts_file, write_test_transport_stream_dvb_subtitle_file, + write_test_transport_stream_dvb_teletext_file, write_test_transport_stream_eac3_file, + write_test_transport_stream_h264_file, write_test_transport_stream_h265_file, + write_test_transport_stream_latm_file, write_test_transport_stream_mhas_file, + write_test_transport_stream_mp3_file, write_test_transport_stream_mp4v_file, + write_test_transport_stream_mpeg2v_file, write_test_transport_stream_truehd_file, + write_test_transport_stream_vvc_file, write_test_truehd_file, write_test_usac_latm_file, + write_test_vobsub_files, write_test_vp10_ivf_file, write_test_wave_pcm_file, + write_test_wrapped_dts_file_with_tail, +}; + +#[test] +fn mux_command_validates_argument_shape() { + let mut stderr = Vec::new(); + assert_eq!(mux::run(&[], &mut stderr), 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + concat!( + "USAGE: mp4forge mux --track [--track ...] [--layout ] [--segment_duration | --fragment_duration ] [--out | --init_out --media_out ] [DEST]\n", + "\n", + "OPTIONS:\n", + " --track Add one mux input using the path-first track-spec grammar\n", + " Path only: PATH\n", + " Select one MP4 track when needed with: PATH#video, PATH#audio, PATH#audio:N, PATH#text, PATH#text:N, PATH#track:ID\n", + " Current path-only auto-detection covers MP4, VobSub, supported AVI audio streams plus H.263/JPEG/PNG/MPEG-4 Part 2/H.264/AVC1 video streams, supported MPEG-PS MPEG audio streams plus LPCM audio plus MPEG-4 Part 2/H.264/H.265/VVC video streams, supported MPEG-TS MPEG audio streams plus AAC LATM/MHAS plus AC-3/E-AC-3/AC-4/DTS/TrueHD audio plus MPEG-2/AV1/AVS3/MPEG-4 Part 2/H.264/H.265/VVC video streams, AAC ADTS, AAC LATM, MP3, AC-3, E-AC-3, AC-4, AMR, AMR-WB, QCP voice audio, DTS-family core audio, Dolby TrueHD, leading-sync MHAS MPEG-H, IAMF, H.263 elementary video, MPEG-2 elementary video, MPEG-4 Part 2 elementary video, H.264 Annex B, H.265 Annex B, VVC Annex B, raw AV1 OBU, raw AV1 Annex B, IVF AV1/VP8/VP9/VP10, JPEG still images, PNG still images, WAVE/AIFF/AIFC PCM, native FLAC, Ogg FLAC, Ogg Opus, Ogg Vorbis, Ogg Speex, Ogg Theora, and CAF ALAC\n", + " Broader DTS-family sample-entry variants remain supported through MP4 track import\n", + " --segment_duration Set one target segment duration for supported fragmented jobs\n", + " --fragment_duration Set one target fragment duration for supported fragmented jobs\n", + " --layout Choose the output container layout; defaults to flat\n", + " --out Force one newly created output destination at PATH\n", + " --init_out Write fragmented initialization boxes to PATH\n", + " --media_out Write fragmented index and media fragments to PATH\n", + " -warnings Emit warning-grade diagnostics to stderr after a successful run\n", + "\n", + "Flat mux jobs may carry multiple video tracks as separate tracks plus one or more audio and text/subtitle tracks. One positional DEST path follows the update-or-create destination flow: if DEST is an existing MP4, its current tracks are preserved and the requested tracks are imported into it; otherwise DEST is treated as the newly created output file. `--out PATH` is the explicit force-new path. `--init_out PATH --media_out PATH` writes a fragmented job as separate outputs. Flat output rejects duration modes. Fragmented output currently requires exactly one duration mode and supports at most one video track per mux output. Path-only MP4 inputs import all supported tracks unless you add one selector suffix.\n", + ) + ); +} + +#[test] +fn mux_command_rejects_positional_dest_when_out_is_present() { + let video_input = build_video_input_file("mux-cli-out-conflict-input", fourcc("isom")); + let args = vec![ + "--out".to_string(), + "fresh-output.mp4".to_string(), + "--track".to_string(), + video_input.to_string_lossy().into_owned(), + "dest.mp4".to_string(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error [stage=request category=input]: --out may not be used together with a positional DEST path\n" + ); +} + +#[test] +fn mux_command_updates_the_positional_destination_mp4() { + let destination = build_video_input_file("mux-cli-destination-video-input", fourcc("isom")); + let audio_input = write_test_adts_file("mux-cli-destination-audio-input", &[b"aud"]); + let args = vec![ + "--track".to_string(), + audio_input.to_string_lossy().into_owned(), + destination.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + + let output_bytes = fs::read(&destination).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(hdlr_boxes.len(), 2); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_dts_input() { + let dts_input = write_test_dts_file("mux-cli-path-only-dts-input", 2); + let expected_payload = fs::read(&dts_input).unwrap(); + let output = write_temp_file("mux-cli-path-only-dts-output", &[]); + let args = vec![ + "--track".to_string(), + dts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_little_endian_dts_input() { + let dts_input = write_test_dts_little_endian_file("mux-cli-path-only-dts-le-input", 2); + let expected_payload = fs::read(&dts_input).unwrap(); + let output = write_temp_file("mux-cli-path-only-dts-le-output", &[]); + let args = vec![ + "--track".to_string(), + dts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_wrapped_core_dts_input_with_trailing_family_tail() { + let dts_input = write_test_wrapped_dts_file_with_tail( + "mux-cli-path-only-dts-wrapped-tail-input", + 2, + b"DTSHDTRAILER", + ); + let expected_payload = fs::read(&dts_input).unwrap(); + let output = write_temp_file("mux-cli-path-only-dts-wrapped-tail-output", &[]); + let args = vec![ + "--track".to_string(), + dts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_avi_pcm_input() { + let chunk = [0_u8, 0, 0, 0, 1, 0, 1, 0]; + let avi_input = write_test_avi_pcm_file( + "mux-cli-path-only-avi-input", + &[TestAviPcmStream { + sample_rate: 48_000, + channel_count: 2, + bits_per_sample: 16, + chunks: &[&chunk], + }], + ); + let output = write_temp_file("mux-cli-path-only-avi-output", &[]); + let args = vec![ + "--track".to_string(), + avi_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let hdlr_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(hdlr_boxes.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_mp4v_input() { + let decoder_specific_info = build_test_mp4v_decoder_specific_info(320, 180); + let intra_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x00, 0xAA, 0xBB]; + let predictive_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x40, 0xCC, 0xDD]; + let mut elementary = decoder_specific_info; + elementary.extend_from_slice(&intra_frame); + elementary.extend_from_slice(&predictive_frame); + let mp4v_input = write_test_mp4v_file("mux-cli-path-only-mp4v-input", &elementary); + let output = write_temp_file("mux-cli-path-only-mp4v-output", &[]); + let args = vec![ + "--track".to_string(), + mp4v_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 180); + assert_eq!(video_entries[0].compressorname[0], 0); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 25_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_avi_mp4v_input() { + let decoder_specific_info = [0x00_u8, 0x00, 0x01, 0x20, 0x11, 0x22]; + let intra_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x00, 0xAA, 0xBB]; + let predictive_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x40, 0xCC, 0xDD]; + let avi_input = write_test_avi_mp4v_file( + "mux-cli-path-only-avi-mp4v-input", + &TestAviMp4vStream { + width: 320, + height: 180, + frame_scale: 1, + frame_rate: 25, + compression: *b"MP4V", + decoder_specific_info: &decoder_specific_info, + frames: &[&intra_frame, &predictive_frame], + }, + ); + let output = write_temp_file("mux-cli-path-only-avi-mp4v-output", &[]); + let args = vec![ + "--track".to_string(), + avi_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 180); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 25_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_avi_h264_input() { + let avi_input = write_test_avi_h264_file( + "mux-cli-path-only-avi-h264-input", + &TestAviH264Stream { + width: 320, + height: 180, + frame_scale: 1, + frame_rate: 25, + compression: *b"H264", + sample_payloads: &[b"\xAA\xBB", b"\xCC\xDD"], + }, + ); + let output = write_temp_file("mux-cli-path-only-avi-h264-output", &[]); + let args = vec![ + "--track".to_string(), + avi_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 180); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 25_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_avi_avc1_input() { + let avi_input = write_test_avi_avc1_file( + "cli-mux-avi-avc1-input", + &TestAviAvc1Stream { + width: 320, + height: 180, + frame_scale: 1, + frame_rate: 25, + sample_payloads: &[b"\xAA\xBB", b"\xCC\xDD"], + }, + ); + let output_path = write_temp_file("cli-mux-avi-avc1-output", &[]); + let args = vec![ + "--track".to_string(), + avi_input.to_string_lossy().into_owned(), + "--out".to_string(), + output_path.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + assert_eq!( + mux::run(&args, &mut stderr), + 0, + "{}", + String::from_utf8_lossy(&stderr) + ); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + ]), + ); + let handlers = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 180); + assert_eq!(handlers.len(), 1); + assert_eq!(handlers[0].name, "VideoHandler"); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_avi_mp3_input() { + let avi_input = write_test_avi_mp3_file( + "mux-cli-path-only-avi-mp3-input", + 48_000, + 2, + &[b"avi-mp3-a", b"avi-mp3-b"], + ); + let output = write_temp_file("mux-cli-path-only-avi-mp3-output", &[]); + let args = vec![ + "--track".to_string(), + avi_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(".mp3"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc(".mp3")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_avi_ac3_input() { + let avi_input = write_test_avi_ac3_file( + "mux-cli-path-only-avi-ac3-input", + 48_000, + 2, + &[b"avi-ac3-a", b"avi-ac3-b"], + ); + let output = write_temp_file("mux-cli-path-only-avi-ac3-output", &[]); + let args = vec![ + "--track".to_string(), + avi_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ac-3"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ac-3")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_avi_h263_input() { + let avi_input = write_test_avi_h263_file( + "mux-cli-path-only-avi-h263-input", + 176, + 144, + 1, + 25, + &[b"\xAA\xBB", b"\xCC\xDD"], + ); + let output = write_temp_file("mux-cli-path-only-avi-h263-output", &[]); + let args = vec![ + "--track".to_string(), + avi_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("H263"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("H263"), + fourcc("btrt"), + ]), + ); + let stss_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 176); + assert_eq!(video_entries[0].height, 144); + assert_eq!(btrt_boxes.len(), 1); + assert!(btrt_boxes[0].buffer_size_db > 0); + assert!(btrt_boxes[0].max_bitrate > 0); + assert!(btrt_boxes[0].avg_bitrate > 0); + assert_eq!(stss_boxes.len(), 1); + assert_eq!(stss_boxes[0].entry_count, 0); + assert!(stss_boxes[0].sample_number.is_empty()); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_avi_jpeg_input() { + let jpeg_frame = fs::read(fixture_path("generated-1x1.jpg")).unwrap(); + let avi_input = write_test_avi_jpeg_file( + "mux-cli-path-only-avi-jpeg-input", + 1, + 1, + 1, + 25, + &[&jpeg_frame], + ); + let output = write_temp_file("mux-cli-path-only-avi-jpeg-output", &[]); + let args = vec![ + "--track".to_string(), + avi_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("MJPG"), + ]), + ); + let stss_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 1); + assert_eq!(video_entries[0].height, 1); + assert_eq!(stss_boxes.len(), 1); + assert_eq!(stss_boxes[0].entry_count, 0); + assert!(stss_boxes[0].sample_number.is_empty()); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_avi_png_input() { + let png_frame_path = write_test_png_file("mux-cli-path-only-avi-png-frame"); + let png_frame = fs::read(png_frame_path).unwrap(); + let avi_input = write_test_avi_png_file( + "mux-cli-path-only-avi-png-input", + 1, + 1, + 1, + 25, + &[&png_frame], + ); + let output = write_temp_file("mux-cli-path-only-avi-png-output", &[]); + let args = vec![ + "--track".to_string(), + avi_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("PNG "), + ]), + ); + let stss_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 1); + assert_eq!(video_entries[0].height, 1); + assert_eq!(stss_boxes.len(), 1); + assert_eq!(stss_boxes[0].entry_count, 0); + assert!(stss_boxes[0].sample_number.is_empty()); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_program_stream_mp4v_input() { + let decoder_specific_info = build_test_mp4v_decoder_specific_info(320, 180); + let intra_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x00, 0xAA, 0xBB]; + let predictive_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x40, 0xCC, 0xDD]; + let first_payload = [&decoder_specific_info[..], &intra_frame[..]].concat(); + let ps_input = write_test_program_stream_mp4v_file( + "mux-cli-path-only-program-stream-mp4v-input", + &[&first_payload, &predictive_frame], + ); + let output = write_temp_file("mux-cli-path-only-program-stream-mp4v-output", &[]); + let args = vec![ + "--track".to_string(), + ps_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + assert_eq!(video_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_program_stream_mpeg2v_input() { + let ps_input = write_test_program_stream_mpeg2v_file( + "mux-cli-path-only-program-stream-mpeg2v-input", + &[b"mpeg2v-a", b"mpeg2v-b"], + ); + let output = write_temp_file("mux-cli-path-only-program-stream-mpeg2v-output", &[]); + let args = vec![ + "--track".to_string(), + ps_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + assert_eq!(video_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_program_stream_input() { + let ps_input = write_test_program_stream_mp3_file( + "mux-cli-path-only-program-stream-input", + &[&[0x21; 96]], + ); + let output = write_temp_file("mux-cli-path-only-program-stream-output", &[]); + let args = vec![ + "--track".to_string(), + ps_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(".mp3"), + ]), + ); + assert_eq!(audio_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_program_stream_ac3_input() { + let ps_input = + write_test_program_stream_ac3_file("mux-cli-path-only-program-stream-ac3-input", &[b"ac3"]); + let output = write_temp_file("mux-cli-path-only-program-stream-ac3-output", &[]); + let args = vec![ + "--track".to_string(), + ps_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ac-3"), + ]), + ); + assert_eq!(audio_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_program_stream_mp3_input() { + let ps_input = write_test_program_stream_mp3_file( + "mux-cli-path-only-program-stream-mp3-input", + &[b"mp3-a", b"mp3-b"], + ); + let output = write_temp_file("mux-cli-path-only-program-stream-mp3-output", &[]); + let args = vec![ + "--track".to_string(), + ps_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(".mp3"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc(".mp3")); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_program_stream_lpcm_input() { + let sample_a = [0x00_u8, 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x04]; + let sample_b = [0x00_u8, 0x05, 0x00, 0x06, 0x00, 0x07, 0x00, 0x08]; + let ps_input = write_test_program_stream_lpcm_file( + "mux-cli-path-only-program-stream-lpcm-input", + &[&sample_a, &sample_b], + ); + let output = write_temp_file("mux-cli-path-only-program-stream-lpcm-output", &[]); + let args = vec![ + "--track".to_string(), + ps_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ipcm")); + assert_eq!(audio_entries[0].channel_count, 2); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_program_stream_h264_input() { + let ps_input = write_test_program_stream_h264_file( + "mux-cli-path-only-program-stream-h264-input", + &[b"idr"], + ); + let output = write_temp_file("mux-cli-path-only-program-stream-h264-output", &[]); + let args = vec![ + "--track".to_string(), + ps_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + ]), + ); + assert_eq!(video_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_program_stream_h264_open_ended_input() { + let ps_input = write_test_program_stream_h264_open_ended_file( + "mux-cli-path-only-program-stream-h264-open-ended-input", + &[b"idr", b"p-frame"], + ); + let output = write_temp_file( + "mux-cli-path-only-program-stream-h264-open-ended-output", + &[], + ); + let args = vec![ + "--track".to_string(), + ps_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + ]), + ); + assert_eq!(video_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_program_stream_h265_input() { + let ps_input = write_test_program_stream_h265_file( + "mux-cli-path-only-program-stream-h265-input", + &[b"hevc"], + ); + let output = write_temp_file("mux-cli-path-only-program-stream-h265-output", &[]); + let args = vec![ + "--track".to_string(), + ps_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("hvc1"), + ]), + ); + assert_eq!(video_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_mp4v_input() { + let decoder_specific_info = build_test_mp4v_decoder_specific_info(320, 180); + let intra_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x00, 0xAA, 0xBB]; + let predictive_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x40, 0xCC, 0xDD]; + let first_payload = [&decoder_specific_info[..], &intra_frame[..]].concat(); + let ts_input = write_test_transport_stream_mp4v_file( + "mux-cli-path-only-transport-stream-mp4v-input", + &[&first_payload, &predictive_frame], + ); + let output = write_temp_file("mux-cli-path-only-transport-stream-mp4v-output", &[]); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + assert_eq!(video_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_mpeg2v_input() { + let ts_input = write_test_transport_stream_mpeg2v_file( + "mux-cli-path-only-transport-stream-mpeg2v-input", + &[b"mpeg2v-a", b"mpeg2v-b"], + ); + let output = write_temp_file("mux-cli-path-only-transport-stream-mpeg2v-output", &[]); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + assert_eq!(video_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_av1_input() { + let frame_a = build_test_av1_sequence_header_obu(320, 240); + let frame_b = build_test_av1_sequence_header_obu(320, 240); + let ts_input = write_test_transport_stream_av1_file( + "mux-cli-path-only-transport-stream-av1-input", + &[&frame_a, &frame_b], + ); + let output = write_temp_file("mux-cli-path-only-transport-stream-av1-output", &[]); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("av01"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 240); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_avs3_input() { + let ts_input = write_test_transport_stream_avs3_file( + "mux-cli-path-only-transport-stream-avs3-input", + &[b"avs3-a", b"avs3-b"], + ); + let output = write_temp_file("mux-cli-path-only-transport-stream-avs3-output", &[]); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avs3"), + ]), + ); + let av3c_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avs3"), + fourcc("av3c"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avs3"), + fourcc("btrt"), + ]), + ); + let stss_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 0); + assert_eq!(video_entries[0].height, 0); + assert_eq!(av3c_boxes.len(), 1); + assert_eq!( + av3c_boxes[0].sequence_header, + vec![0x00, 0x00, 0x01, 0xB0, 0x20, 0x10] + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].name, "VideoHandler"); + assert_eq!(btrt_boxes.len(), 1); + assert_eq!(stss_boxes.len(), 1); + assert_eq!(stss_boxes[0].entry_count, 0); + assert!(stss_boxes[0].sample_number.is_empty()); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_h264_input() { + let ts_input = write_test_transport_stream_h264_file( + "mux-cli-path-only-transport-stream-h264-input", + &[b"idr"], + ); + let output = write_temp_file("mux-cli-path-only-transport-stream-h264-output", &[]); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + ]), + ); + assert_eq!(video_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_h265_input() { + let ts_input = write_test_transport_stream_h265_file( + "mux-cli-path-only-transport-stream-h265-input", + &[b"hevc"], + ); + let output = write_temp_file("mux-cli-path-only-transport-stream-h265-output", &[]); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("hvc1"), + ]), + ); + assert_eq!(video_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_program_stream_vvc_input() { + let ps_input = + write_test_program_stream_vvc_file("mux-cli-path-only-program-stream-vvc-input", &[]); + let output = write_temp_file("mux-cli-path-only-program-stream-vvc-output", &[]); + let args = vec![ + "--track".to_string(), + ps_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("vvc1"), + ]), + ); + let vvc_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("vvc1"), + fourcc("vvcC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 1280); + assert_eq!(video_entries[0].height, 720); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 25); + assert_eq!(mdhd_boxes[0].duration(), 2); + assert_eq!(vvc_boxes.len(), 1); + assert!(!vvc_boxes[0].decoder_configuration_record.is_empty()); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_vvc_input() { + let ts_input = + write_test_transport_stream_vvc_file("mux-cli-path-only-transport-stream-vvc-input", &[]); + let output = write_temp_file("mux-cli-path-only-transport-stream-vvc-output", &[]); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("vvc1"), + ]), + ); + let vvc_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("vvc1"), + fourcc("vvcC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 1280); + assert_eq!(video_entries[0].height, 720); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(mdhd_boxes[0].duration(), 0); + assert_eq!(vvc_boxes.len(), 1); + assert!(!vvc_boxes[0].decoder_configuration_record.is_empty()); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_ac3_input() { + let ts_input = write_test_transport_stream_ac3_file( + "mux-cli-path-only-transport-stream-ac3-input", + &[b"ac3"], + ); + let output = write_temp_file("mux-cli-path-only-transport-stream-ac3-output", &[]); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ac-3"), + ]), + ); + assert_eq!(audio_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_latm_input() { + let ts_input = write_test_transport_stream_latm_file( + "mux-cli-path-only-transport-stream-latm-input", + &[b"abc", b"defg"], + ); + let output = write_temp_file("mux-cli-path-only-transport-stream-latm-output", &[]); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + fourcc("esds"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(esds_boxes.len(), 1); + assert_eq!( + esds_boxes[0] + .decoder_config_descriptor() + .unwrap() + .object_type_indication, + 0x40 + ); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_mhas_input() { + let ts_input = write_test_transport_stream_mhas_file( + "mux-cli-path-only-transport-stream-mhas-input", + &[b"frame-one", b"frame-two"], + ); + let output = write_temp_file("mux-cli-path-only-transport-stream-mhas-output", &[]); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mhm1"), + ]), + ); + assert_eq!(audio_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_eac3_input() { + let ts_input = write_test_transport_stream_eac3_file( + "mux-cli-path-only-transport-stream-eac3-input", + &[b"ec3"], + ); + let output = write_temp_file("mux-cli-path-only-transport-stream-eac3-output", &[]); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ec-3"), + ]), + ); + assert_eq!(audio_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_ac4_input() { + let ts_input = + write_test_transport_stream_ac4_file("mux-cli-path-only-transport-stream-ac4-input", 2); + let output = write_temp_file("mux-cli-path-only-transport-stream-ac4-output", &[]); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ac-4"), + ]), + ); + assert_eq!(audio_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_truehd_input() { + let ts_input = write_test_transport_stream_truehd_file( + "mux-cli-path-only-transport-stream-truehd-input", + &[b"abcdefgh", b"ijklmnop"], + ); + let output = write_temp_file("mux-cli-path-only-transport-stream-truehd-output", &[]); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mlpa"), + ]), + ); + assert_eq!(audio_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_dts_input() { + let ts_input = + write_test_transport_stream_dts_file("mux-cli-path-only-transport-stream-dts-input", 2); + let output = write_temp_file("mux-cli-path-only-transport-stream-dts-output", &[]); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dtsx"), + ]), + ); + assert_eq!(audio_entries.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_dvb_subtitle_input() { + let ts_input = write_test_transport_stream_dvb_subtitle_file( + "mux-cli-path-only-transport-stream-dvb-subtitle-input", + &[b"\x20cli-subtitle"], + ); + let output = write_temp_file( + "mux-cli-path-only-transport-stream-dvb-subtitle-output", + &[], + ); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let subtitle_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dvbs"), + ]), + ); + let dvsc_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dvbs"), + fourcc("dvsC"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(subtitle_entries.len(), 1); + assert_eq!(subtitle_entries[0].sample_entry.box_type, fourcc("dvbs")); + assert_eq!(dvsc_boxes.len(), 1); + assert_eq!(dvsc_boxes[0].composition_page_id, 0x0123); + assert_eq!(dvsc_boxes[0].ancillary_page_id, 0x0456); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("subt")); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_dvb_teletext_input() { + let ts_input = write_test_transport_stream_dvb_teletext_file( + "mux-cli-path-only-transport-stream-dvb-teletext-input", + &[b"\x10cli-text"], + ); + let output = write_temp_file( + "mux-cli-path-only-transport-stream-dvb-teletext-output", + &[], + ); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let subtitle_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dvbt"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(subtitle_entries.len(), 1); + assert_eq!(subtitle_entries[0].sample_entry.box_type, fourcc("dvbt")); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("subt")); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_vobsub_sub_input() { + let (_idx_input, sub_input) = + write_test_vobsub_files("mux-cli-path-only-vobsub-sub-input", &[0], &[b"\x11\x22"]); + let output = write_temp_file("mux-cli-path-only-vobsub-sub-output", &[]); + let args = vec![ + "--track".to_string(), + sub_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let subtitle_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4s"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(subtitle_entries.len(), 1); + assert_eq!(subtitle_entries[0].sample_entry.box_type, fourcc("mp4s")); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("subp")); + assert_eq!(hdlr_boxes[0].name, "SubtitleHandler"); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_program_stream_vobsub_input() { + let ps_input = write_test_program_stream_vobsub_file( + "mux-cli-path-only-program-stream-vobsub-input", + &[0], + &[b"\x11\x22"], + ); + let output = write_temp_file("mux-cli-path-only-program-stream-vobsub-output", &[]); + let args = vec![ + "--track".to_string(), + ps_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let subtitle_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4s"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(subtitle_entries.len(), 1); + assert_eq!(subtitle_entries[0].sample_entry.box_type, fourcc("mp4s")); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("subp")); + assert_eq!(hdlr_boxes[0].name, "SubtitleHandler"); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_vvc_input() { + let vvc_input = fixture_path("mux/raw_vvc_idr.vvc"); + let output = write_temp_file("mux-cli-path-only-vvc-output", &[]); + let args = vec![ + "--track".to_string(), + vvc_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("vvc1"), + ]), + ); + let vvc_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("vvc1"), + fourcc("vvcC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 1280); + assert_eq!(video_entries[0].height, 720); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 25); + assert_eq!(mdhd_boxes[0].duration(), 2); + assert_eq!(vvc_boxes.len(), 1); + assert!(!vvc_boxes[0].decoder_configuration_record.is_empty()); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_only_transport_stream_input() { + let ts_input = write_test_transport_stream_mp3_file( + "mux-cli-path-only-transport-stream-input", + &[&[0x31; 320]], + ); + let output = write_temp_file("mux-cli-path-only-transport-stream-output", &[]); + let args = vec![ + "--track".to_string(), + ts_input.to_string_lossy().into_owned(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(".mp3"), + ]), + ); + assert_eq!(audio_entries.len(), 1); +} + +#[test] +fn mux_command_rejects_invalid_track_specs() { + let output = write_temp_file("mux-cli-invalid-output", &[]); + let args = vec![ + "--track".to_string(), + "input.bin#width=640".to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error [stage=request category=input]: invalid mux track spec `input.bin#width=640`: public mux track specs only allow selector suffixes such as `#video`, `#audio`, `#text`, or `#track:ID`; raw `#name=value` parameters are no longer accepted\n" + ); +} + +#[test] +fn mux_command_rejects_conflicting_duration_flags() { + let output = write_temp_file("mux-cli-conflict-output", &[]); + let args = vec![ + "--track".to_string(), + "input.aac".to_string(), + "--segment_duration".to_string(), + "4".to_string(), + "--fragment_duration".to_string(), + "2".to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error [stage=request category=input]: --segment_duration and --fragment_duration may not be used together\n" + ); +} + +#[test] +fn mux_command_rejects_duration_flags_for_flat_layout() { + let output = write_temp_file("mux-cli-flat-layout-output", &[]); + let args = vec![ + "--track".to_string(), + "input.aac".to_string(), + "--fragment_duration".to_string(), + "2".to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error [stage=request category=input]: invalid mux layout `flat`: flat output does not support `--fragment_duration`; use `--layout fragmented` instead\n" + ); +} + +#[test] +fn mux_command_rejects_fragmented_layout_without_duration() { + let output = write_temp_file("mux-cli-fragmented-missing-duration-output", &[]); + let args = vec![ + "--track".to_string(), + "input.aac".to_string(), + "--layout".to_string(), + "fragmented".to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error [stage=request category=input]: invalid mux layout `fragmented`: fragmented output requires exactly one of `--segment_duration` or `--fragment_duration`\n" + ); +} + +#[test] +fn mux_command_rejects_fragmented_multiple_video_tracks() { + let output = write_temp_file("mux-cli-multi-video-output", &[]); + let args = vec![ + "--track".to_string(), + "first.mp4#video".to_string(), + "--track".to_string(), + "second.mp4#video".to_string(), + "--layout".to_string(), + "fragmented".to_string(), + "--fragment_duration".to_string(), + "1.0".to_string(), + "--out".to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error [stage=request category=input]: fragmented output supports at most one video track per mux output, but 2 were requested\n" + ); +} + +#[test] +fn mux_command_writes_fragmented_multi_track_jobs() { + let audio_input = + build_audio_input_file("mux-cli-fragmented-multi-audio-input", fourcc("dash")); + let video_input = + build_video_input_file("mux-cli-fragmented-multi-video-input", fourcc("isom")); + let output = write_temp_file("mux-cli-fragmented-multi-track-output", &[]); + let args = vec![ + "--track".to_string(), + format!("{}#audio", audio_input.display()), + "--track".to_string(), + format!("{}#video", video_input.display()), + "--layout".to_string(), + "fragmented".to_string(), + "--fragment_duration".to_string(), + "1.0".to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("sidx"), + fourcc("moof"), + fourcc("mdat"), + ] + ); + assert_eq!(mdat_payload(&output_bytes, root_boxes[4]), b"audvideo"); +} + +#[test] +fn mux_command_rejects_fragmented_destination_path_mode_before_execution() { + let destination = + build_audio_input_file("mux-cli-fragmented-destination-output", fourcc("isom")); + let audio_input = write_test_adts_file("mux-cli-fragmented-destination-audio-input", &[b"aud"]); + let args = vec![ + "--track".to_string(), + audio_input.to_string_lossy().into_owned(), + "--layout".to_string(), + "fragmented".to_string(), + "--fragment_duration".to_string(), + "2".to_string(), + destination.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error [stage=request category=input]: invalid mux destination mode `update-or-create-destination`: the current destination-path mux mode only supports flat output; use `--out PATH` for create-new fragmented output\n" + ); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_mp4_tracks() { + let audio_input = build_audio_input_file("mux-cli-audio-input", fourcc("dash")); + let video_input = build_video_input_file("mux-cli-video-input", fourcc("isom")); + let output = write_temp_file("mux-cli-output", &[]); + let args = vec![ + "--track".to_string(), + format!("{}#audio", audio_input.display()), + "--track".to_string(), + format!("{}#video", video_input.display()), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"audvideo"); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_text_track_selectors() { + let text_input = build_text_input_file("mux-cli-text-input", fourcc("isom")); + let output = write_temp_file("mux-cli-text-output", &[]); + let args = vec![ + "--track".to_string(), + format!("{}#text", text_input.display()), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"wvtt"); + + let hdlr_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("text")); + + let nmhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("nmhd"), + ]), + ); + assert_eq!(nmhd_boxes.len(), 1); +} + +#[test] +fn mux_command_writes_fragmented_output_when_requested() { + let audio_input = build_audio_input_file("mux-cli-fragmented-audio-input", fourcc("isom")); + let output = write_temp_file("mux-cli-fragmented-output", &[]); + let args = vec![ + "--track".to_string(), + format!("{}#audio", audio_input.display()), + "--layout".to_string(), + "fragmented".to_string(), + "--fragment_duration".to_string(), + "0.015".to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("sidx"), + fourcc("moof"), + fourcc("mdat"), + ] + ); +} + +#[test] +fn mux_command_writes_separate_fragmented_outputs_when_requested() { + let audio_input = + build_audio_input_file("mux-cli-fragmented-split-audio-input", fourcc("isom")); + let output_dir = temp_output_dir("mux-cli-fragmented-split-output-dir"); + let init_output = output_dir.join("init.mp4"); + let media_output = output_dir.join("media.mp4"); + let args = vec![ + "--track".to_string(), + format!("{}#audio", audio_input.display()), + "--layout".to_string(), + "fragmented".to_string(), + "--fragment_duration".to_string(), + "0.015".to_string(), + "--init_out".to_string(), + init_output.to_string_lossy().into_owned(), + "--media_out".to_string(), + media_output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let init_bytes = fs::read(init_output).unwrap(); + let media_bytes = fs::read(media_output).unwrap(); + let init_boxes = read_root_boxes(&init_bytes); + let media_boxes = read_root_boxes(&media_bytes); + assert_eq!( + init_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![fourcc("ftyp"), fourcc("moov")] + ); + assert_eq!( + media_boxes + .iter() + .map(BoxInfo::box_type) + .collect::>(), + vec![fourcc("sidx"), fourcc("moof"), fourcc("mdat")] + ); +} + +#[test] +fn mux_command_rejects_separate_outputs_for_flat_layout() { + let audio_input = build_audio_input_file("mux-cli-flat-split-audio-input", fourcc("isom")); + let output_dir = temp_output_dir("mux-cli-flat-split-output-dir"); + let args = vec![ + "--track".to_string(), + format!("{}#audio", audio_input.display()), + "--init_out".to_string(), + output_dir.join("init.mp4").to_string_lossy().into_owned(), + "--media_out".to_string(), + output_dir.join("media.mp4").to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error [stage=request category=input]: invalid mux layout `flat`: separate fragmented output requires fragmented layout\n" + ); +} + +#[test] +fn mux_command_can_emit_warning_mode_for_fragmented_audio_only_output() { + let audio_input = + build_audio_input_file("mux-cli-fragmented-warning-audio-input", fourcc("isom")); + let output = write_temp_file("mux-cli-fragmented-warning-output", &[]); + let args = vec![ + "-warnings".to_string(), + "--track".to_string(), + format!("{}#audio", audio_input.display()), + "--layout".to_string(), + "fragmented".to_string(), + "--fragment_duration".to_string(), + "0.015".to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Warning: divide output is audio-only; no fragmented video track was selected\n" + ); +} + +#[test] +fn mux_command_writes_mixed_video_audio_subtitle_output_and_preserves_track_metadata() { + let video_input = build_video_input_file_with_metadata( + "mux-cli-mixed-video-input", + fourcc("isom"), + "avc1", + *b"und", + "PrimaryVideoHandler", + b"video", + ); + let audio_input = build_audio_input_file_with_metadata( + "mux-cli-mixed-audio-input", + fourcc("dash"), + "mp4a", + *b"eng", + "EnglishAudioHandler", + b"aud", + ); + let text_input = build_mixed_text_input_file("mux-cli-mixed-text-input", fourcc("mp42")); + let output = write_temp_file("mux-cli-mixed-output", &[]); + let args = vec![ + "--track".to_string(), + format!("{}#video", video_input.display()), + "--track".to_string(), + format!("{}#audio", audio_input.display()), + "--track".to_string(), + format!("{}#text", text_input.display()), + "--track".to_string(), + format!("{}#text:2", text_input.display()), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"videoaudwvttstpp" + ); + + let hdlr_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!( + hdlr_boxes + .iter() + .map(|box_value| box_value.handler_type) + .collect::>(), + vec![ + fourcc("vide"), + fourcc("soun"), + fourcc("text"), + fourcc("subt"), + ] + ); + assert_eq!( + hdlr_boxes + .iter() + .map(|box_value| box_value.name.as_str()) + .collect::>(), + vec![ + "PrimaryVideoHandler", + "EnglishAudioHandler", + "EnglishCaptionHandler", + "FrenchSubtitleHandler", + ] + ); + + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!( + mdhd_boxes + .iter() + .map(|box_value| decode_mdhd_language(box_value.language)) + .collect::>(), + vec![*b"und", *b"eng", *b"eng", *b"fra"] + ); + + let nmhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("nmhd"), + ]), + ); + assert_eq!(nmhd_boxes.len(), 1); + + let sthd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("sthd"), + ]), + ); + assert_eq!(sthd_boxes.len(), 1); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_broader_codec_track_selectors() { + let audio_input = + build_audio_input_file_with_type("mux-cli-alac-input", fourcc("dash"), "alac"); + let video_input = + build_video_input_file_with_type("mux-cli-dvh1-input", fourcc("isom"), "dvh1"); + let output = write_temp_file("mux-cli-broader-output", &[]); + let args = vec![ + "--track".to_string(), + format!("{}#audio", audio_input.display()), + "--track".to_string(), + format!("{}#video", video_input.display()), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"alacdvh1"); + + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("alac"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("alac")); + + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dvh1"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("dvh1")); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_ivf_tracks() { + let av1_frame_a = build_test_av1_sequence_header_obu(640, 360); + let av1_frame_b = build_test_av1_sequence_header_obu(640, 360); + let video_input = write_test_av1_ivf_file( + "mux-cli-raw-av1-input", + 640, + 360, + &[0, 1], + &[av1_frame_a.as_slice(), av1_frame_b.as_slice()], + ); + let output = write_temp_file("mux-cli-raw-broader-output", &[]); + let args = vec![ + "--track".to_string(), + video_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + [av1_frame_a, av1_frame_b].concat() + ); + + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("av01"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("av01")); + assert_eq!(video_entries[0].width, 640); + assert_eq!(video_entries[0].height, 360); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_raw_av1_obu_tracks() { + let av1_frame_a = build_test_av1_sequence_header_obu(640, 360); + let av1_frame_b = build_test_av1_sequence_header_obu(640, 360); + let video_input = + write_test_av1_obu_file("mux-cli-raw-av1-obu-input", &[&av1_frame_a, &av1_frame_b]); + let output = write_temp_file("mux-cli-raw-av1-obu-output", &[]); + let args = vec![ + "--track".to_string(), + video_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + [av1_frame_a, av1_frame_b].concat() + ); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_raw_av1_annexb_tracks() { + let av1_frame_a = build_test_av1_sequence_header_obu(640, 360); + let av1_frame_b = build_test_av1_sequence_header_obu(640, 360); + let video_input = write_test_av1_annex_b_file( + "mux-cli-raw-av1-annexb-input", + &[av1_frame_a.as_slice(), av1_frame_b.as_slice()], + ); + let output = write_temp_file("mux-cli-raw-av1-annexb-output", &[]); + let args = vec![ + "--track".to_string(), + video_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + [av1_frame_a, av1_frame_b].concat() + ); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_vp10_tracks() { + let frame_a = build_test_vp10_keyframe(640, 360, 0); + let frame_b = build_test_vp10_keyframe(640, 360, 0); + let video_input = write_test_vp10_ivf_file( + "mux-cli-raw-vp10-input", + 640, + 360, + &[0, 1], + &[frame_a.as_slice(), frame_b.as_slice()], + ); + let output = write_temp_file("mux-cli-raw-vp10-output", &[]); + let args = vec![ + "--track".to_string(), + video_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + [frame_a, frame_b].concat() + ); + + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("vp10"), + ]), + ); + let vpcc = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("vp10"), + fourcc("vpcC"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("vp10")); + assert_eq!(video_entries[0].width, 640); + assert_eq!(video_entries[0].height, 360); + assert_eq!(vpcc.len(), 1); + assert_eq!(vpcc[0].profile, 1); + assert_eq!(vpcc[0].level, 10); + assert_eq!(vpcc[0].bit_depth, 8); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_ac4_tracks() { + let audio_input = write_test_ac4_file("mux-cli-raw-ac4-input", 2); + let output = write_temp_file("mux-cli-raw-ac4-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ac-4"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ac-4")); + assert!(audio_entries[0].channel_count > 0); + assert_eq!(stts_boxes.len(), 1); + assert!(stts_boxes[0].timescale > 0); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_amr_tracks() { + let audio_input = write_test_amr_file("mux-cli-raw-amr-input", &[b"one", b"two"]); + let output = write_temp_file("mux-cli-raw-amr-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("samr"), + ]), + ); + let damr_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("samr"), + fourcc("damr"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("samr")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(damr_boxes.len(), 1); + assert_eq!(damr_boxes[0].vendor, 0); + assert_eq!(damr_boxes[0].frames_per_sample, 1); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 8_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_amr_wb_tracks() { + let audio_input = write_test_amr_wb_file("mux-cli-raw-amr-wb-input", &[b"wide", b"band"]); + let output = write_temp_file("mux-cli-raw-amr-wb-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("sawb"), + ]), + ); + let damr_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("sawb"), + fourcc("damr"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("sawb")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(damr_boxes.len(), 1); + assert_eq!(damr_boxes[0].vendor, 0); + assert_eq!(damr_boxes[0].frames_per_sample, 1); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 16_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_qcp_tracks() { + let audio_input = write_test_qcp_constant_file( + "mux-cli-raw-qcp-input", + TestQcpCodecKind::Qcelp, + &[&b"QCP1"[..], &b"QCP2"[..]], + ); + let output = write_temp_file("mux-cli-raw-qcp-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("sqcp"), + ]), + ); + let dqcp_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("sqcp"), + fourcc("dqcp"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("sqcp")); + assert_eq!(dqcp_boxes.len(), 1); + assert_eq!(dqcp_boxes[0].vendor, 0); + assert_eq!(dqcp_boxes[0].frames_per_sample, 1); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 8_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_mp3_tracks() { + let audio_input = write_test_mp3_file("mux-cli-raw-mp3-input", &[&b"abc"[..], &b"defg"[..]]); + let expected_payload = fs::read(&audio_input).unwrap(); + let output = write_temp_file("mux-cli-raw-mp3-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(".mp3"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc(".mp3")); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_latm_tracks() { + let audio_input = write_test_latm_file("mux-cli-raw-latm-input", &[b"abc", b"defg"]); + let output = write_temp_file("mux-cli-raw-latm-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"abcdefg"); + + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + fourcc("esds"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("mp4a")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(esds_boxes.len(), 1); + assert_eq!( + esds_boxes[0] + .decoder_config_descriptor() + .unwrap() + .object_type_indication, + 0x40 + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].name, "SoundHandler"); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_usac_latm_tracks() { + let audio_input = + write_test_usac_latm_file("mux-cli-raw-usac-latm-input", &[b"\x80abc", b"\x00defg"]); + let output = write_temp_file("mux-cli-raw-usac-latm-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"\x80abc\x00defg" + ); + + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + fourcc("esds"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("mp4a")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(esds_boxes.len(), 1); + assert_eq!(esds_boxes[0].decoder_specific_info().unwrap().len(), 3); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_truehd_tracks() { + let audio_input = + write_test_truehd_file("mux-cli-raw-truehd-input", &[b"abcdefgh", b"ijklmnop"]); + let expected_payload = fs::read(&audio_input).unwrap(); + let output = write_temp_file("mux-cli-raw-truehd-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mlpa"), + ]), + ); + let dmlp_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mlpa"), + fourcc("dmlp"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mlpa"), + fourcc("btrt"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("mlpa")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(audio_entries[0].sample_rate, 48_000); + assert_eq!(dmlp_boxes.len(), 1); + assert_eq!(dmlp_boxes[0].format_info, 0); + assert_eq!(dmlp_boxes[0].peak_data_rate, 0); + assert_eq!(btrt_boxes.len(), 1); + assert_eq!(btrt_boxes[0].buffer_size_db, 40); + assert_eq!(btrt_boxes[0].max_bitrate, 384_000); + assert_eq!(btrt_boxes[0].avg_bitrate, 384_000); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].name, "SoundHandler"); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_mhas_tracks() { + let audio_input = write_test_mhas_file("mux-cli-raw-mhas-input", &[b"frame-one", b"frame-two"]); + let expected_payload = fs::read(&audio_input).unwrap(); + let output = write_temp_file("mux-cli-raw-mhas-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mhm1"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("mhm1")); + assert_eq!(audio_entries[0].channel_count, 0); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_flac_tracks() { + let audio_input = write_test_flac_file("mux-cli-raw-flac-input", b"flac-frame"); + let output = write_temp_file("mux-cli-raw-flac-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let input_bytes = fs::read(&audio_input).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + &input_bytes[42..] + ); + + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("fLaC")); + assert_eq!(audio_entries[0].channel_count, 2); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_ogg_flac_tracks() { + let audio_input = write_test_ogg_flac_file("mux-cli-raw-ogg-flac-input", &[b"abc", b"def"]); + let output = write_temp_file("mux-cli-raw-ogg-flac-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("fLaC")); + assert_eq!(mdhd_boxes[0].timescale, 1_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_ogg_flac_mapping_tracks() { + let audio_input = + write_test_ogg_flac_mapping_file("mux-cli-raw-ogg-flac-mapping-input", &[b"abc", b"def"]); + let output = write_temp_file("mux-cli-raw-ogg-flac-mapping-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("fLaC")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(mdhd_boxes[0].timescale, 1_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_ogg_flac_split_header_tracks() { + let audio_input = write_test_ogg_flac_split_header_file( + "mux-cli-raw-ogg-flac-split-input", + &[b"abc", b"def"], + ); + let output = write_temp_file("mux-cli-raw-ogg-flac-split-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("fLaC")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(mdhd_boxes[0].timescale, 1_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_ogg_opus_tracks() { + let audio_input = write_test_ogg_opus_file("mux-cli-raw-opus-input", &[b"abc", b"def"]); + let output = write_temp_file("mux-cli-raw-opus-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"\0abc\0def"); + + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("Opus"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("Opus")); + assert_eq!(mdhd_boxes[0].timescale, 48_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_wave_pcm_tracks() { + let audio_input = write_test_wave_pcm_file( + "mux-cli-raw-wave-pcm-input", + &[[-1_000, 1_000], [2_000, -2_000]], + ); + let output = write_temp_file("mux-cli-raw-wave-pcm-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + let expected_payload = fs::read(&audio_input).unwrap()[44..].to_vec(); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ipcm")); + assert_eq!(audio_entries[0].channel_count, 2); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_aiff_pcm_tracks() { + let audio_input = write_test_aiff_pcm_file( + "mux-cli-raw-aiff-pcm-input", + &[[-1_000, 1_000], [2_000, -2_000]], + ); + let output = write_temp_file("mux-cli-raw-aiff-pcm-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + let expected_payload = vec![0xFC, 0x18, 0x03, 0xE8, 0x07, 0xD0, 0xF8, 0x30]; + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ipcm")); + assert_eq!(audio_entries[0].channel_count, 2); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_aifc_pcm_tracks() { + let audio_input = write_test_aifc_pcm_file( + "mux-cli-raw-aifc-pcm-input", + &[[-1_000, 1_000], [2_000, -2_000]], + ); + let output = write_temp_file("mux-cli-raw-aifc-pcm-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + let expected_payload = vec![0xFC, 0x18, 0x03, 0xE8, 0x07, 0xD0, 0xF8, 0x30]; + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ipcm")); + assert_eq!(audio_entries[0].channel_count, 2); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_ogg_vorbis_tracks() { + let audio_input = write_test_ogg_vorbis_file("mux-cli-raw-vorbis-input", &[b"abc", b"def"]); + let output = write_temp_file("mux-cli-raw-vorbis-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + fourcc("esds"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("mp4a")); + assert_eq!( + esds_boxes[0] + .decoder_config_descriptor() + .unwrap() + .object_type_indication, + 0xDD + ); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_ogg_speex_tracks() { + let audio_input = write_test_ogg_speex_file("mux-cli-raw-speex-input", &[b"abc", b"def"]); + let output = write_temp_file("mux-cli-raw-speex-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("spex"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("spex")); + assert_eq!(audio_entries[0].channel_count, 0); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_ogg_theora_tracks() { + let video_input = + write_test_ogg_theora_file("mux-cli-raw-theora-input", &[b"frame-a", b"frame-b"]); + let output = write_temp_file("mux-cli-raw-theora-output", &[]); + let args = vec![ + "--track".to_string(), + video_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + fourcc("esds"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("mp4v")); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 240); + assert_eq!( + esds_boxes[0] + .decoder_config_descriptor() + .unwrap() + .object_type_indication, + 0xDF + ); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_jpeg_tracks() { + let image_input = write_test_jpeg_file("mux-cli-raw-jpeg-input"); + let output = write_temp_file("mux-cli-raw-jpeg-output", &[]); + let args = vec![ + "--track".to_string(), + image_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let input_bytes = fs::read(&image_input).unwrap(); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), input_bytes); + + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("jpeg"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("jpeg")); + assert_eq!(video_entries[0].width, 1); + assert_eq!(video_entries[0].height, 1); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(video_entries[0].horizresolution, 72); + assert_eq!(video_entries[0].vertresolution, 72); + assert_eq!(mdhd_boxes[0].timescale, 1_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_h263_tracks() { + let video_input = write_test_h263_file("mux-cli-raw-h263-input", &[b"frame-a", b"frame-b"]); + let output = write_temp_file("mux-cli-raw-h263-output", &[]); + let args = vec![ + "--track".to_string(), + video_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let input_bytes = fs::read(&video_input).unwrap(); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), input_bytes); + + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("s263"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("s263")); + assert_eq!(video_entries[0].width, 176); + assert_eq!(video_entries[0].height, 144); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 1_200_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_png_tracks() { + let image_input = write_test_png_file("mux-cli-raw-png-input"); + let output = write_temp_file("mux-cli-raw-png-output", &[]); + let args = vec![ + "--track".to_string(), + image_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("png "), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("png ")); + assert_eq!(video_entries[0].width, 1); + assert_eq!(video_entries[0].height, 1); + assert_eq!(video_entries[0].horizresolution, 72); + assert_eq!(video_entries[0].vertresolution, 72); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 1_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_iamf_tracks() { + let audio_input = write_test_iamf_file("mux-cli-raw-iamf-input", &[b"frame-one", b"frame-two"]); + let output = write_temp_file("mux-cli-raw-iamf-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("iamf"), + ]), + ); + let iacb_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("iamf"), + fourcc("iacb"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("iamf")); + assert_eq!(audio_entries[0].channel_count, 0); + assert_eq!(audio_entries[0].sample_size, 0); + assert_eq!(audio_entries[0].sample_rate, 0); + assert_eq!(iacb_boxes.len(), 1); + assert_eq!(iacb_boxes[0].configuration_version, 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_caf_alac_tracks() { + let audio_input = write_test_caf_alac_file("mux-cli-raw-alac-input", &[b"ABCD", b"EFGH"]); + let output = write_temp_file("mux-cli-raw-alac-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"ABCDEFGH"); + + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("alac"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("alac")); + assert_eq!(audio_entries[0].channel_count, 2); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_variable_packet_caf_alac_tracks() { + let packet_a = vec![b'A'; 1_977]; + let packet_b = vec![b'B'; 254]; + let audio_input = write_test_caf_alac_variable_packet_file( + "mux-cli-raw-alac-variable-input", + &[packet_a.as_slice(), packet_b.as_slice()], + ); + let output = write_temp_file("mux-cli-raw-alac-variable-output", &[]); + let args = vec![ + "--track".to_string(), + audio_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + let payload = mdat_payload(&output_bytes, root_boxes[2]); + assert_eq!(payload.len(), packet_a.len() + packet_b.len()); + assert_eq!(&payload[..packet_a.len()], packet_a.as_slice()); + assert_eq!(&payload[packet_a.len()..], packet_b.as_slice()); + + let audio_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("alac"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("alac")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(mdhd_boxes[0].timescale, 44_100); +} + +#[test] +fn mux_command_writes_real_mp4_output_from_path_first_h265_tracks() { + let video_input = write_test_h265_annexb_file("mux-cli-raw-h265-input", &[b"hevc"]); + let output = write_temp_file("mux-cli-raw-h265-output", &[]); + let args = vec![ + "--track".to_string(), + video_input.display().to_string(), + output.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = mux::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + let output_bytes = fs::read(output).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + mp4forge::walk::BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("hvc1"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("hvc1")); + assert_eq!(video_entries[0].width, 1920); + assert_eq!(video_entries[0].height, 1080); +} + +#[test] +fn dispatch_routes_mux_command() { + let audio_input = build_audio_input_file("mux-dispatch-audio-input", fourcc("dash")); + let video_input = build_video_input_file("mux-dispatch-video-input", fourcc("isom")); + let output = write_temp_file("mux-dispatch-output", &[]); + let args = vec![ + "mux".to_string(), + "--track".to_string(), + format!("{}#audio", audio_input.display()), + "--track".to_string(), + format!("{}#video", video_input.display()), + output.to_string_lossy().into_owned(), + ]; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = cli::dispatch(&args, &mut stdout, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"audvideo"); +} + +fn build_audio_input_file(prefix: &str, major_brand: mp4forge::FourCc) -> TestTempPath { + write_single_track_mp4_input( + prefix, + &MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")), + MuxTrackConfig::new_audio(1, 1_000, audio_sample_entry_box()), + &[TestMuxSample { + bytes: b"aud", + duration: 4, + composition_time_offset: 0, + is_sync_sample: true, + }], + ) +} + +fn build_audio_input_file_with_type( + prefix: &str, + major_brand: mp4forge::FourCc, + sample_entry_type: &str, +) -> TestTempPath { + write_single_track_mp4_input( + prefix, + &MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")), + MuxTrackConfig::new_audio( + 1, + 1_000, + audio_sample_entry_box_with_type(sample_entry_type), + ), + &[TestMuxSample { + bytes: sample_entry_type.as_bytes(), + duration: 4, + composition_time_offset: 0, + is_sync_sample: true, + }], + ) +} + +fn build_video_input_file(prefix: &str, major_brand: mp4forge::FourCc) -> TestTempPath { + write_single_track_mp4_input( + prefix, + &MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")), + MuxTrackConfig::new_video(1, 1_000, 640, 360, video_sample_entry_box()), + &[TestMuxSample { + bytes: b"video", + duration: 4, + composition_time_offset: 0, + is_sync_sample: true, + }], + ) +} + +fn build_video_input_file_with_type( + prefix: &str, + major_brand: mp4forge::FourCc, + sample_entry_type: &str, +) -> TestTempPath { + write_single_track_mp4_input( + prefix, + &MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")), + MuxTrackConfig::new_video( + 1, + 1_000, + 640, + 360, + video_sample_entry_box_with_type(sample_entry_type), + ), + &[TestMuxSample { + bytes: sample_entry_type.as_bytes(), + duration: 4, + composition_time_offset: 0, + is_sync_sample: true, + }], + ) +} + +fn build_text_input_file(prefix: &str, major_brand: mp4forge::FourCc) -> TestTempPath { + write_single_track_mp4_input( + prefix, + &MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")), + MuxTrackConfig::new_text(1, 1_000, 0, 0, text_sample_entry_box()), + &[TestMuxSample { + bytes: b"wvtt", + duration: 4, + composition_time_offset: 0, + is_sync_sample: true, + }], + ) +} + +fn build_audio_input_file_with_metadata( + prefix: &str, + major_brand: mp4forge::FourCc, + sample_entry_type: &str, + language: [u8; 3], + handler_name: &str, + payload: &[u8], +) -> TestTempPath { + write_single_track_mp4_input( + prefix, + &MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")), + MuxTrackConfig::new_audio( + 1, + 1_000, + audio_sample_entry_box_with_type(sample_entry_type), + ) + .with_language(language) + .with_handler_name(handler_name), + &[TestMuxSample { + bytes: payload, + duration: 4, + composition_time_offset: 0, + is_sync_sample: true, + }], + ) +} + +fn build_video_input_file_with_metadata( + prefix: &str, + major_brand: mp4forge::FourCc, + sample_entry_type: &str, + language: [u8; 3], + handler_name: &str, + payload: &[u8], +) -> TestTempPath { + write_single_track_mp4_input( + prefix, + &MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")), + MuxTrackConfig::new_video( + 1, + 1_000, + 640, + 360, + video_sample_entry_box_with_type(sample_entry_type), + ) + .with_language(language) + .with_handler_name(handler_name), + &[TestMuxSample { + bytes: payload, + duration: 4, + composition_time_offset: 0, + is_sync_sample: true, + }], + ) +} + +fn build_mixed_text_input_file(prefix: &str, major_brand: mp4forge::FourCc) -> TestTempPath { + let first_source = write_temp_file(&format!("{prefix}-source-text"), b"wvtt"); + let second_source = write_temp_file(&format!("{prefix}-source-subtitle"), b"stpp"); + let output_path = write_temp_file(prefix, &[]); + let plan = mp4forge::mux::plan_staged_media_items( + vec![ + mp4forge::mux::MuxStagedMediaItem::new(0, 1, 0, 10, 0, 4).with_sync_sample(true), + mp4forge::mux::MuxStagedMediaItem::new(1, 2, 0, 10, 0, 4).with_sync_sample(true), + ], + mp4forge::mux::MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + let file_config = MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")); + let track_configs = vec![ + MuxTrackConfig::new_text(1, 1_000, 0, 0, text_sample_entry_box()) + .with_language(*b"eng") + .with_handler_name("EnglishCaptionHandler"), + MuxTrackConfig::new_subtitle(2, 1_000, 0, 0, subtitle_sample_entry_box()) + .with_language(*b"fra") + .with_handler_name("FrenchSubtitleHandler"), + ]; + + mp4forge::mux::write_mp4_mux_to_path( + &[&first_source, &second_source], + &output_path, + &file_config, + &track_configs, + &plan, + ) + .unwrap(); + output_path +} + +fn audio_sample_entry_box() -> Vec { + audio_sample_entry_box_with_type("mp4a") +} + +fn audio_sample_entry_box_with_type(box_type: &str) -> Vec { + encode_supported_box( + &AudioSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc(box_type), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000_u32 << 16, + ..AudioSampleEntry::default() + }, + &[], + ) +} + +fn video_sample_entry_box() -> Vec { + video_sample_entry_box_with_type("avc1") +} + +fn video_sample_entry_box_with_type(box_type: &str) -> Vec { + encode_supported_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc(box_type), + data_reference_index: 1, + }, + width: 640, + height: 360, + horizresolution: 72_u32 << 16, + vertresolution: 72_u32 << 16, + frame_count: 1, + depth: 0x0018, + pre_defined3: -1, + ..VisualSampleEntry::default() + }, + &[], + ) +} + +fn text_sample_entry_box() -> Vec { + let children = [ + encode_supported_box( + &WebVTTConfigurationBox { + config: "WEBVTT".to_string(), + }, + &[], + ), + encode_supported_box( + &WebVTTSourceLabelBox { + source_label: "source_label".to_string(), + }, + &[], + ), + ] + .concat(); + encode_supported_box( + &WVTTSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("wvtt"), + data_reference_index: 1, + }, + }, + &children, + ) +} + +fn subtitle_sample_entry_box() -> Vec { + encode_supported_box( + &XMLSubtitleSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("stpp"), + data_reference_index: 1, + }, + namespace: "http://www.w3.org/ns/ttml".to_string(), + schema_location: String::new(), + auxiliary_mime_types: String::new(), + }, + &[], + ) +} + +fn decode_mdhd_language(encoded: [u8; 3]) -> [u8; 3] { + [encoded[0] + b'`', encoded[1] + b'`', encoded[2] + b'`'] +} + +fn read_root_boxes(bytes: &[u8]) -> Vec { + let mut reader = Cursor::new(bytes); + let mut root_boxes = Vec::new(); + while usize::try_from(reader.position()) + .ok() + .is_some_and(|offset| offset < bytes.len()) + { + let info = BoxInfo::read(&mut reader).unwrap(); + info.seek_to_end(&mut reader).unwrap(); + root_boxes.push(info); + } + root_boxes +} + +fn mdat_payload(bytes: &[u8], mdat: BoxInfo) -> &[u8] { + let start = usize::try_from(mdat.offset() + mdat.header_size()).unwrap(); + let end = usize::try_from(mdat.offset() + mdat.size()).unwrap(); + &bytes[start..end] +} + +fn extract_boxes(bytes: &[u8], path: mp4forge::walk::BoxPath) -> Vec +where + T: mp4forge::codec::CodecBox + Clone + 'static, +{ + let mut reader = Cursor::new(bytes); + mp4forge::extract::extract_box_as::<_, T>(&mut reader, None, path).unwrap() +} diff --git a/tests/cli_probe.rs b/tests/cli_probe.rs index 8e2b0fe..9fe9bba 100644 --- a/tests/cli_probe.rs +++ b/tests/cli_probe.rs @@ -615,8 +615,6 @@ fn probe_command_reads_a_file_and_honors_the_yaml_flag() { let mut stderr = Vec::new(); let exit_code = probe::run(&args, &mut stdout, &mut stderr); - let _ = fs::remove_file(&path); - assert_eq!(exit_code, 0); assert_eq!(String::from_utf8(stderr).unwrap(), ""); assert_eq!( diff --git a/tests/cli_psshdump.rs b/tests/cli_psshdump.rs index 9c87a67..7c8965e 100644 --- a/tests/cli_psshdump.rs +++ b/tests/cli_psshdump.rs @@ -2,7 +2,6 @@ mod support; -use std::fs; use std::io::Cursor; use mp4forge::boxes::iso14496_12::{Ftyp, Moof, Moov}; @@ -190,8 +189,6 @@ fn psshdump_command_renders_offsets_flags_and_base64() { let mut stderr = Vec::new(); let exit_code = pssh::run(&args, &mut stdout, &mut stderr); - let _ = fs::remove_file(&path); - assert_eq!(exit_code, 0); assert_eq!(String::from_utf8(stderr).unwrap(), ""); assert_eq!( @@ -224,8 +221,6 @@ fn psshdump_command_filters_text_output_by_path() { let mut stderr = Vec::new(); let exit_code = pssh::run(&args, &mut stdout, &mut stderr); - let _ = fs::remove_file(&path); - let stdout = String::from_utf8(stdout).unwrap(); assert_eq!(exit_code, 0); assert_eq!(String::from_utf8(stderr).unwrap(), ""); @@ -286,8 +281,6 @@ fn psshdump_command_filters_structured_output_with_stable_goldens() { "golden mismatch for {golden}" ); } - - let _ = fs::remove_file(&path); } #[test] @@ -324,8 +317,6 @@ fn psshdump_command_rejects_invalid_filter_values() { assert_eq!(String::from_utf8(stdout).unwrap(), ""); assert_eq!(String::from_utf8(stderr).unwrap(), *expected_stderr); } - - let _ = fs::remove_file(&path); } #[test] @@ -367,8 +358,6 @@ fn psshdump_command_returns_empty_reports_for_empty_matches() { String::from_utf8(json_stdout).unwrap(), "{\n \"Entries\": [\n ]\n}\n" ); - - let _ = fs::remove_file(&path); } #[test] diff --git a/tests/decrypt_api.rs b/tests/decrypt_api.rs index 079d624..e776de6 100644 --- a/tests/decrypt_api.rs +++ b/tests/decrypt_api.rs @@ -7,7 +7,7 @@ use std::io::Cursor; use mp4forge::decrypt::{ DecryptError, DecryptOptions, DecryptProgress, DecryptProgressPhase, DecryptRewriteError, - DecryptionKey, DecryptionKeyId, decrypt_bytes, decrypt_bytes_with_progress, + DecryptionKey, DecryptionKeyId, decrypt_bytes, decrypt_bytes_with_progress, decrypt_file, decrypt_file_with_progress, }; use mp4forge::extract::extract_box_payload_bytes; @@ -181,6 +181,57 @@ fn decrypt_file_with_progress_writes_clear_output() { ); } +#[test] +fn decrypt_file_rejects_same_input_and_output_path_with_context() { + let fixture = build_decrypt_rewrite_fixture(); + let input_path = write_temp_file("decrypt-api-same-path-input", &fixture.single_file); + + let error = decrypt_file( + &input_path, + &input_path, + &options_with_keys(&fixture.all_keys), + ) + .unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains("invalid decrypt file arguments"), + "{message}" + ); + assert!(message.contains("conflicts with input"), "{message}"); +} + +#[test] +fn decrypt_errors_report_stable_category_and_stage_metadata() { + let missing_fragments = DecryptError::MissingFragmentsInfo; + assert_eq!(missing_fragments.category(), "input"); + assert_eq!(missing_fragments.stage(), "request"); + + let layout = DecryptError::Rewrite(DecryptRewriteError::InvalidLayout { + reason: "demo".to_string(), + }); + assert_eq!(layout.category(), "layout"); + assert_eq!(layout.stage(), "rewrite"); +} + +#[test] +fn decrypt_file_with_progress_reports_truncated_progressive_ranges_with_context() { + let fixture = build_decrypt_rewrite_fixture(); + let mut truncated = fixture.media_segment.clone(); + truncated.truncate(truncated.len().saturating_sub(1)); + let input_path = write_temp_file("decrypt-api-truncated-progressive-input", &truncated); + let output_path = write_temp_file("decrypt-api-truncated-progressive-output", &[]); + let options = + options_with_keys(&fixture.all_keys).with_fragments_info_bytes(&fixture.init_segment); + + let error = + decrypt_file_with_progress(&input_path, &output_path, &options, |_| {}).unwrap_err(); + let message = error.to_string(); + + assert!(message.contains("progressive"), "{message}"); + assert!(message.contains("buffered tail is"), "{message}"); +} + fn assert_retained_file_fixture_decrypts_bytes(fixture: &RetainedDecryptFileFixture) { let input = fs::read(&fixture.encrypted_path).unwrap(); let expected = fs::read(&fixture.decrypted_path).unwrap(); @@ -220,6 +271,28 @@ fn assert_retained_fragmented_fixture_decrypts_bytes(fixture: &RetainedFragmente assert_eq!(output, expected); } +fn assert_retained_fragmented_fixture_decrypts_with_progress( + fixture: &RetainedFragmentedDecryptFixture, + temp_prefix: &str, +) { + let segment = fs::read(&fixture.encrypted_segment_path).unwrap(); + let input_path = write_temp_file(temp_prefix, &segment); + let output_path = write_temp_file(&format!("{temp_prefix}-output"), &[]); + let expected = fs::read(&fixture.clear_segment_path).unwrap(); + let fragments_info = fs::read(&fixture.fragments_info_path).unwrap(); + let options = options_with_keys(&fixture.keys).with_fragments_info_bytes(fragments_info); + let mut progress = Vec::new(); + + decrypt_file_with_progress(&input_path, &output_path, &options, |snapshot| { + progress.push(snapshot); + }) + .unwrap(); + + let output = fs::read(&output_path).unwrap(); + assert_eq!(output, expected); + assert_eq!(phases(&progress), expected_file_fragment_progress_phases()); +} + fn assert_generated_topology_fixture_decrypts_bytes(fixture: ProtectedMovieTopologyFixture) { let output = decrypt_bytes(&fixture.encrypted, &options_with_keys(&fixture.keys)).unwrap(); assert_eq!(output, fixture.decrypted); @@ -508,6 +581,14 @@ fn decrypt_file_with_progress_supports_retained_common_encryption_multi_track_fi ); } +#[test] +fn decrypt_file_with_progress_supports_retained_cenc_single_video_media_segments() { + assert_retained_fragmented_fixture_decrypts_with_progress( + &common_encryption_fragment_fixture("cenc-single", "video"), + "decrypt-api-cenc-single-video-segment-input", + ); +} + #[test] fn decrypt_bytes_supports_multi_sample_entry_fragmented_tracks() { let fixture = build_multi_sample_entry_decrypt_fixture(); @@ -683,3 +764,20 @@ fn expected_file_progress_phases() -> Vec { DecryptProgressPhase::FinalizeOutput, ] } + +fn expected_file_fragment_progress_phases() -> Vec { + vec![ + DecryptProgressPhase::OpenInput, + DecryptProgressPhase::OpenInput, + DecryptProgressPhase::InspectStructure, + DecryptProgressPhase::OpenFragmentsInfo, + DecryptProgressPhase::OpenFragmentsInfo, + DecryptProgressPhase::InspectStructure, + DecryptProgressPhase::ProcessSamples, + DecryptProgressPhase::ProcessSamples, + DecryptProgressPhase::OpenOutput, + DecryptProgressPhase::OpenOutput, + DecryptProgressPhase::FinalizeOutput, + DecryptProgressPhase::FinalizeOutput, + ] +} diff --git a/tests/decrypt_async.rs b/tests/decrypt_async.rs index ae49aee..ab0460b 100644 --- a/tests/decrypt_async.rs +++ b/tests/decrypt_async.rs @@ -58,6 +58,40 @@ async fn async_decrypt_file_with_progress_matches_sync_output() { assert_eq!(phases(&async_progress), phases(&sync_progress)); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_with_progress_matches_sync_output_for_retained_media_segments() { + let fixture = common_encryption_fragment_fixture("cenc-single", "video"); + let fragments_info = fs::read(&fixture.fragments_info_path).unwrap(); + let options = options_with_keys(&fixture.keys).with_fragments_info_bytes(fragments_info); + let sync_output_path = write_temp_file("decrypt-async-retained-fragment-sync-output", &[]); + let async_output_path = write_temp_file("decrypt-async-retained-fragment-async-output", &[]); + + let mut sync_progress = Vec::new(); + decrypt_file_with_progress( + &fixture.encrypted_segment_path, + &sync_output_path, + &options, + |snapshot| sync_progress.push(snapshot), + ) + .unwrap(); + + let mut async_progress = Vec::new(); + decrypt_file_with_progress_async( + &fixture.encrypted_segment_path, + &async_output_path, + &options, + |snapshot| async_progress.push(snapshot), + ) + .await + .unwrap(); + + assert_eq!( + fs::read(sync_output_path).unwrap(), + fs::read(async_output_path).unwrap() + ); + assert_eq!(phases(&async_progress), phases(&sync_progress)); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn async_decrypt_helpers_can_run_on_tokio_worker_threads() { let fixture = build_decrypt_rewrite_fixture(); @@ -88,6 +122,48 @@ async fn async_decrypt_helpers_can_run_on_tokio_worker_threads() { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_rejects_same_input_and_output_path_with_context() { + let fixture = build_decrypt_rewrite_fixture(); + let input_path = write_temp_file("decrypt-async-same-path-input", &fixture.single_file); + + let error = decrypt_file_async( + &input_path, + &input_path, + &options_with_keys(&fixture.all_keys), + ) + .await + .unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains("invalid decrypt file arguments"), + "{message}" + ); + assert!(message.contains("conflicts with input"), "{message}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_reports_truncated_progressive_ranges_with_context() { + let fixture = build_decrypt_rewrite_fixture(); + let mut truncated = fixture.single_file.clone(); + truncated.truncate(truncated.len().saturating_sub(1)); + let input_path = write_temp_file("decrypt-async-truncated-progressive-input", &truncated); + let output_path = write_temp_file("decrypt-async-truncated-progressive-output", &[]); + + let error = decrypt_file_async( + &input_path, + &output_path, + &options_with_keys(&fixture.all_keys), + ) + .await + .unwrap_err(); + let message = error.to_string(); + + assert!(message.contains("progressive"), "{message}"); + assert!(message.contains("buffered tail is"), "{message}"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn async_decrypt_independent_file_tasks_can_run_concurrently_on_tokio_worker_threads() { let fixture = build_decrypt_rewrite_fixture(); @@ -219,6 +295,30 @@ async fn assert_retained_fragmented_fixture_decrypts_async( assert_eq!(output, expected); } +async fn assert_retained_fragmented_fixture_decrypts_async_with_progress( + fixture: &RetainedFragmentedDecryptFixture, + temp_prefix: &str, +) { + let output_path = write_temp_file(temp_prefix, &[]); + let expected = fs::read(&fixture.clear_segment_path).unwrap(); + let fragments_info = fs::read(&fixture.fragments_info_path).unwrap(); + let options = options_with_keys(&fixture.keys).with_fragments_info_bytes(fragments_info); + let mut progress = Vec::new(); + + decrypt_file_with_progress_async( + &fixture.encrypted_segment_path, + &output_path, + &options, + |snapshot| progress.push(snapshot), + ) + .await + .unwrap(); + + let output = fs::read(output_path).unwrap(); + assert_eq!(output, expected); + assert_eq!(phases(&progress), expected_file_fragment_progress_phases()); +} + async fn assert_generated_topology_fixture_decrypts_async( fixture: ProtectedMovieTopologyFixture, temp_prefix: &str, @@ -439,6 +539,15 @@ async fn async_decrypt_file_supports_retained_common_encryption_multi_track_file .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_with_progress_supports_retained_cenc_single_video_media_segments() { + assert_retained_fragmented_fixture_decrypts_async_with_progress( + &common_encryption_fragment_fixture("cenc-single", "video"), + "decrypt-async-cenc-single-video-segment-progress-output", + ) + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn async_decrypt_file_supports_multi_sample_entry_fragmented_tracks() { let fixture = build_multi_sample_entry_decrypt_fixture(); @@ -586,3 +695,20 @@ fn options_with_keys(keys: &[DecryptionKey]) -> DecryptOptions { fn phases(progress: &[DecryptProgress]) -> Vec { progress.iter().map(|snapshot| snapshot.phase).collect() } + +fn expected_file_fragment_progress_phases() -> Vec { + vec![ + DecryptProgressPhase::OpenInput, + DecryptProgressPhase::OpenInput, + DecryptProgressPhase::InspectStructure, + DecryptProgressPhase::OpenFragmentsInfo, + DecryptProgressPhase::OpenFragmentsInfo, + DecryptProgressPhase::InspectStructure, + DecryptProgressPhase::ProcessSamples, + DecryptProgressPhase::ProcessSamples, + DecryptProgressPhase::OpenOutput, + DecryptProgressPhase::OpenOutput, + DecryptProgressPhase::FinalizeOutput, + DecryptProgressPhase::FinalizeOutput, + ] +} diff --git a/tests/extract.rs b/tests/extract.rs index 70a06da..6259711 100644 --- a/tests/extract.rs +++ b/tests/extract.rs @@ -17,14 +17,15 @@ use mp4forge::boxes::oma_dcf::{ }; use mp4forge::codec::{CodecBox, marshal}; use mp4forge::extract::{ - ExtractError, extract_box, extract_box_as, extract_box_as_bytes, extract_box_bytes, - extract_box_payload_bytes, extract_box_with_payload, extract_boxes, extract_boxes_as_bytes, - extract_boxes_bytes, extract_boxes_payload_bytes, + ExtractError, copy_box_bytes_to, copy_box_payload_bytes_to, extract_box, extract_box_as, + extract_box_as_bytes, extract_box_bytes, extract_box_payload_bytes, extract_box_with_payload, + extract_boxes, extract_boxes_as_bytes, extract_boxes_bytes, extract_boxes_payload_bytes, }; #[cfg(feature = "async")] use mp4forge::extract::{ - extract_box_as_async, extract_box_async, extract_box_bytes_async, - extract_box_payload_bytes_async, extract_box_with_payload_async, extract_boxes_async, + copy_box_bytes_to_async, copy_box_payload_bytes_to_async, extract_box_as_async, + extract_box_async, extract_box_bytes_async, extract_box_payload_bytes_async, + extract_box_with_payload_async, extract_boxes_async, }; use mp4forge::stringify::stringify; use mp4forge::walk::BoxPath; @@ -35,9 +36,40 @@ mod support; #[cfg(feature = "decrypt")] use mp4forge::boxes::isma_cryp::{Ikms, Isfm, Islt}; #[cfg(feature = "async")] +use std::pin::Pin; +#[cfg(feature = "async")] +use std::task::{Context, Poll}; +#[cfg(feature = "async")] use support::build_visual_sample_entry_box_with_trailing_bytes; #[cfg(feature = "async")] use support::write_temp_file; +#[cfg(feature = "async")] +use tokio::io::AsyncWrite; + +#[cfg(feature = "async")] +struct VecAsyncWriter { + bytes: Vec, +} + +#[cfg(feature = "async")] +impl AsyncWrite for VecAsyncWriter { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + self.bytes.extend_from_slice(buf); + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} use support::{ build_encrypted_fragmented_video_file, build_event_message_movie_file, fixture_path, }; @@ -1141,6 +1173,35 @@ fn extract_box_payload_bytes_preserve_exact_container_payload_bytes() { assert_eq!(extracted, vec![leaf]); } +#[test] +fn copy_box_byte_surfaces_stream_exact_leaf_and_payload_bytes() { + let leaf = encode_raw_box(fourcc("zzzz"), &[0xde, 0xad, 0xbe, 0xef]); + let udta = encode_supported_box(&Udta, &leaf); + let moov = encode_supported_box(&Moov, &udta); + + let mut full_bytes = Vec::new(); + let full_lengths = copy_box_bytes_to( + &mut Cursor::new(moov.clone()), + None, + BoxPath::from([fourcc("moov"), fourcc("udta"), fourcc("zzzz")]), + &mut full_bytes, + ) + .unwrap(); + assert_eq!(full_lengths, vec![leaf.len() as u64]); + assert_eq!(full_bytes, leaf); + + let mut payload_bytes = Vec::new(); + let payload_lengths = copy_box_payload_bytes_to( + &mut Cursor::new(moov), + None, + BoxPath::from([fourcc("moov"), fourcc("udta")]), + &mut payload_bytes, + ) + .unwrap(); + assert_eq!(payload_lengths, vec![leaf.len() as u64]); + assert_eq!(payload_bytes, leaf); +} + #[cfg(feature = "async")] #[tokio::test] async fn async_extract_box_payload_bytes_preserve_exact_container_payload_bytes() { @@ -1159,6 +1220,38 @@ async fn async_extract_box_payload_bytes_preserve_exact_container_payload_bytes( assert_eq!(extracted, vec![leaf]); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_copy_box_byte_surfaces_stream_exact_leaf_and_payload_bytes() { + let leaf = encode_raw_box(fourcc("zzzz"), &[0xde, 0xad, 0xbe, 0xef]); + let udta = encode_supported_box(&Udta, &leaf); + let moov = encode_supported_box(&Moov, &udta); + + let mut full_writer = VecAsyncWriter { bytes: Vec::new() }; + let full_lengths = copy_box_bytes_to_async( + &mut Cursor::new(moov.clone()), + None, + BoxPath::from([fourcc("moov"), fourcc("udta"), fourcc("zzzz")]), + &mut full_writer, + ) + .await + .unwrap(); + assert_eq!(full_lengths, vec![leaf.len() as u64]); + assert_eq!(full_writer.bytes, leaf); + + let mut payload_writer = VecAsyncWriter { bytes: Vec::new() }; + let payload_lengths = copy_box_payload_bytes_to_async( + &mut Cursor::new(moov), + None, + BoxPath::from([fourcc("moov"), fourcc("udta")]), + &mut payload_writer, + ) + .await + .unwrap(); + assert_eq!(payload_lengths, vec![leaf.len() as u64]); + assert_eq!(payload_writer.bytes, leaf); +} + #[cfg(feature = "async")] #[tokio::test] async fn async_extract_box_bytes_descends_visual_sample_entry_children_without_trailing_bytes() { diff --git a/tests/fixtures/generated-1x1.jpg b/tests/fixtures/generated-1x1.jpg new file mode 100644 index 0000000..1cda9a5 Binary files /dev/null and b/tests/fixtures/generated-1x1.jpg differ diff --git a/tests/fixtures/mux/imported_avc_no_edit_list.mp4 b/tests/fixtures/mux/imported_avc_no_edit_list.mp4 new file mode 100644 index 0000000..8981306 Binary files /dev/null and b/tests/fixtures/mux/imported_avc_no_edit_list.mp4 differ diff --git a/tests/fixtures/mux/imported_hevc.mp4 b/tests/fixtures/mux/imported_hevc.mp4 new file mode 100644 index 0000000..7eb6b23 Binary files /dev/null and b/tests/fixtures/mux/imported_hevc.mp4 differ diff --git a/tests/fixtures/mux/imported_hevc_hdr10.mp4 b/tests/fixtures/mux/imported_hevc_hdr10.mp4 new file mode 100644 index 0000000..ac3a796 Binary files /dev/null and b/tests/fixtures/mux/imported_hevc_hdr10.mp4 differ diff --git a/tests/fixtures/mux/imported_mpegh_audio.mp4 b/tests/fixtures/mux/imported_mpegh_audio.mp4 new file mode 100644 index 0000000..271f59b Binary files /dev/null and b/tests/fixtures/mux/imported_mpegh_audio.mp4 differ diff --git a/tests/fixtures/mux/program_stream_video.mpeg b/tests/fixtures/mux/program_stream_video.mpeg new file mode 100644 index 0000000..4e51b5a Binary files /dev/null and b/tests/fixtures/mux/program_stream_video.mpeg differ diff --git a/tests/fixtures/mux/raw_h265_bframes.h265 b/tests/fixtures/mux/raw_h265_bframes.h265 new file mode 100644 index 0000000..623ae99 Binary files /dev/null and b/tests/fixtures/mux/raw_h265_bframes.h265 differ diff --git a/tests/fixtures/mux/raw_vvc_idr.vvc b/tests/fixtures/mux/raw_vvc_idr.vvc new file mode 100644 index 0000000..dcc406b Binary files /dev/null and b/tests/fixtures/mux/raw_vvc_idr.vvc differ diff --git a/tests/fixtures/mux/transport_h264.ts b/tests/fixtures/mux/transport_h264.ts new file mode 100644 index 0000000..728209a Binary files /dev/null and b/tests/fixtures/mux/transport_h264.ts differ diff --git a/tests/fixtures/mux/transport_h264_wrap_colr.ts b/tests/fixtures/mux/transport_h264_wrap_colr.ts new file mode 100644 index 0000000..8d633b5 Binary files /dev/null and b/tests/fixtures/mux/transport_h264_wrap_colr.ts differ diff --git a/tests/fixtures/mux/transport_hevc.ts b/tests/fixtures/mux/transport_hevc.ts new file mode 100644 index 0000000..14ad463 Binary files /dev/null and b/tests/fixtures/mux/transport_hevc.ts differ diff --git a/tests/golden/cli_divide/sample_fragmented/master.m3u8 b/tests/golden/cli_divide/sample_fragmented/master.m3u8 index e03e818..66884f4 100644 --- a/tests/golden/cli_divide/sample_fragmented/master.m3u8 +++ b/tests/golden/cli_divide/sample_fragmented/master.m3u8 @@ -1,4 +1,4 @@ #EXTM3U -#EXT-X-MEDIA:TYPE=AUDIO,URI="audio/playlist.m3u8",GROUP-ID="audio",NAME="audio",AUTOSELECT=YES,CHANNELS="2" +#EXT-X-MEDIA:TYPE=AUDIO,URI="audio/playlist.m3u8",GROUP-ID="audio",NAME="audio",AUTOSELECT=YES,LANGUAGE="eng",CHANNELS="2" #EXT-X-STREAM-INF:BANDWIDTH=28320,CODECS="avc1.4d401f,mp4a.40.2",RESOLUTION=1280x720,AUDIO="audio" video/playlist.m3u8 diff --git a/tests/golden/cli_dump/sample.json b/tests/golden/cli_dump/sample.json index 55700d6..ca96210 100644 --- a/tests/golden/cli_dump/sample.json +++ b/tests/golden/cli_dump/sample.json @@ -1660,17 +1660,11 @@ "ValueKind": "bytes", "Value": [ 3, - 128, - 128, - 128, 37, 0, 2, 0, 4, - 128, - 128, - 128, 23, 64, 21, @@ -1686,9 +1680,6 @@ 41, 74, 5, - 128, - 128, - 128, 5, 18, 16, @@ -1696,9 +1687,6 @@ 229, 0, 6, - 128, - 128, - 128, 1, 2 ], diff --git a/tests/golden/cli_dump/sample.yaml b/tests/golden/cli_dump/sample.yaml index 17f0cab..d82cb4a 100644 --- a/tests/golden/cli_dump/sample.yaml +++ b/tests/golden/cli_dump/sample.yaml @@ -1176,17 +1176,11 @@ boxes: value_kind: bytes value: - 3 - - 128 - - 128 - - 128 - 37 - 0 - 2 - 0 - 4 - - 128 - - 128 - - 128 - 23 - 64 - 21 @@ -1202,9 +1196,6 @@ boxes: - 41 - 74 - 5 - - 128 - - 128 - - 128 - 5 - 18 - 16 @@ -1212,9 +1203,6 @@ boxes: - 229 - 0 - 6 - - 128 - - 128 - - 128 - 1 - 2 display_value: '[{Tag=ESDescr Size=37 ESID=2 StreamDependenceFlag=false UrlFlag=false OcrStreamFlag=false StreamPriority=0}, {Tag=DecoderConfigDescr Size=23 ObjectTypeIndication=0x40 StreamType=5 UpStream=false Reserved=true BufferSizeDB=0 MaxBitrate=10570 AvgBitrate=10570}, {Tag=DecSpecificInfo Size=5 Data=[0x12, 0x10, 0x56, 0xe5, 0x0]}, {Tag=SLConfigDescr Size=1 Data=[0x2]}]' diff --git a/tests/inspect.rs b/tests/inspect.rs new file mode 100644 index 0000000..3e5b383 --- /dev/null +++ b/tests/inspect.rs @@ -0,0 +1,1166 @@ +#![cfg(feature = "mux")] + +mod support; + +use mp4forge::mux::inspect::{ + DirectIngestDetectedKind, DirectIngestPacketEntry, DirectIngestPacketReport, + DirectIngestReport, DirectIngestReportFormat, DirectIngestSampleReport, + DirectIngestSourceSegmentReport, DirectIngestStagedSourceReport, DirectIngestTrackReport, + collect_packet_report_warnings, collect_track_report_warnings, inspect_direct_ingest_packets, + inspect_direct_ingest_path, write_packet_report, write_report, +}; + +use support::{write_temp_file_with_extension, write_test_ogg_opus_file, write_test_vobsub_files}; + +fn sample_report( + source_index: usize, + data_offset: u64, + decode_time: u64, +) -> DirectIngestSampleReport { + DirectIngestSampleReport { + source_index, + data_offset, + data_size: 3, + decode_time, + previous_decode_delta: if decode_time == 0 { None } else { Some(960) }, + composition_time_offset: 0, + presentation_time: decode_time as i64, + presentation_end_time: decode_time as i64 + 960, + previous_presentation_delta: if decode_time == 0 { None } else { Some(960) }, + duration: 960, + is_sync_sample: true, + } +} + +fn packet_entry( + packet_index: usize, + data_offset: u64, + decode_time: u64, + previous_decode_delta: Option, + payload_crc32: u32, +) -> DirectIngestPacketEntry { + DirectIngestPacketEntry { + track_id: 1, + packet_index, + track_kind: "audio".to_string(), + timescale: 48_000, + sample_entry_type: "Opus".to_string(), + source_index: 0, + data_offset, + data_size: 3, + decode_time, + composition_time_offset: 0, + presentation_time: decode_time as i64, + presentation_end_time: decode_time as i64 + 960, + previous_presentation_delta: if packet_index == 0 { None } else { Some(960) }, + duration: 960, + previous_decode_delta, + payload_crc32, + is_sync_sample: true, + } +} + +fn crc32(bytes: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFF_u32; + for byte in bytes { + crc ^= u32::from(*byte); + for _ in 0..8 { + crc = if crc & 1 != 0 { + (crc >> 1) ^ 0xEDB8_8320 + } else { + crc >> 1 + }; + } + } + !crc +} + +fn example_track_report() -> DirectIngestTrackReport { + DirectIngestTrackReport { + track_id: 1, + kind: "audio".to_string(), + timescale: 48_000, + language: "und".to_string(), + handler_name: "SoundHandler".to_string(), + sample_entry_type: "Opus".to_string(), + sample_entry_box_hex: "000000104f7075730000000000000000".to_string(), + width: None, + height: None, + source_edit_media_time: Some(312), + sample_roll_distance: Some(3_840), + sample_count: 2, + sync_sample_count: 2, + starts_with_sync_sample: true, + total_duration: 1_920, + total_payload_size: 6, + average_sample_size: Some(3), + minimum_sample_size: Some(3), + maximum_sample_size: Some(3), + minimum_sample_duration: Some(960), + maximum_sample_duration: Some(960), + average_bitrate_bits_per_second: Some(1_200), + minimum_sync_sample_size: Some(3), + maximum_sync_sample_size: Some(3), + average_sync_sample_size: Some(3), + average_non_sync_sample_size: None, + minimum_composition_time_offset: Some(0), + maximum_composition_time_offset: Some(0), + minimum_presentation_time: Some(0), + maximum_presentation_end_time: Some(1_920), + minimum_previous_decode_delta: Some(960), + maximum_previous_decode_delta: Some(960), + minimum_previous_presentation_delta: Some(960), + maximum_previous_presentation_delta: Some(960), + presentation_gap_count: 0, + presentation_overlap_count: 0, + presentation_regression_count: 0, + duration_change_count: 0, + composition_time_offset_change_count: 0, + minimum_sync_sample_distance: Some(1), + maximum_sync_sample_distance: Some(1), + average_sync_sample_distance: Some(1), + minimum_sync_sample_decode_delta: Some(960), + maximum_sync_sample_decode_delta: Some(960), + average_sync_sample_decode_delta: Some(960), + first_sync_sample_index: Some(0), + last_sync_sample_index: Some(1), + first_sync_decode_time: Some(0), + last_sync_decode_time: Some(960), + first_sync_presentation_time: Some(0), + last_sync_presentation_time: Some(960), + first_decode_time: 0, + end_decode_time: 1_920, + samples: vec![sample_report(0, 8, 0), sample_report(0, 11, 960)], + } +} + +fn example_report() -> DirectIngestReport { + DirectIngestReport { + input_path: "input.ogg".into(), + detected_kind: DirectIngestDetectedKind::Raw { + codec: "opus".to_string(), + }, + supports_flat_mux: true, + note: None, + track_count: 1, + total_sample_count: 2, + total_sync_sample_count: 2, + total_payload_size: 6, + staged_sources: vec![DirectIngestStagedSourceReport { + source_index: 0, + path: "input.ogg".into(), + segmented: true, + total_size: 96, + segment_count: Some(3), + segments: Some(vec![ + DirectIngestSourceSegmentReport { + kind: "prefix".to_string(), + logical_offset: 0, + logical_size: 4, + source_offset: None, + source_path: None, + data_hex: Some("4f676753".to_string()), + }, + DirectIngestSourceSegmentReport { + kind: "file_range".to_string(), + logical_offset: 4, + logical_size: 88, + source_offset: Some(4), + source_path: None, + data_hex: None, + }, + DirectIngestSourceSegmentReport { + kind: "bytes".to_string(), + logical_offset: 92, + logical_size: 4, + source_offset: None, + source_path: None, + data_hex: Some("deadbeef".to_string()), + }, + ]), + }], + tracks: vec![example_track_report()], + } +} + +fn example_packet_report() -> DirectIngestPacketReport { + DirectIngestPacketReport { + input_path: "input.ogg".into(), + detected_kind: DirectIngestDetectedKind::Raw { + codec: "opus".to_string(), + }, + supports_flat_mux: true, + note: None, + track_count: 1, + packet_count: 2, + sync_packet_count: 2, + starts_with_sync_packet: true, + total_payload_size: 6, + minimum_packet_size: Some(3), + maximum_packet_size: Some(3), + minimum_sync_packet_size: Some(3), + maximum_sync_packet_size: Some(3), + average_sync_packet_size: Some(3), + average_non_sync_packet_size: None, + minimum_packet_duration: Some(960), + maximum_packet_duration: Some(960), + minimum_previous_decode_delta: Some(960), + maximum_previous_decode_delta: Some(960), + minimum_composition_time_offset: Some(0), + maximum_composition_time_offset: Some(0), + minimum_presentation_time: Some(0), + maximum_presentation_end_time: Some(1_920), + minimum_previous_presentation_delta: Some(960), + maximum_previous_presentation_delta: Some(960), + presentation_gap_count: 0, + presentation_overlap_count: 0, + presentation_regression_count: 0, + duration_change_count: 0, + composition_time_offset_change_count: 0, + minimum_sync_packet_distance: Some(1), + maximum_sync_packet_distance: Some(1), + average_sync_packet_distance: Some(1), + minimum_sync_packet_decode_delta: Some(960), + maximum_sync_packet_decode_delta: Some(960), + average_sync_packet_decode_delta: Some(960), + first_sync_packet_track_id: Some(1), + first_sync_packet_index: Some(0), + last_sync_packet_track_id: Some(1), + last_sync_packet_index: Some(1), + first_sync_decode_time: Some(0), + last_sync_decode_time: Some(960), + first_sync_presentation_time: Some(0), + last_sync_presentation_time: Some(960), + tracks: vec![example_track_report()], + staged_sources: vec![DirectIngestStagedSourceReport { + source_index: 0, + path: "input.ogg".into(), + segmented: true, + total_size: 96, + segment_count: Some(3), + segments: Some(vec![ + DirectIngestSourceSegmentReport { + kind: "prefix".to_string(), + logical_offset: 0, + logical_size: 4, + source_offset: None, + source_path: None, + data_hex: Some("4f676753".to_string()), + }, + DirectIngestSourceSegmentReport { + kind: "file_range".to_string(), + logical_offset: 4, + logical_size: 88, + source_offset: Some(4), + source_path: None, + data_hex: None, + }, + DirectIngestSourceSegmentReport { + kind: "bytes".to_string(), + logical_offset: 92, + logical_size: 4, + source_offset: None, + source_path: None, + data_hex: Some("deadbeef".to_string()), + }, + ]), + }], + packets: vec![ + packet_entry(0, 8, 0, None, crc32(b"abc")), + packet_entry(1, 11, 960, Some(960), crc32(b"def")), + ], + } +} + +#[test] +fn direct_ingest_warning_helpers_surface_track_level_timing_and_sync_issues() { + let mut report = example_report(); + let track = &mut report.tracks[0]; + track.starts_with_sync_sample = false; + track.sync_sample_count = 0; + track.presentation_gap_count = 2; + track.presentation_overlap_count = 1; + track.presentation_regression_count = 3; + track.duration_change_count = 4; + track.maximum_sample_duration = Some(1_920); + track.composition_time_offset_change_count = 5; + track.maximum_composition_time_offset = Some(33); + + let warnings = collect_track_report_warnings(&report); + + assert!( + warnings + .iter() + .any(|line| line.contains("does not start with a sync sample")) + ); + assert!( + warnings + .iter() + .any(|line| line.contains("has no sync samples")) + ); + assert!( + warnings + .iter() + .any(|line| line.contains("2 presentation gap(s)")) + ); + assert!( + warnings + .iter() + .any(|line| line.contains("1 presentation overlap(s)")) + ); + assert!( + warnings + .iter() + .any(|line| line.contains("3 presentation regression(s)")) + ); + assert!( + warnings + .iter() + .any(|line| line.contains("changes decode duration 4 time(s)")) + ); + assert!( + warnings + .iter() + .any(|line| line.contains("changes composition offset 5 time(s)")) + ); +} + +#[test] +fn direct_ingest_warning_helpers_surface_packet_level_timing_and_sync_issues() { + let mut report = example_packet_report(); + report.starts_with_sync_packet = false; + report.sync_packet_count = 0; + report.presentation_gap_count = 2; + report.presentation_overlap_count = 1; + report.presentation_regression_count = 3; + report.duration_change_count = 4; + report.maximum_packet_duration = Some(1_920); + report.composition_time_offset_change_count = 5; + report.maximum_composition_time_offset = Some(33); + + let warnings = collect_packet_report_warnings(&report); + + assert!( + warnings + .iter() + .any(|line| line.contains("does not start with a sync packet")) + ); + assert!( + warnings + .iter() + .any(|line| line.contains("has no sync packets")) + ); + assert!( + warnings + .iter() + .any(|line| line.contains("2 presentation gap(s)")) + ); + assert!( + warnings + .iter() + .any(|line| line.contains("1 presentation overlap(s)")) + ); + assert!( + warnings + .iter() + .any(|line| line.contains("3 presentation regression(s)")) + ); + assert!( + warnings + .iter() + .any(|line| line.contains("changes decode duration 4 time(s)")) + ); + assert!( + warnings + .iter() + .any(|line| line.contains("changes composition offset 5 time(s)")) + ); +} + +#[test] +fn direct_ingest_report_renders_json_yaml_and_nhml_with_stable_fields() { + let report = example_report(); + + let mut json = Vec::new(); + write_report(&mut json, &report, DirectIngestReportFormat::Json).unwrap(); + assert_eq!( + String::from_utf8(json).unwrap(), + concat!( + "{\n", + " \"InputPath\": \"input.ogg\",\n", + " \"DetectedKind\": {\n", + " \"Kind\": \"raw\",\n", + " \"Codec\": \"opus\"\n", + " },\n", + " \"SupportsFlatMux\": true,\n", + " \"TrackCount\": 1,\n", + " \"TotalSampleCount\": 2,\n", + " \"TotalSyncSampleCount\": 2,\n", + " \"TotalPayloadSize\": 6,\n", + " \"StagedSources\": [\n", + " {\n", + " \"SourceIndex\": 0,\n", + " \"Path\": \"input.ogg\",\n", + " \"Segmented\": true,\n", + " \"TotalSize\": 96,\n", + " \"SegmentCount\": 3,\n", + " \"Segments\": [\n", + " {\n", + " \"Kind\": \"prefix\",\n", + " \"LogicalOffset\": 0,\n", + " \"LogicalSize\": 4,\n", + " \"DataHex\": \"4f676753\"\n", + " },\n", + " {\n", + " \"Kind\": \"file_range\",\n", + " \"LogicalOffset\": 4,\n", + " \"LogicalSize\": 88,\n", + " \"SourceOffset\": 4\n", + " },\n", + " {\n", + " \"Kind\": \"bytes\",\n", + " \"LogicalOffset\": 92,\n", + " \"LogicalSize\": 4,\n", + " \"DataHex\": \"deadbeef\"\n", + " }\n", + " ]\n", + " }\n", + " ],\n", + " \"Tracks\": [\n", + " {\n", + " \"TrackID\": 1,\n", + " \"Kind\": \"audio\",\n", + " \"Timescale\": 48000,\n", + " \"Language\": \"und\",\n", + " \"HandlerName\": \"SoundHandler\",\n", + " \"SampleEntryType\": \"Opus\",\n", + " \"SampleEntryBoxHex\": \"000000104f7075730000000000000000\",\n", + " \"SampleCount\": 2,\n", + " \"SyncSampleCount\": 2,\n", + " \"StartsWithSyncSample\": true,\n", + " \"TotalDuration\": 1920,\n", + " \"TotalPayloadSize\": 6,\n", + " \"AverageSampleSize\": 3,\n", + " \"MinimumSampleSize\": 3,\n", + " \"MaximumSampleSize\": 3,\n", + " \"MinimumSampleDuration\": 960,\n", + " \"MaximumSampleDuration\": 960,\n", + " \"AverageBitrateBitsPerSecond\": 1200,\n", + " \"MinimumSyncSampleSize\": 3,\n", + " \"MaximumSyncSampleSize\": 3,\n", + " \"AverageSyncSampleSize\": 3,\n", + " \"AverageNonSyncSampleSize\": null,\n", + " \"MinimumCompositionTimeOffset\": 0,\n", + " \"MaximumCompositionTimeOffset\": 0,\n", + " \"MinimumPresentationTime\": 0,\n", + " \"MaximumPresentationEndTime\": 1920,\n", + " \"MinimumPreviousDecodeDelta\": 960,\n", + " \"MaximumPreviousDecodeDelta\": 960,\n", + " \"MinimumPreviousPresentationDelta\": 960,\n", + " \"MaximumPreviousPresentationDelta\": 960,\n", + " \"PresentationGapCount\": 0,\n", + " \"PresentationOverlapCount\": 0,\n", + " \"PresentationRegressionCount\": 0,\n", + " \"DurationChangeCount\": 0,\n", + " \"CompositionTimeOffsetChangeCount\": 0,\n", + " \"MinimumSyncSampleDistance\": 1,\n", + " \"MaximumSyncSampleDistance\": 1,\n", + " \"AverageSyncSampleDistance\": 1,\n", + " \"MinimumSyncSampleDecodeDelta\": 960,\n", + " \"MaximumSyncSampleDecodeDelta\": 960,\n", + " \"AverageSyncSampleDecodeDelta\": 960,\n", + " \"FirstSyncSampleIndex\": 0,\n", + " \"LastSyncSampleIndex\": 1,\n", + " \"FirstSyncDecodeTime\": 0,\n", + " \"LastSyncDecodeTime\": 960,\n", + " \"FirstSyncPresentationTime\": 0,\n", + " \"LastSyncPresentationTime\": 960,\n", + " \"FirstDecodeTime\": 0,\n", + " \"EndDecodeTime\": 1920,\n", + " \"SourceEditMediaTime\": 312,\n", + " \"SampleRollDistance\": 3840,\n", + " \"Samples\": [\n", + " {\n", + " \"SourceIndex\": 0,\n", + " \"DataOffset\": 8,\n", + " \"DataSize\": 3,\n", + " \"DecodeTime\": 0,\n", + " \"PreviousDecodeDelta\": null,\n", + " \"CompositionTimeOffset\": 0,\n", + " \"PresentationTime\": 0,\n", + " \"PresentationEndTime\": 960,\n", + " \"PreviousPresentationDelta\": null,\n", + " \"Duration\": 960,\n", + " \"IsSyncSample\": true\n", + " },\n", + " {\n", + " \"SourceIndex\": 0,\n", + " \"DataOffset\": 11,\n", + " \"DataSize\": 3,\n", + " \"DecodeTime\": 960,\n", + " \"PreviousDecodeDelta\": 960,\n", + " \"CompositionTimeOffset\": 0,\n", + " \"PresentationTime\": 960,\n", + " \"PresentationEndTime\": 1920,\n", + " \"PreviousPresentationDelta\": 960,\n", + " \"Duration\": 960,\n", + " \"IsSyncSample\": true\n", + " }\n", + " ]\n", + " }\n", + " ]\n", + "}\n" + ) + ); + + let mut yaml = Vec::new(); + write_report(&mut yaml, &report, DirectIngestReportFormat::Yaml).unwrap(); + assert_eq!( + String::from_utf8(yaml).unwrap(), + concat!( + "input_path: input.ogg\n", + "detected_kind:\n", + " kind: raw\n", + " codec: opus\n", + "supports_flat_mux: true\n", + "track_count: 1\n", + "total_sample_count: 2\n", + "total_sync_sample_count: 2\n", + "total_payload_size: 6\n", + "staged_sources:\n", + "- source_index: 0\n", + " path: input.ogg\n", + " segmented: true\n", + " total_size: 96\n", + " segment_count: 3\n", + " segments:\n", + " - kind: prefix\n", + " logical_offset: 0\n", + " logical_size: 4\n", + " source_offset: null\n", + " data_hex: 4f676753\n", + " - kind: file_range\n", + " logical_offset: 4\n", + " logical_size: 88\n", + " source_offset: 4\n", + " data_hex: null\n", + " - kind: bytes\n", + " logical_offset: 92\n", + " logical_size: 4\n", + " source_offset: null\n", + " data_hex: deadbeef\n", + "tracks:\n", + "- track_id: 1\n", + " kind: audio\n", + " timescale: 48000\n", + " language: und\n", + " handler_name: SoundHandler\n", + " sample_entry_type: Opus\n", + " sample_entry_box_hex: 000000104f7075730000000000000000\n", + " source_edit_media_time: 312\n", + " sample_roll_distance: 3840\n", + " sample_count: 2\n", + " sync_sample_count: 2\n", + " starts_with_sync_sample: true\n", + " total_duration: 1920\n", + " total_payload_size: 6\n", + " average_sample_size: 3\n", + " minimum_sample_size: 3\n", + " maximum_sample_size: 3\n", + " minimum_sample_duration: 960\n", + " maximum_sample_duration: 960\n", + " average_bitrate_bits_per_second: 1200\n", + " minimum_sync_sample_size: 3\n", + " maximum_sync_sample_size: 3\n", + " average_sync_sample_size: 3\n", + " average_non_sync_sample_size: null\n", + " minimum_composition_time_offset: 0\n", + " maximum_composition_time_offset: 0\n", + " minimum_presentation_time: 0\n", + " maximum_presentation_end_time: 1920\n", + " minimum_previous_decode_delta: 960\n", + " maximum_previous_decode_delta: 960\n", + " minimum_previous_presentation_delta: 960\n", + " maximum_previous_presentation_delta: 960\n", + " presentation_gap_count: 0\n", + " presentation_overlap_count: 0\n", + " presentation_regression_count: 0\n", + " duration_change_count: 0\n", + " composition_time_offset_change_count: 0\n", + " minimum_sync_sample_distance: 1\n", + " maximum_sync_sample_distance: 1\n", + " average_sync_sample_distance: 1\n", + " minimum_sync_sample_decode_delta: 960\n", + " maximum_sync_sample_decode_delta: 960\n", + " average_sync_sample_decode_delta: 960\n", + " first_sync_sample_index: 0\n", + " last_sync_sample_index: 1\n", + " first_sync_decode_time: 0\n", + " last_sync_decode_time: 960\n", + " first_sync_presentation_time: 0\n", + " last_sync_presentation_time: 960\n", + " first_decode_time: 0\n", + " end_decode_time: 1920\n", + " samples:\n", + " - source_index: 0\n", + " data_offset: 8\n", + " data_size: 3\n", + " decode_time: 0\n", + " previous_decode_delta: null\n", + " composition_time_offset: 0\n", + " presentation_time: 0\n", + " presentation_end_time: 960\n", + " previous_presentation_delta: null\n", + " duration: 960\n", + " is_sync_sample: true\n", + " - source_index: 0\n", + " data_offset: 11\n", + " data_size: 3\n", + " decode_time: 960\n", + " previous_decode_delta: 960\n", + " composition_time_offset: 0\n", + " presentation_time: 960\n", + " presentation_end_time: 1920\n", + " previous_presentation_delta: 960\n", + " duration: 960\n", + " is_sync_sample: true\n" + ) + ); + + let mut nhml = Vec::new(); + write_report(&mut nhml, &report, DirectIngestReportFormat::Nhml).unwrap(); + assert_eq!( + String::from_utf8(nhml).unwrap(), + concat!( + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ) + ); +} + +#[test] +fn direct_ingest_packet_report_renders_json_yaml_and_nhnt_with_stable_fields() { + let report = example_packet_report(); + + let mut json = Vec::new(); + write_packet_report(&mut json, &report, DirectIngestReportFormat::Json).unwrap(); + let expected_json = format!( + concat!( + "{{\n", + " \"InputPath\": \"input.ogg\",\n", + " \"DetectedKind\": {{\n", + " \"Kind\": \"raw\",\n", + " \"Codec\": \"opus\"\n", + " }},\n", + " \"SupportsFlatMux\": true,\n", + " \"TrackCount\": 1,\n", + " \"PacketCount\": 2,\n", + " \"SyncPacketCount\": 2,\n", + " \"StartsWithSyncPacket\": true,\n", + " \"TotalPayloadSize\": 6,\n", + " \"MinimumPacketSize\": 3,\n", + " \"MaximumPacketSize\": 3,\n", + " \"MinimumSyncPacketSize\": 3,\n", + " \"MaximumSyncPacketSize\": 3,\n", + " \"AverageSyncPacketSize\": 3,\n", + " \"MinimumPacketDuration\": 960,\n", + " \"MaximumPacketDuration\": 960,\n", + " \"MinimumPreviousDecodeDelta\": 960,\n", + " \"MaximumPreviousDecodeDelta\": 960,\n", + " \"MinimumCompositionTimeOffset\": 0,\n", + " \"MaximumCompositionTimeOffset\": 0,\n", + " \"MinimumPresentationTime\": 0,\n", + " \"MaximumPresentationEndTime\": 1920,\n", + " \"MinimumPreviousPresentationDelta\": 960,\n", + " \"MaximumPreviousPresentationDelta\": 960,\n", + " \"PresentationGapCount\": 0,\n", + " \"PresentationOverlapCount\": 0,\n", + " \"PresentationRegressionCount\": 0,\n", + " \"DurationChangeCount\": 0,\n", + " \"CompositionTimeOffsetChangeCount\": 0,\n", + " \"MinimumSyncPacketDistance\": 1,\n", + " \"MaximumSyncPacketDistance\": 1,\n", + " \"AverageSyncPacketDistance\": 1,\n", + " \"MinimumSyncPacketDecodeDelta\": 960,\n", + " \"MaximumSyncPacketDecodeDelta\": 960,\n", + " \"AverageSyncPacketDecodeDelta\": 960,\n", + " \"FirstSyncPacketTrackID\": 1,\n", + " \"FirstSyncPacketIndex\": 0,\n", + " \"LastSyncPacketTrackID\": 1,\n", + " \"LastSyncPacketIndex\": 1,\n", + " \"FirstSyncDecodeTime\": 0,\n", + " \"LastSyncDecodeTime\": 960,\n", + " \"FirstSyncPresentationTime\": 0,\n", + " \"LastSyncPresentationTime\": 960,\n", + " \"StagedSources\": [\n", + " {{\n", + " \"SourceIndex\": 0,\n", + " \"Path\": \"input.ogg\",\n", + " \"Segmented\": true,\n", + " \"TotalSize\": 96,\n", + " \"SegmentCount\": 3,\n", + " \"Segments\": [\n", + " {{\n", + " \"Kind\": \"prefix\",\n", + " \"LogicalOffset\": 0,\n", + " \"LogicalSize\": 4,\n", + " \"DataHex\": \"4f676753\"\n", + " }},\n", + " {{\n", + " \"Kind\": \"file_range\",\n", + " \"LogicalOffset\": 4,\n", + " \"LogicalSize\": 88,\n", + " \"SourceOffset\": 4\n", + " }},\n", + " {{\n", + " \"Kind\": \"bytes\",\n", + " \"LogicalOffset\": 92,\n", + " \"LogicalSize\": 4,\n", + " \"DataHex\": \"deadbeef\"\n", + " }}\n", + " ]\n", + " }}\n", + " ],\n", + " \"Packets\": [\n", + " {{\n", + " \"TrackID\": 1,\n", + " \"PacketIndex\": 0,\n", + " \"TrackKind\": \"audio\",\n", + " \"Timescale\": 48000,\n", + " \"SampleEntryType\": \"Opus\",\n", + " \"SourceIndex\": 0,\n", + " \"DataOffset\": 8,\n", + " \"DataSize\": 3,\n", + " \"DecodeTime\": 0,\n", + " \"CompositionTimeOffset\": 0,\n", + " \"PresentationTime\": 0,\n", + " \"PresentationEndTime\": 960,\n", + " \"PreviousPresentationDelta\": null,\n", + " \"Duration\": 960,\n", + " \"PreviousDecodeDelta\": null,\n", + " \"PayloadCrc32\": {},\n", + " \"IsSyncSample\": true\n", + " }},\n", + " {{\n", + " \"TrackID\": 1,\n", + " \"PacketIndex\": 1,\n", + " \"TrackKind\": \"audio\",\n", + " \"Timescale\": 48000,\n", + " \"SampleEntryType\": \"Opus\",\n", + " \"SourceIndex\": 0,\n", + " \"DataOffset\": 11,\n", + " \"DataSize\": 3,\n", + " \"DecodeTime\": 960,\n", + " \"CompositionTimeOffset\": 0,\n", + " \"PresentationTime\": 960,\n", + " \"PresentationEndTime\": 1920,\n", + " \"PreviousPresentationDelta\": 960,\n", + " \"Duration\": 960,\n", + " \"PreviousDecodeDelta\": 960,\n", + " \"PayloadCrc32\": {},\n", + " \"IsSyncSample\": true\n", + " }}\n", + " ]\n", + "}}\n" + ), + crc32(b"abc"), + crc32(b"def") + ); + assert_eq!(String::from_utf8(json).unwrap(), expected_json); + + let mut yaml = Vec::new(); + write_packet_report(&mut yaml, &report, DirectIngestReportFormat::Yaml).unwrap(); + let expected_yaml = format!( + concat!( + "input_path: input.ogg\n", + "detected_kind:\n", + " kind: raw\n", + " codec: opus\n", + "supports_flat_mux: true\n", + "track_count: 1\n", + "packet_count: 2\n", + "sync_packet_count: 2\n", + "starts_with_sync_packet: true\n", + "total_payload_size: 6\n", + "minimum_packet_size: 3\n", + "maximum_packet_size: 3\n", + "minimum_sync_packet_size: 3\n", + "maximum_sync_packet_size: 3\n", + "average_sync_packet_size: 3\n", + "minimum_packet_duration: 960\n", + "maximum_packet_duration: 960\n", + "minimum_previous_decode_delta: 960\n", + "maximum_previous_decode_delta: 960\n", + "minimum_composition_time_offset: 0\n", + "maximum_composition_time_offset: 0\n", + "minimum_presentation_time: 0\n", + "maximum_presentation_end_time: 1920\n", + "minimum_previous_presentation_delta: 960\n", + "maximum_previous_presentation_delta: 960\n", + "presentation_gap_count: 0\n", + "presentation_overlap_count: 0\n", + "presentation_regression_count: 0\n", + "duration_change_count: 0\n", + "composition_time_offset_change_count: 0\n", + "minimum_sync_packet_distance: 1\n", + "maximum_sync_packet_distance: 1\n", + "average_sync_packet_distance: 1\n", + "minimum_sync_packet_decode_delta: 960\n", + "maximum_sync_packet_decode_delta: 960\n", + "average_sync_packet_decode_delta: 960\n", + "first_sync_packet_track_id: 1\n", + "first_sync_packet_index: 0\n", + "last_sync_packet_track_id: 1\n", + "last_sync_packet_index: 1\n", + "first_sync_decode_time: 0\n", + "last_sync_decode_time: 960\n", + "first_sync_presentation_time: 0\n", + "last_sync_presentation_time: 960\n", + "staged_sources:\n", + "- source_index: 0\n", + " path: input.ogg\n", + " segmented: true\n", + " total_size: 96\n", + " segment_count: 3\n", + " segments:\n", + " - kind: prefix\n", + " logical_offset: 0\n", + " logical_size: 4\n", + " source_offset: null\n", + " data_hex: 4f676753\n", + " - kind: file_range\n", + " logical_offset: 4\n", + " logical_size: 88\n", + " source_offset: 4\n", + " data_hex: null\n", + " - kind: bytes\n", + " logical_offset: 92\n", + " logical_size: 4\n", + " source_offset: null\n", + " data_hex: deadbeef\n", + "packets:\n", + "- track_id: 1\n", + " packet_index: 0\n", + " track_kind: audio\n", + " timescale: 48000\n", + " sample_entry_type: Opus\n", + " source_index: 0\n", + " data_offset: 8\n", + " data_size: 3\n", + " decode_time: 0\n", + " composition_time_offset: 0\n", + " presentation_time: 0\n", + " presentation_end_time: 960\n", + " previous_presentation_delta: null\n", + " duration: 960\n", + " previous_decode_delta: null\n", + " payload_crc32: {}\n", + " is_sync_sample: true\n", + "- track_id: 1\n", + " packet_index: 1\n", + " track_kind: audio\n", + " timescale: 48000\n", + " sample_entry_type: Opus\n", + " source_index: 0\n", + " data_offset: 11\n", + " data_size: 3\n", + " decode_time: 960\n", + " composition_time_offset: 0\n", + " presentation_time: 960\n", + " presentation_end_time: 1920\n", + " previous_presentation_delta: 960\n", + " duration: 960\n", + " previous_decode_delta: 960\n", + " payload_crc32: {}\n", + " is_sync_sample: true\n" + ), + crc32(b"abc"), + crc32(b"def") + ); + assert_eq!(String::from_utf8(yaml).unwrap(), expected_yaml); + + let mut nhnt = Vec::new(); + write_packet_report(&mut nhnt, &report, DirectIngestReportFormat::Nhnt).unwrap(); + let expected_nhnt = format!( + concat!( + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + crc32(b"abc"), + crc32(b"def") + ); + assert_eq!(String::from_utf8(nhnt).unwrap(), expected_nhnt); +} + +#[test] +fn inspect_direct_ingest_path_reports_real_ogg_opus_tracks() { + let input = write_test_ogg_opus_file("inspect-ogg-opus-input", &[b"abc", b"def"]); + + let report = inspect_direct_ingest_path(&input).unwrap(); + + assert!(report.supports_flat_mux); + assert_eq!( + report.detected_kind, + DirectIngestDetectedKind::Raw { + codec: "opus".to_string() + } + ); + assert_eq!(report.track_count, 1); + assert_eq!(report.total_sample_count, 2); + assert_eq!(report.total_sync_sample_count, 2); + assert_eq!(report.total_payload_size, 8); + assert_eq!(report.staged_sources.len(), 1); + assert!(report.staged_sources[0].segmented); + assert_eq!( + report.staged_sources[0] + .segments + .as_ref() + .map(|segments| segments.len()), + report.staged_sources[0].segment_count + ); + assert!( + report.staged_sources[0] + .segments + .as_ref() + .is_some_and(|segments| !segments.is_empty()) + ); + assert_eq!(report.tracks.len(), 1); + assert_eq!(report.tracks[0].kind, "audio"); + assert_eq!(report.tracks[0].sample_entry_type, "Opus"); + assert!(!report.tracks[0].sample_entry_box_hex.is_empty()); + assert_eq!(report.tracks[0].sample_count, 2); + assert_eq!(report.tracks[0].sync_sample_count, 2); + assert!(report.tracks[0].starts_with_sync_sample); + assert_eq!(report.tracks[0].total_payload_size, 8); + assert_eq!(report.tracks[0].average_sample_size, Some(4)); + assert_eq!(report.tracks[0].minimum_sample_size, Some(4)); + assert_eq!(report.tracks[0].maximum_sample_size, Some(4)); + assert_eq!(report.tracks[0].minimum_sample_duration, Some(480)); + assert_eq!(report.tracks[0].maximum_sample_duration, Some(480)); + assert_eq!( + report.tracks[0].average_bitrate_bits_per_second, + Some(3_200) + ); + assert_eq!(report.tracks[0].minimum_sync_sample_size, Some(4)); + assert_eq!(report.tracks[0].maximum_sync_sample_size, Some(4)); + assert_eq!(report.tracks[0].average_sync_sample_size, Some(4)); + assert_eq!(report.tracks[0].average_non_sync_sample_size, None); + assert_eq!(report.tracks[0].minimum_composition_time_offset, Some(0)); + assert_eq!(report.tracks[0].maximum_composition_time_offset, Some(0)); + assert_eq!(report.tracks[0].minimum_presentation_time, Some(0)); + assert_eq!(report.tracks[0].maximum_presentation_end_time, Some(960)); + assert_eq!(report.tracks[0].minimum_previous_decode_delta, Some(480)); + assert_eq!(report.tracks[0].maximum_previous_decode_delta, Some(480)); + assert_eq!( + report.tracks[0].minimum_previous_presentation_delta, + Some(480) + ); + assert_eq!( + report.tracks[0].maximum_previous_presentation_delta, + Some(480) + ); + assert_eq!(report.tracks[0].presentation_gap_count, 0); + assert_eq!(report.tracks[0].presentation_overlap_count, 0); + assert_eq!(report.tracks[0].presentation_regression_count, 0); + assert_eq!(report.tracks[0].duration_change_count, 0); + assert_eq!(report.tracks[0].composition_time_offset_change_count, 0); + assert_eq!(report.tracks[0].minimum_sync_sample_distance, Some(1)); + assert_eq!(report.tracks[0].maximum_sync_sample_distance, Some(1)); + assert_eq!(report.tracks[0].average_sync_sample_distance, Some(1)); + assert_eq!(report.tracks[0].minimum_sync_sample_decode_delta, Some(480)); + assert_eq!(report.tracks[0].maximum_sync_sample_decode_delta, Some(480)); + assert_eq!(report.tracks[0].average_sync_sample_decode_delta, Some(480)); + assert_eq!(report.tracks[0].first_sync_sample_index, Some(0)); + assert_eq!(report.tracks[0].last_sync_sample_index, Some(1)); + assert_eq!(report.tracks[0].first_sync_decode_time, Some(0)); + assert_eq!(report.tracks[0].last_sync_decode_time, Some(480)); + assert_eq!(report.tracks[0].first_sync_presentation_time, Some(0)); + assert_eq!(report.tracks[0].last_sync_presentation_time, Some(480)); + assert_eq!(report.tracks[0].samples.len(), 2); + assert_eq!(report.tracks[0].samples[0].decode_time, 0); + assert_eq!(report.tracks[0].samples[1].decode_time, 480); + assert_eq!(report.tracks[0].samples[0].previous_decode_delta, None); + assert_eq!(report.tracks[0].samples[1].previous_decode_delta, Some(480)); + assert_eq!(report.tracks[0].samples[0].presentation_time, 0); + assert_eq!(report.tracks[0].samples[1].presentation_end_time, 960); + assert_eq!( + report.tracks[0].samples[0].previous_presentation_delta, + None + ); + assert_eq!( + report.tracks[0].samples[1].previous_presentation_delta, + Some(480) + ); +} + +#[test] +fn inspect_direct_ingest_path_round_trips_generated_nhml_sidecar() { + let input = write_test_ogg_opus_file("inspect-nhml-roundtrip", &[b"abc", b"def"]); + let report = inspect_direct_ingest_path(&input).unwrap(); + let mut rendered = Vec::new(); + write_report(&mut rendered, &report, DirectIngestReportFormat::Nhml).unwrap(); + let sidecar = write_temp_file_with_extension("inspect-nhml-roundtrip", "nhml", &rendered); + + let sidecar_report = inspect_direct_ingest_path(&sidecar).unwrap(); + assert_eq!( + sidecar_report.detected_kind, + DirectIngestDetectedKind::Container { + container: "nhml".to_string(), + } + ); + assert!(sidecar_report.supports_flat_mux); + assert_eq!(sidecar_report.staged_sources, report.staged_sources); + assert_eq!(sidecar_report.tracks, report.tracks); +} + +#[test] +fn inspect_direct_ingest_packets_flattens_real_ogg_opus_tracks() { + let input = write_test_ogg_opus_file("inspect-packets-ogg-opus-input", &[b"abc", b"def"]); + + let report = inspect_direct_ingest_packets(&input).unwrap(); + + assert!(report.supports_flat_mux); + assert_eq!(report.track_count, 1); + assert_eq!(report.packet_count, 2); + assert_eq!(report.packets[0].previous_decode_delta, None); + assert_eq!(report.packets[1].previous_decode_delta, Some(480)); + assert_ne!(report.packets[0].payload_crc32, 0); + assert_ne!(report.packets[1].payload_crc32, 0); + assert_eq!(report.sync_packet_count, 2); + assert!(report.starts_with_sync_packet); + assert_eq!(report.total_payload_size, 8); + assert_eq!(report.minimum_packet_size, Some(4)); + assert_eq!(report.maximum_packet_size, Some(4)); + assert_eq!(report.minimum_sync_packet_size, Some(4)); + assert_eq!(report.maximum_sync_packet_size, Some(4)); + assert_eq!(report.average_sync_packet_size, Some(4)); + assert_eq!(report.average_non_sync_packet_size, None); + assert_eq!(report.minimum_packet_duration, Some(480)); + assert_eq!(report.maximum_packet_duration, Some(480)); + assert_eq!(report.minimum_previous_decode_delta, Some(480)); + assert_eq!(report.maximum_previous_decode_delta, Some(480)); + assert_eq!(report.minimum_composition_time_offset, Some(0)); + assert_eq!(report.maximum_composition_time_offset, Some(0)); + assert_eq!(report.minimum_presentation_time, Some(0)); + assert_eq!(report.maximum_presentation_end_time, Some(960)); + assert_eq!(report.minimum_previous_presentation_delta, Some(480)); + assert_eq!(report.maximum_previous_presentation_delta, Some(480)); + assert_eq!(report.presentation_gap_count, 0); + assert_eq!(report.presentation_overlap_count, 0); + assert_eq!(report.presentation_regression_count, 0); + assert_eq!(report.duration_change_count, 0); + assert_eq!(report.composition_time_offset_change_count, 0); + assert_eq!(report.minimum_sync_packet_distance, Some(1)); + assert_eq!(report.maximum_sync_packet_distance, Some(1)); + assert_eq!(report.average_sync_packet_distance, Some(1)); + assert_eq!(report.minimum_sync_packet_decode_delta, Some(480)); + assert_eq!(report.maximum_sync_packet_decode_delta, Some(480)); + assert_eq!(report.average_sync_packet_decode_delta, Some(480)); + assert_eq!(report.first_sync_packet_track_id, Some(1)); + assert_eq!(report.first_sync_packet_index, Some(0)); + assert_eq!(report.last_sync_packet_track_id, Some(1)); + assert_eq!(report.last_sync_packet_index, Some(1)); + assert_eq!(report.first_sync_decode_time, Some(0)); + assert_eq!(report.last_sync_decode_time, Some(480)); + assert_eq!(report.first_sync_presentation_time, Some(0)); + assert_eq!(report.last_sync_presentation_time, Some(480)); + assert_eq!(report.staged_sources.len(), 1); + assert_eq!( + report.staged_sources[0] + .segments + .as_ref() + .map(|segments| segments.len()), + report.staged_sources[0].segment_count + ); + assert_eq!(report.packets.len(), 2); + assert_eq!(report.packets[0].track_kind, "audio"); + assert_eq!(report.packets[0].sample_entry_type, "Opus"); + assert_eq!(report.packets[0].packet_index, 0); + assert_eq!(report.packets[1].packet_index, 1); + assert_eq!(report.packets[0].decode_time, 0); + assert_eq!(report.packets[1].decode_time, 480); + assert_eq!(report.packets[0].presentation_time, 0); + assert_eq!(report.packets[1].presentation_end_time, 960); + assert_eq!(report.packets[0].previous_presentation_delta, None); + assert_eq!(report.packets[1].previous_presentation_delta, Some(480)); +} + +#[test] +fn inspect_direct_ingest_packets_round_trips_generated_nhnt_sidecar() { + let input = write_test_ogg_opus_file("inspect-nhnt-roundtrip", &[b"abc", b"def"]); + let report = inspect_direct_ingest_packets(&input).unwrap(); + let mut rendered = Vec::new(); + write_packet_report(&mut rendered, &report, DirectIngestReportFormat::Nhnt).unwrap(); + let sidecar = write_temp_file_with_extension("inspect-nhnt-roundtrip", "nhnt", &rendered); + + let sidecar_report = inspect_direct_ingest_packets(&sidecar).unwrap(); + assert_eq!( + sidecar_report.detected_kind, + DirectIngestDetectedKind::Container { + container: "nhnt".to_string(), + } + ); + assert!(sidecar_report.supports_flat_mux); + assert_eq!(sidecar_report.staged_sources, report.staged_sources); + assert_eq!(sidecar_report.tracks, report.tracks); + assert_eq!(sidecar_report.packets, report.packets); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn inspect_direct_ingest_path_async_matches_sync_for_real_ogg_opus_tracks() { + let input = write_test_ogg_opus_file("inspect-ogg-opus-async-input", &[b"abc", b"def"]); + + let sync_report = inspect_direct_ingest_path(&input).unwrap(); + let async_report = mp4forge::mux::inspect::inspect_direct_ingest_path_async(&input) + .await + .unwrap(); + + assert_eq!(async_report, sync_report); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn inspect_direct_ingest_packets_async_matches_sync_for_real_ogg_opus_tracks() { + let input = write_test_ogg_opus_file("inspect-packets-ogg-opus-async-input", &[b"abc", b"def"]); + + let sync_report = inspect_direct_ingest_packets(&input).unwrap(); + let async_report = mp4forge::mux::inspect::inspect_direct_ingest_packets_async(&input) + .await + .unwrap(); + + assert_eq!(async_report, sync_report); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn inspect_direct_ingest_path_async_matches_sync_for_vobsub_sidecars() { + let (_idx_input, sub_input) = + write_test_vobsub_files("inspect-vobsub-async-input", &[1_000], &[b"\xDE\xAD"]); + + let sync_report = inspect_direct_ingest_path(&sub_input).unwrap(); + let async_report = mp4forge::mux::inspect::inspect_direct_ingest_path_async(&sub_input) + .await + .unwrap(); + + assert_eq!(async_report, sync_report); +} diff --git a/tests/malformed_inputs.rs b/tests/malformed_inputs.rs index 63f9e48..f2d4c9c 100644 --- a/tests/malformed_inputs.rs +++ b/tests/malformed_inputs.rs @@ -5,7 +5,7 @@ use mp4forge::FourCc; use mp4forge::cli::edit::{EditError, EditOptions, edit_reader}; use mp4forge::codec::CodecError; use mp4forge::extract::{ExtractError, extract_box_with_payload}; -use mp4forge::header::{BoxInfo, HeaderError}; +use mp4forge::header::BoxInfo; use mp4forge::walk::{BoxPath, WalkControl, WalkError, walk_structure}; #[test] @@ -22,11 +22,7 @@ fn walk_structure_rejects_truncated_child_headers() { }) .unwrap_err(); - assert!(matches!( - error, - WalkError::Header(HeaderError::Io(ref io_error)) - if io_error.kind() == std::io::ErrorKind::UnexpectedEof - )); + assert!(matches!(error, WalkError::UnexpectedEof)); } #[test] @@ -45,11 +41,7 @@ fn walk_structure_rejects_huge_declared_supported_payloads_without_preallocating }) .unwrap_err(); - assert!(matches!( - error, - WalkError::Codec(CodecError::Io(ref io_error)) - if io_error.kind() == std::io::ErrorKind::UnexpectedEof - )); + assert!(matches!(error, WalkError::UnexpectedEof)); } #[test] diff --git a/tests/mux.rs b/tests/mux.rs new file mode 100644 index 0000000..1037080 --- /dev/null +++ b/tests/mux.rs @@ -0,0 +1,18657 @@ +#![cfg(feature = "mux")] + +mod support; + +use std::fs; +use std::io::Cursor; +use std::path::Path; +use std::str::FromStr; + +use mp4forge::BoxInfo; +use mp4forge::boxes::av1::AV1CodecConfiguration; +use mp4forge::boxes::avs3::Av3c; +use mp4forge::boxes::dolby::Dmlp; +use mp4forge::boxes::dts::{Ddts, Udts}; +use mp4forge::boxes::etsi_ts_102_366::Dec3; +use mp4forge::boxes::etsi_ts_103_190::Dac4; +use mp4forge::boxes::flac::{DfLa, FlacMetadataBlock}; +use mp4forge::boxes::iamf::Iacb; +use mp4forge::boxes::iso14496_12::{ + AVCDecoderConfiguration, AudioSampleEntry, Btrt, Chnl, Co64, Colr, Ctts, Dinf, Dref, DvsC, + Edts, Elst, ElstEntry, Emsg, Ftyp, GenericMediaSampleEntry, Hdlr, Mdhd, Mdia, Mehd, Meta, Minf, + Moov, Mvex, Mvhd, Nmhd, Pasp, Prft, SampleEntry, Sbgp, Sgpd, Sidx, Smhd, Stbl, Stco, Sthd, + Stsc, StscEntry, Stsd, Stss, Stsz, Stts, SttsEntry, Tfdt, Tfhd, Tkhd, Trak, Trex, Trun, Url, + VisualSampleEntry, Vmhd, XMLSubtitleSampleEntry, +}; +use mp4forge::boxes::iso14496_14::Esds; +use mp4forge::boxes::iso14496_14::Iods; +use mp4forge::boxes::iso14496_15::VVCDecoderConfiguration; +use mp4forge::boxes::iso14496_30::{WVTTSampleEntry, WebVTTConfigurationBox, WebVTTSourceLabelBox}; +use mp4forge::boxes::iso23001_5::PcmC; +use mp4forge::boxes::metadata::Id32; +use mp4forge::boxes::mpeg_h::MhaC; +use mp4forge::boxes::threegpp::{D263, Damr, Devc, Dqcp, Dsmv}; +use mp4forge::boxes::vp::VpCodecConfiguration; +use mp4forge::codec::{ImmutableBox, MutableBox}; +use mp4forge::extract::{extract_box_as, extract_box_bytes}; +use mp4forge::mux::inspect::{ + DirectIngestReportFormat, inspect_direct_ingest_packets, inspect_direct_ingest_path, + write_packet_report, write_report, +}; +use mp4forge::mux::{ + MuxDurationMode, MuxError, MuxFileConfig, MuxFragmentEventMessage, MuxInterleavePolicy, + MuxMp4TrackSelector, MuxOutputLayout, MuxProducerReferenceTime, MuxRawVideoParams, + MuxRawVideoPixelFormat, MuxRequest, MuxStagedMediaItem, MuxTrackConfig, MuxTrackKind, + MuxTrackSpec, copy_planned_payloads, copy_planned_payloads_async, + copy_planned_payloads_async_progressive, copy_planned_payloads_progressive, + copy_planned_payloads_to_path, copy_planned_payloads_to_path_async, mux_fragmented_to_paths, + mux_into_path, mux_to_path, plan_staged_media_items, + plan_staged_media_items_with_chunk_sample_counts, write_fragmented_mp4_mux_chunked, + write_fragmented_mp4_mux_segmented, write_fragmented_mp4_mux_split, write_mp4_mux, + write_mp4_mux_to_path, write_mp4_mux_to_path_async, +}; +#[cfg(feature = "async")] +use mp4forge::mux::{ + mux_fragmented_to_paths_async, mux_to_path_async, write_fragmented_mp4_mux_segmented_async, +}; +use mp4forge::probe::{TrackCodecDetails, probe_codec_detailed_bytes}; +use mp4forge::walk::BoxPath; +#[cfg(feature = "async")] +use tokio::io::AsyncWriteExt; + +use support::{ + TestAviAvc1Stream, TestAviH264Stream, TestAviMp4vStream, TestAviPcmStream, TestMuxSample, + TestQcpCodecKind, TestTempPath, build_test_ac4_sample_payload_bytes, + build_test_av1_sequence_header_obu, build_test_mp4v_decoder_specific_info, + build_test_mp4v_decoder_specific_info_with_vol_control, build_test_mpeg2v_bytes, + build_test_truehd_stream_bytes, build_test_vp8_keyframe, build_test_vp9_keyframe, + build_test_vp10_keyframe, encode_raw_box, encode_supported_box, fixture_path, fourcc, + temp_output_dir, write_single_track_mp4_input, write_temp_file, write_temp_file_with_extension, + write_test_ac3_44100_file, write_test_ac3_file, write_test_ac4_file, write_test_adts_file, + write_test_aifc_alaw_file, write_test_aifc_alaw_file_with_declared_bits, + write_test_aifc_float64_file, write_test_aifc_pcm_file, write_test_aifc_ulaw_file, + write_test_aifc_ulaw_file_with_declared_bits, write_test_aiff_pcm_file, write_test_amr_file, + write_test_amr_wb_file, write_test_av1_annex_b_file, write_test_av1_ivf_file, + write_test_av1_obu_file, write_test_avi_ac3_file, write_test_avi_alaw_file, + write_test_avi_audio_tag_file, write_test_avi_avc1_file, write_test_avi_extensible_alaw_file, + write_test_avi_extensible_float_file, write_test_avi_extensible_mulaw_file, + write_test_avi_extensible_pcm_file, write_test_avi_h263_file, write_test_avi_h264_file, + write_test_avi_jpeg_file, write_test_avi_mp3_file, write_test_avi_mp4v_file, + write_test_avi_mulaw_file, write_test_avi_pcm_file, write_test_avi_png_file, + write_test_avi_raw_bgr_file, write_test_avi_video_tag_file, write_test_caf_alac_file, + write_test_caf_alac_variable_packet_file, write_test_dts_14bit_big_endian_file, + write_test_dts_14bit_little_endian_file, write_test_dts_file, + write_test_dts_little_endian_file, write_test_eac3_file, + write_test_eac3_file_with_dependent_substream, write_test_flac_file, + write_test_flac_file_with_frames, write_test_flac_file_with_frames_and_block_size, + write_test_h263_file, write_test_h264_annexb_file, write_test_h265_annexb_file, + write_test_h265_annexb_file_with_timing, write_test_iamf_file, write_test_jpeg_file, + write_test_latm_file, write_test_mhas_file, write_test_mp3_44100_file, write_test_mp3_file, + write_test_mp3_file_with_leading_id3_tag, write_test_mp4v_file, write_test_mpeg2v_file, + write_test_ogg_flac_file, write_test_ogg_flac_mapping_file, + write_test_ogg_flac_split_header_file, write_test_ogg_opus_file, write_test_ogg_speex_file, + write_test_ogg_theora_file, write_test_ogg_vorbis_file, write_test_png_file, + write_test_program_stream_ac3_file, write_test_program_stream_h264_file, + write_test_program_stream_h264_open_ended_file, write_test_program_stream_h265_file, + write_test_program_stream_lpcm_file, write_test_program_stream_mp2_file, + write_test_program_stream_mp3_file, write_test_program_stream_mp4v_file, + write_test_program_stream_mpeg2v_file, write_test_program_stream_mpeg2v_pts_dts_file, + write_test_program_stream_vobsub_file, write_test_program_stream_vvc_file, + write_test_qcp_constant_file, write_test_qcp_variable_file, write_test_saf_aac_file, + write_test_saf_scene_plus_mp4v_file, write_test_transport_stream_ac3_file, + write_test_transport_stream_ac4_file, write_test_transport_stream_av1_file, + write_test_transport_stream_avs3_file, write_test_transport_stream_dts_file, + write_test_transport_stream_dts_stream_type_file, + write_test_transport_stream_dvb_subtitle_file, write_test_transport_stream_dvb_teletext_file, + write_test_transport_stream_eac3_file, write_test_transport_stream_h264_file, + write_test_transport_stream_h265_file, write_test_transport_stream_latm_file, + write_test_transport_stream_latm_other_data_file, write_test_transport_stream_mhas_file, + write_test_transport_stream_mp3_file, write_test_transport_stream_mp4v_file, + write_test_transport_stream_mpeg2v_file, write_test_transport_stream_multi_program_mp3_file, + write_test_transport_stream_truehd_file, write_test_transport_stream_vvc_file, + write_test_truehd_file, write_test_usac_latm_file, write_test_vobsub_files, + write_test_vp8_ivf_file, write_test_vp9_ivf_file, write_test_vp10_ivf_file, + write_test_wave_pcm_file, write_test_wrapped_dts_file, write_test_wrapped_dts_file_with_tail, +}; + +fn corrupt_mpeg2ts_section_crc(input: &Path, target_pid: u16, prefix: &str) -> TestTempPath { + let mut bytes = fs::read(input).unwrap(); + for packet in bytes.chunks_mut(188) { + if packet.first().copied() != Some(0x47) { + continue; + } + let pid = (u16::from(packet[1] & 0x1F) << 8) | u16::from(packet[2]); + if pid != target_pid { + continue; + } + let adaptation_control = (packet[3] >> 4) & 0x03; + if adaptation_control == 0 || adaptation_control == 0x02 { + continue; + } + let mut payload_offset = 4usize; + if adaptation_control == 0x03 { + let adaptation_length = usize::from(packet[4]); + payload_offset += 1 + adaptation_length; + } + if payload_offset >= packet.len() { + continue; + } + let payload = &mut packet[payload_offset..]; + if payload.is_empty() { + continue; + } + let pointer_field = usize::from(payload[0]); + let start = 1 + pointer_field; + if payload.len() < start + 8 { + continue; + } + let section_length = + usize::from(u16::from_be_bytes([payload[start + 1], payload[start + 2]]) & 0x0FFF); + let section_end = start + 3 + section_length; + if payload.len() < section_end { + continue; + } + let crc_offset = section_end - 4; + payload[crc_offset] ^= 0xFF; + return write_temp_file(prefix, &bytes); + } + panic!("target MPEG-TS section PID {target_pid:#06x} not found"); +} + +fn write_multi_sample_vvc_annex_b_input(prefix: &str) -> TestTempPath { + let mut bytes = fs::read(fixture_path("mux/raw_vvc_idr.vvc")).unwrap(); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0x00, 20 << 3]); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0x00, 8 << 3, 0x01]); + write_temp_file(prefix, &bytes) +} + +fn decode_alaw_pcm_sample(value: u8) -> i16 { + let value = value ^ 0x55; + let mut sample = i16::from(value & 0x0F) << 4; + let segment = i16::from((value & 0x70) >> 4); + sample += 8; + if segment != 0 { + sample += 0x100; + } + if segment > 1 { + sample <<= u32::try_from(segment - 1).unwrap(); + } + if value & 0x80 == 0 { -sample } else { sample } +} + +fn decode_ulaw_pcm_sample(value: u8) -> i16 { + let value = !value; + let mut sample = (i16::from(value & 0x0F) << 3) + 0x84; + sample <<= u32::from((value & 0x70) >> 4); + if value & 0x80 != 0 { + 0x84 - sample + } else { + sample - 0x84 + } +} + +fn decode_companded_pcm_payload(bytes: &[u8], decode: F) -> Vec +where + F: Fn(u8) -> i16, +{ + let mut decoded = Vec::with_capacity(bytes.len().saturating_mul(2)); + for &value in bytes { + decoded.extend_from_slice(&decode(value).to_le_bytes()); + } + decoded +} + +#[derive(Default)] +struct FlushCountingWriter { + bytes: Vec, + flush_count: usize, +} + +impl std::io::Write for FlushCountingWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.bytes.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.flush_count += 1; + Ok(()) + } +} + +fn assert_mp4_files_match_ignoring_time_fields(left_path: L, right_path: R) +where + L: AsRef, + R: AsRef, +{ + let mut left = fs::read(left_path).unwrap(); + let mut right = fs::read(right_path).unwrap(); + if left == right { + return; + } + normalize_mp4_time_fields(&mut left); + normalize_mp4_time_fields(&mut right); + assert_eq!( + left, right, + "MP4 outputs differ after normalizing volatile time fields" + ); +} + +fn normalize_mp4_time_fields(bytes: &mut [u8]) { + normalize_mp4_time_fields_in_range(bytes, 0, bytes.len()); +} + +fn normalize_mp4_time_fields_in_range(bytes: &mut [u8], start: usize, end: usize) { + let mut offset = start; + while offset + 8 <= end { + let size = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize; + let box_type = [ + bytes[offset + 4], + bytes[offset + 5], + bytes[offset + 6], + bytes[offset + 7], + ]; + let (header_size, box_end) = if size == 1 { + if offset + 16 > end { + break; + } + let large_size = u64::from_be_bytes(bytes[offset + 8..offset + 16].try_into().unwrap()); + let Ok(large_size) = usize::try_from(large_size) else { + break; + }; + if large_size < 16 { + break; + } + (16, offset.saturating_add(large_size)) + } else if size == 0 { + (8, end) + } else { + if size < 8 { + break; + } + (8, offset.saturating_add(size)) + }; + if box_end > end || box_end <= offset { + break; + } + let payload_start = offset + header_size; + match &box_type { + b"mvhd" | b"tkhd" | b"mdhd" => { + normalize_full_box_creation_modification_times(bytes, payload_start, box_end); + } + b"moov" | b"trak" | b"mdia" | b"minf" | b"stbl" | b"dinf" | b"edts" | b"udta" => { + normalize_mp4_time_fields_in_range(bytes, payload_start, box_end); + } + b"meta" => { + let child_start = payload_start.saturating_add(4); + if child_start <= box_end { + normalize_mp4_time_fields_in_range(bytes, child_start, box_end); + } + } + _ => {} + } + offset = box_end; + } +} + +fn normalize_full_box_creation_modification_times( + bytes: &mut [u8], + payload_start: usize, + end: usize, +) { + let Some(&version) = bytes.get(payload_start) else { + return; + }; + let time_range = if version == 1 { + payload_start + 4..payload_start + 20 + } else { + payload_start + 4..payload_start + 12 + }; + if time_range.end <= end && time_range.end <= bytes.len() { + bytes[time_range].fill(0); + } +} + +#[test] +fn mux_plan_orders_items_by_decode_time_and_assigns_output_offsets() { + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 2, 10, 4, 20, 3), + MuxStagedMediaItem::new(1, 1, 0, 5, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 2, 0, 4, 12, 2).with_composition_time_offset(2), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + assert_eq!(plan.total_payload_size(), 9); + assert_eq!(plan.track_plans().len(), 2); + assert_eq!( + plan.planned_items() + .iter() + .map(|item| ( + item.staged().track_id(), + item.staged().source_index(), + item.staged().decode_time(), + item.decode_end_time(), + item.output_offset(), + item.output_end_offset(), + item.staged().composition_time_offset(), + item.staged().is_sync_sample(), + )) + .collect::>(), + vec![ + (2, 0, 0, 4, 0, 2, 2, false), + (1, 1, 0, 5, 2, 6, 0, true), + (2, 0, 10, 14, 6, 9, 0, false) + ] + ); + assert_eq!( + plan.track_plans() + .iter() + .map(|track| ( + track.track_id(), + track.item_count(), + track.first_decode_time(), + track.end_decode_time(), + )) + .collect::>(), + vec![(1, 1, 0, 5), (2, 2, 0, 14)] + ); +} + +#[test] +fn mux_track_spec_from_str_accepts_the_path_first_public_grammar() { + assert_eq!( + MuxTrackSpec::from_str("path/to/video.h264").unwrap(), + MuxTrackSpec::path("path/to/video.h264") + ); + assert_eq!( + MuxTrackSpec::from_str("path/to/audio.aac").unwrap(), + MuxTrackSpec::path("path/to/audio.aac") + ); + assert_eq!( + MuxTrackSpec::from_str("path/to/file.mp4#video").unwrap(), + MuxTrackSpec::selected("path/to/file.mp4", MuxMp4TrackSelector::Video) + ); + assert_eq!( + MuxTrackSpec::from_str("path/to/file.mp4#audio").unwrap(), + MuxTrackSpec::selected( + "path/to/file.mp4", + MuxMp4TrackSelector::Audio { occurrence: 1 } + ) + ); + assert_eq!( + MuxTrackSpec::from_str("path/to/file.mp4#audio:2").unwrap(), + MuxTrackSpec::selected( + "path/to/file.mp4", + MuxMp4TrackSelector::Audio { occurrence: 2 } + ) + ); + assert_eq!( + MuxTrackSpec::from_str("path/to/file.mp4#text").unwrap(), + MuxTrackSpec::selected( + "path/to/file.mp4", + MuxMp4TrackSelector::Text { occurrence: 1 } + ) + ); + assert_eq!( + MuxTrackSpec::from_str("path/to/file.mp4#track:7").unwrap(), + MuxTrackSpec::selected( + "path/to/file.mp4", + MuxMp4TrackSelector::TrackId { track_id: 7 } + ) + ); + assert_eq!( + MuxTrackSpec::from_str("path/to/video.raw#rawvideo:size=2x2,spfmt=yuv420,fps=25/1") + .unwrap(), + MuxTrackSpec::raw_video( + "path/to/video.raw", + MuxRawVideoParams::new(2, 2, MuxRawVideoPixelFormat::Yuv420p8, 25, 1).unwrap() + ) + ); + assert_eq!( + MuxTrackSpec::from_str("path/to/video.raw#rawvideo:size=4x4,spfmt=rgb,fps=30000/1001") + .unwrap(), + MuxTrackSpec::raw_video( + "path/to/video.raw", + MuxRawVideoParams::new(4, 4, MuxRawVideoPixelFormat::Rgb24, 30_000, 1_001).unwrap() + ) + ); + assert_eq!( + MuxTrackSpec::from_str("path/to/video.raw#rawvideo:size=2x2,spfmt=yp4l,fps=25/1").unwrap(), + MuxTrackSpec::raw_video( + "path/to/video.raw", + MuxRawVideoParams::new(2, 2, MuxRawVideoPixelFormat::Yuv444p10, 25, 1).unwrap() + ) + ); + assert_eq!( + MuxTrackSpec::from_str("path/to/video.raw#rawvideo:size=2x2,spfmt=nv1l,fps=25/1").unwrap(), + MuxTrackSpec::raw_video( + "path/to/video.raw", + MuxRawVideoParams::new(2, 2, MuxRawVideoPixelFormat::Nv12p10, 25, 1).unwrap() + ) + ); + assert_eq!( + MuxTrackSpec::from_str("path/to/video.raw#rawvideo:size=48x2,spfmt=v210,fps=25/1").unwrap(), + MuxTrackSpec::raw_video( + "path/to/video.raw", + MuxRawVideoParams::new(48, 2, MuxRawVideoPixelFormat::V210, 25, 1).unwrap() + ) + ); + assert_eq!( + MuxTrackSpec::from_str("path/to/video.raw#rawvideo:size=2x2,spfmt=bgra,fps=25/1").unwrap(), + MuxTrackSpec::raw_video( + "path/to/video.raw", + MuxRawVideoParams::new(2, 2, MuxRawVideoPixelFormat::Bgra32, 25, 1).unwrap() + ) + ); +} + +#[test] +fn mux_track_spec_from_str_rejects_public_parameter_suffixes() { + let error = MuxTrackSpec::from_str("path/to/video.h265#sample_entry=hvc1").unwrap_err(); + assert!(matches!(error, MuxError::InvalidTrackSpec { .. })); + assert!( + error + .to_string() + .contains("public mux track specs only allow selector suffixes"), + "{error}" + ); +} + +#[test] +fn mux_track_spec_from_str_rejects_incomplete_rawvideo_parameters() { + let error = + MuxTrackSpec::from_str("path/to/video.raw#rawvideo:spfmt=yuv420,fps=25/1").unwrap_err(); + assert!(matches!(error, MuxError::InvalidTrackSpec { .. })); + assert!( + error + .to_string() + .contains("must declare `size=WIDTHxHEIGHT`") + ); +} + +#[test] +fn mux_to_path_imports_path_only_raw_dts_inputs() { + let dts_input = write_test_dts_file("mux-raw-dts-input", 2); + let expected_payload = fs::read(&dts_input).unwrap(); + let output_path = write_temp_file("mux-raw-dts-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&dts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + assert_raw_dts_mux_output_matches_payload( + &output_path, + &expected_payload, + fourcc("dtsc"), + 48_000 << 16, + 2_048, + 768_000, + 2, + ); +} + +#[test] +fn mux_to_path_imports_path_only_little_endian_raw_dts_inputs() { + let dts_input = write_test_dts_little_endian_file("mux-raw-dts-le-input", 2); + let expected_payload = fs::read(&dts_input).unwrap(); + let output_path = write_temp_file("mux-raw-dts-le-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&dts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + assert_raw_dts_mux_output_matches_payload( + &output_path, + &expected_payload, + fourcc("dtsc"), + 48_000 << 16, + 2_048, + 768_000, + 2, + ); +} + +#[test] +fn mux_to_path_imports_path_only_wrapped_core_dts_inputs() { + let dts_input = write_test_wrapped_dts_file("mux-raw-dts-wrapped-input", 2); + let expected_payload = fs::read(&dts_input).unwrap(); + let output_path = write_temp_file("mux-raw-dts-wrapped-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&dts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + assert_raw_dts_mux_output_matches_payload( + &output_path, + &expected_payload, + fourcc("dtsx"), + 0, + 2_056, + 769_496, + 2, + ); +} + +#[test] +fn mux_to_path_imports_path_only_wrapped_core_dts_inputs_with_trailing_family_tail() { + let expected_payload = { + let input = write_test_wrapped_dts_file_with_tail( + "mux-raw-dts-wrapped-tail-expected", + 2, + b"DTSHDTRAILER", + ); + fs::read(&input).unwrap() + }; + let dts_input = + write_test_wrapped_dts_file_with_tail("mux-raw-dts-wrapped-tail-input", 2, b"DTSHDTRAILER"); + let output_path = write_temp_file("mux-raw-dts-wrapped-tail-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&dts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + assert_raw_dts_mux_output_matches_payload( + &output_path, + &expected_payload, + fourcc("dtsx"), + 0, + 4_116, + 1_543_496, + 1, + ); +} + +#[test] +fn mux_to_path_imports_path_only_14bit_big_endian_raw_dts_inputs() { + let dts_input = write_test_dts_14bit_big_endian_file("mux-raw-dts-14be-input", 2); + let expected_payload = fs::read(&dts_input).unwrap(); + let output_path = write_temp_file("mux-raw-dts-14be-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&dts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + assert_raw_dts_mux_output_matches_payload( + &output_path, + &expected_payload, + fourcc("dtsc"), + 48_000 << 16, + 2_342, + 878_248, + 2, + ); +} + +#[test] +fn mux_to_path_imports_path_only_14bit_little_endian_raw_dts_inputs() { + let dts_input = write_test_dts_14bit_little_endian_file("mux-raw-dts-14le-input", 2); + let expected_payload = fs::read(&dts_input).unwrap(); + let output_path = write_temp_file("mux-raw-dts-14le-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&dts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + assert_raw_dts_mux_output_matches_payload( + &output_path, + &expected_payload, + fourcc("dtsc"), + 48_000 << 16, + 2_342, + 878_248, + 2, + ); +} + +fn assert_raw_dts_mux_output_matches_payload( + output_path: &std::path::Path, + expected_payload: &[u8], + expected_sample_entry_type: mp4forge::FourCc, + expected_sample_rate_fixed_point: u32, + expected_buffer_size_db: u32, + expected_bitrate: u32, + expected_sample_count: u32, +) { + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + expected_sample_entry_type, + ]), + ); + let ddts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dtsc"), + fourcc("ddts"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + expected_sample_entry_type, + fourcc("btrt"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!( + audio_entries[0].sample_entry.box_type, + expected_sample_entry_type + ); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!( + audio_entries[0].sample_rate, + expected_sample_rate_fixed_point + ); + assert!(ddts_boxes.is_empty()); + assert_eq!(btrt_boxes.len(), 1); + assert_eq!(btrt_boxes[0].buffer_size_db, expected_buffer_size_db); + assert_eq!(btrt_boxes[0].max_bitrate, expected_bitrate); + assert_eq!(btrt_boxes[0].avg_bitrate, expected_bitrate); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, expected_sample_count); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1_920); +} + +#[test] +fn mux_to_path_imports_path_only_avi_pcm_inputs() { + let chunk = [0_u8, 0, 0, 0, 1, 0, 1, 0]; + let avi_input = write_test_avi_pcm_file( + "mux-avi-pcm-input", + &[TestAviPcmStream { + sample_rate: 48_000, + channel_count: 2, + bits_per_sample: 16, + chunks: &[&chunk], + }], + ); + let output_path = write_temp_file("mux-avi-pcm-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ipcm")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(audio_entries[0].sample_rate, 48_000 << 16); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 2, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_ms_adpcm_inputs() { + let avi_input = write_test_avi_audio_tag_file( + "mux-avi-ms-adpcm-input", + 0x0002, + 8_000, + 1, + 4, + &[ + b"\x12\x34\x56\x78\x9A\xBC\xDE\xF0\x11\x22\x33", + b"\x13\x35\x57\x79\x9B\xBD\xDF\xF1\x10\x20\x30", + ], + ); + let output_path = write_temp_file("mux-avi-ms-adpcm-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + mp4forge::FourCc::from_bytes([0x6D, 0x73, 0x00, 0x02]), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!( + audio_entries[0].sample_entry.box_type, + mp4forge::FourCc::from_bytes([0x6D, 0x73, 0x00, 0x02]) + ); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_size, 4); + assert_eq!(audio_entries[0].sample_rate, 8_000 << 16); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 10, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_ima_adpcm_inputs() { + let avi_input = write_test_avi_audio_tag_file( + "mux-avi-ima-adpcm-input", + 0x0011, + 8_000, + 1, + 4, + &[ + b"\x12\x34\x56\x78\x9A\xBC\xDE\xF0", + b"\x21\x43\x65\x87\xA9\xCB\xED\x0F", + ], + ); + let output_path = write_temp_file("mux-avi-ima-adpcm-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + mp4forge::FourCc::from_bytes([0x6D, 0x73, 0x00, 0x11]), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!( + audio_entries[0].sample_entry.box_type, + mp4forge::FourCc::from_bytes([0x6D, 0x73, 0x00, 0x11]) + ); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_size, 4); + assert_eq!(audio_entries[0].sample_rate, 8_000 << 16); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 9, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_extensible_pcm_inputs() { + let chunk = [0_u8, 0, 0, 0, 1, 0, 1, 0]; + let avi_input = write_test_avi_extensible_pcm_file( + "mux-avi-extensible-pcm-input", + 48_000, + 2, + 16, + &[&chunk], + ); + let output_path = write_temp_file("mux-avi-extensible-pcm-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ipcm")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(audio_entries[0].sample_rate, 48_000 << 16); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 2, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_extensible_float_inputs() { + let chunk = [0_u8, 0, 0x80, 0x3F, 0, 0, 0x00, 0x40]; + let avi_input = write_test_avi_extensible_float_file( + "mux-avi-extensible-float-input", + 48_000, + 1, + 32, + &[&chunk], + ); + let output_path = write_temp_file("mux-avi-extensible-float-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fpcm"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("fpcm")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_rate, 48_000 << 16); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 2, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_alaw_inputs() { + let chunk = [0x11_u8, 0x22, 0x33, 0x44]; + let avi_input = write_test_avi_alaw_file("mux-avi-alaw-input", 8_000, 1, &[&chunk]); + let output_path = write_temp_file("mux-avi-alaw-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("alaw"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("alaw")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_rate, 8_000 << 16); + assert_eq!(audio_entries[0].sample_size, 8); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 8_000); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 4, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_ibm_alaw_inputs() { + let chunk = [0x11_u8, 0x22, 0x33, 0x44]; + let avi_input = + write_test_avi_audio_tag_file("mux-avi-ibm-alaw-input", 0x0102, 8_000, 1, 8, &[&chunk]); + let output_path = write_temp_file("mux-avi-ibm-alaw-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("alaw"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("alaw")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_rate, 8_000 << 16); + assert_eq!(audio_entries[0].sample_size, 8); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 1, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_mulaw_inputs() { + let chunk = [0x55_u8, 0x66, 0x77, 0x88]; + let avi_input = write_test_avi_mulaw_file("mux-avi-mulaw-input", 8_000, 1, &[&chunk]); + let output_path = write_temp_file("mux-avi-mulaw-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("MLAW"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("MLAW"), + fourcc("btrt"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("MLAW")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_rate, 8_000 << 16); + assert_eq!(audio_entries[0].sample_size, 16); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 8_000); + assert_eq!(btrt_boxes.len(), 1); + assert!(btrt_boxes[0].avg_bitrate > 0); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 4, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_ibm_mulaw_inputs() { + let chunk = [0x55_u8, 0x66, 0x77, 0x88]; + let avi_input = + write_test_avi_audio_tag_file("mux-avi-ibm-mulaw-input", 0x0101, 8_000, 1, 8, &[&chunk]); + let output_path = write_temp_file("mux-avi-ibm-mulaw-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("MLAW"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("MLAW")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_rate, 8_000 << 16); + assert_eq!(audio_entries[0].sample_size, 16); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 1, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_ibm_cvsd_inputs() { + let chunk = [0x10_u8, 0x20, 0x30, 0x40]; + let avi_input = + write_test_avi_audio_tag_file("mux-avi-ibm-cvsd-input", 0x0005, 8_000, 1, 8, &[&chunk]); + let output_path = write_temp_file("mux-avi-ibm-cvsd-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("CSVD"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("CSVD")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_rate, 8_000 << 16); + assert_eq!(audio_entries[0].sample_size, 8); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 4, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_oki_adpcm_inputs() { + let chunk = [0x12_u8, 0x34, 0x56, 0x78]; + let avi_input = + write_test_avi_audio_tag_file("mux-avi-oki-adpcm-input", 0x0010, 8_000, 1, 4, &[&chunk]); + let output_path = write_temp_file("mux-avi-oki-adpcm-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("OPCM"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("OPCM")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_rate, 8_000 << 16); + assert_eq!(audio_entries[0].sample_size, 4); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 8, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_digistd_inputs() { + let chunk = [0x21_u8, 0x43, 0x65, 0x87]; + let avi_input = + write_test_avi_audio_tag_file("mux-avi-digistd-input", 0x0015, 8_000, 1, 8, &[&chunk]); + let output_path = write_temp_file("mux-avi-digistd-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("DSTD"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("DSTD")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_rate, 8_000 << 16); + assert_eq!(audio_entries[0].sample_size, 8); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 4, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_yamaha_adpcm_inputs() { + let chunk = [0x31_u8, 0x42, 0x53, 0x64]; + let avi_input = + write_test_avi_audio_tag_file("mux-avi-yamaha-adpcm-input", 0x0020, 8_000, 1, 4, &[&chunk]); + let output_path = write_temp_file("mux-avi-yamaha-adpcm-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("YPCM"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("YPCM")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_rate, 8_000 << 16); + assert_eq!(audio_entries[0].sample_size, 4); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 8, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_truespeech_inputs() { + let chunk = [0x41_u8, 0x52, 0x63, 0x74]; + let avi_input = + write_test_avi_audio_tag_file("mux-avi-truespeech-input", 0x0022, 8_000, 1, 8, &[&chunk]); + let output_path = write_temp_file("mux-avi-truespeech-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("TSPE"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("TSPE")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_rate, 8_000 << 16); + assert_eq!(audio_entries[0].sample_size, 8); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 4, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_gsm610_inputs() { + let chunk = [0x51_u8, 0x62, 0x73, 0x84]; + let avi_input = + write_test_avi_audio_tag_file("mux-avi-gsm610-input", 0x0031, 8_000, 1, 8, &[&chunk]); + let output_path = write_temp_file("mux-avi-gsm610-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("G610"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("G610")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_rate, 8_000 << 16); + assert_eq!(audio_entries[0].sample_size, 8); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 4, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_ibm_adpcm_inputs() { + let chunk = [0x61_u8, 0x72, 0x83, 0x94]; + let avi_input = + write_test_avi_audio_tag_file("mux-avi-ibm-adpcm-input", 0x0103, 8_000, 1, 4, &[&chunk]); + let output_path = write_temp_file("mux-avi-ibm-adpcm-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("IPCM"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("IPCM")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_rate, 8_000 << 16); + assert_eq!(audio_entries[0].sample_size, 4); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 8, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_aac_adts_inputs() { + let adts_input = write_test_adts_file("mux-avi-aac-adts-source", &[b"abc", b"defg"]); + let adts_bytes = fs::read(&adts_input).unwrap(); + let first_frame_len = usize::from(u16::from(adts_bytes[3] & 0x03) << 11) + | (usize::from(adts_bytes[4]) << 3) + | usize::from(adts_bytes[5] >> 5); + let avi_input = write_test_avi_audio_tag_file( + "mux-avi-aac-adts-input", + 0x706D, + 44_100, + 2, + 16, + &[ + &adts_bytes[..first_frame_len], + &adts_bytes[first_frame_len..], + ], + ); + let output_path = write_temp_file("mux-avi-aac-adts-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("mp4a")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(audio_entries[0].sample_rate, 44_100 << 16); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 44_100); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 1_024, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_extensible_alaw_inputs() { + let chunk = [0x11_u8, 0x22, 0x33, 0x44]; + let avi_input = + write_test_avi_extensible_alaw_file("mux-avi-extensible-alaw-input", 8_000, 1, &[&chunk]); + let output_path = write_temp_file("mux-avi-extensible-alaw-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("alaw"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("alaw")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_rate, 8_000 << 16); + assert_eq!(audio_entries[0].sample_size, 8); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 8_000); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 4, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_extensible_mulaw_inputs() { + let chunk = [0x55_u8, 0x66, 0x77, 0x88]; + let avi_input = + write_test_avi_extensible_mulaw_file("mux-avi-extensible-mulaw-input", 8_000, 1, &[&chunk]); + let output_path = write_temp_file("mux-avi-extensible-mulaw-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("MLAW"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("MLAW")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_rate, 8_000 << 16); + assert_eq!(audio_entries[0].sample_size, 16); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 8_000); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 4, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_mp4v_inputs() { + let decoder_specific_info = build_test_mp4v_decoder_specific_info(320, 180); + let intra_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x00, 0xAA, 0xBB]; + let predictive_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x40, 0xCC, 0xDD]; + let mut elementary = decoder_specific_info.clone(); + elementary.extend_from_slice(&intra_frame); + elementary.extend_from_slice(&predictive_frame); + let mp4v_input = write_test_mp4v_file("mux-mp4v-input", &elementary); + let output_path = write_temp_file("mux-mp4v-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&mp4v_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + fourcc("esds"), + ]), + ); + let pasp_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + fourcc("pasp"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + fourcc("btrt"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let stss_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("mp4v")); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 180); + assert_eq!(video_entries[0].compressorname[0], 0); + assert_eq!(esds_boxes.len(), 1); + assert_eq!( + esds_boxes[0].decoder_specific_info().unwrap(), + decoder_specific_info + ); + assert_eq!(pasp_boxes.len(), 1); + assert_eq!(pasp_boxes[0].h_spacing, 1); + assert_eq!(pasp_boxes[0].v_spacing, 1); + assert!(btrt_boxes.is_empty()); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 25_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 1_000, + }] + ); + assert_eq!(stss_boxes.len(), 1); + assert_eq!(stss_boxes[0].sample_number, vec![1]); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + [&intra_frame[..], &predictive_frame[..]].concat() + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_mp4v_inputs() { + let decoder_specific_info = [0x00_u8, 0x00, 0x01, 0x20, 0x11, 0x22]; + let intra_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x00, 0xAA, 0xBB]; + let predictive_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x40, 0xCC, 0xDD]; + let avi_input = write_test_avi_mp4v_file( + "mux-avi-mp4v-input", + &TestAviMp4vStream { + width: 320, + height: 180, + frame_scale: 1, + frame_rate: 25, + compression: *b"MP4V", + decoder_specific_info: &decoder_specific_info, + frames: &[&intra_frame, &predictive_frame], + }, + ); + let expected_payload = [&intra_frame[..], &predictive_frame[..]].concat(); + let output_path = write_temp_file("mux-avi-mp4v-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + fourcc("esds"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let stss_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("mp4v")); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 180); + assert_eq!(esds_boxes.len(), 1); + assert_eq!( + esds_boxes[0] + .decoder_config_descriptor() + .unwrap() + .object_type_indication, + 0x20 + ); + assert_eq!( + esds_boxes[0].decoder_specific_info().unwrap(), + decoder_specific_info + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 25_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 1_000, + }] + ); + assert_eq!(stss_boxes.len(), 1); + assert_eq!(stss_boxes[0].sample_number, vec![1]); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); +} + +#[test] +fn mux_to_path_imports_path_only_avi_mp4v_inputs_with_vol_control_parameters() { + let decoder_specific_info = build_test_mp4v_decoder_specific_info_with_vol_control(320, 180); + let intra_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x00, 0xAA, 0xBB]; + let predictive_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x40, 0xCC, 0xDD]; + let avi_input = write_test_avi_mp4v_file( + "mux-avi-mp4v-vol-control-input", + &TestAviMp4vStream { + width: 320, + height: 180, + frame_scale: 1, + frame_rate: 25, + compression: *b"MP4V", + decoder_specific_info: &decoder_specific_info, + frames: &[&intra_frame, &predictive_frame], + }, + ); + let output_path = write_temp_file("mux-avi-mp4v-vol-control-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let esds_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + fourcc("esds"), + ]), + ); + assert_eq!(esds_boxes.len(), 1); + assert_eq!( + esds_boxes[0].decoder_specific_info().unwrap(), + decoder_specific_info + ); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + [&intra_frame[..], &predictive_frame[..]].concat() + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_h264_inputs() { + let avi_input = write_test_avi_h264_file( + "mux-avi-h264-input", + &TestAviH264Stream { + width: 320, + height: 180, + frame_scale: 1, + frame_rate: 25, + compression: *b"H264", + sample_payloads: &[b"\xAA\xBB", b"\xCC\xDD"], + }, + ); + let output_path = write_temp_file("mux-avi-h264-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 180); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 25_000); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 1_000, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_avc1_inputs() { + let avi_input = write_test_avi_avc1_file( + "mux-avi-avc1-input", + &TestAviAvc1Stream { + width: 320, + height: 180, + frame_scale: 1, + frame_rate: 25, + sample_payloads: &[b"\xAA\xBB", b"\xCC\xDD"], + }, + ); + let output_path = write_temp_file("mux-avi-avc1-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stss_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + let colr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + fourcc("colr"), + ]), + ); + let avcc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + fourcc("avcC"), + ]), + ); + + assert!(output_bytes.windows(4).any(|bytes| bytes == b"avc1")); + assert!(output_bytes.windows(4).any(|bytes| bytes == b"avcC")); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 25_000); + assert_eq!(stss_boxes.len(), 1); + assert!(stss_boxes[0].sample_number.is_empty()); + assert!(colr_boxes.is_empty()); + assert_eq!(avcc_boxes.len(), 1); + assert!(avcc_boxes[0].high_profile_fields_enabled); + assert_eq!(avcc_boxes[0].chroma_format, 1); + assert_eq!(avcc_boxes[0].num_of_sequence_parameter_set_ext, 0); +} + +#[test] +fn mux_to_path_imports_path_only_avi_mp3_inputs() { + let avi_input = write_test_avi_mp3_file( + "mux-avi-mp3-input", + 48_000, + 2, + &[b"avi-mp3-a", b"avi-mp3-b"], + ); + let output_path = write_temp_file("mux-avi-mp3-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(".mp3"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc(".mp3")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); +} + +#[test] +fn mux_to_path_imports_path_only_avi_ac3_inputs() { + let avi_input = write_test_avi_ac3_file( + "mux-avi-ac3-input", + 48_000, + 2, + &[b"avi-ac3-a", b"avi-ac3-b"], + ); + let output_path = write_temp_file("mux-avi-ac3-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ac-3"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ac-3")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); +} + +#[test] +fn mux_to_path_imports_path_only_avi_h263_inputs() { + let avi_input = write_test_avi_h263_file( + "mux-avi-h263-input", + 176, + 144, + 1, + 25, + &[b"\xAA\xBB", b"\xCC\xDD"], + ); + let output_path = write_temp_file("mux-avi-h263-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("H263"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("H263"), + fourcc("btrt"), + ]), + ); + let stss_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 176); + assert_eq!(video_entries[0].height, 144); + assert_eq!(btrt_boxes.len(), 1); + assert!(btrt_boxes[0].buffer_size_db > 0); + assert!(btrt_boxes[0].max_bitrate > 0); + assert!(btrt_boxes[0].avg_bitrate > 0); + assert_eq!(stss_boxes.len(), 1); + assert_eq!(stss_boxes[0].entry_count, 0); + assert!(stss_boxes[0].sample_number.is_empty()); +} + +#[test] +fn mux_to_path_imports_path_only_avi_jpeg_inputs() { + let jpeg_frame = fs::read(fixture_path("generated-1x1.jpg")).unwrap(); + let avi_input = write_test_avi_jpeg_file("mux-avi-jpeg-input", 1, 1, 1, 25, &[&jpeg_frame]); + let output_path = write_temp_file("mux-avi-jpeg-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("MJPG"), + ]), + ); + let stss_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 1); + assert_eq!(video_entries[0].height, 1); + assert_eq!(video_entries[0].compressorname[0], 19); + assert_eq!( + &video_entries[0].compressorname[1..20], + b"Codec Not Supported" + ); + assert_eq!(stss_boxes.len(), 1); + assert_eq!(stss_boxes[0].entry_count, 0); + assert!(stss_boxes[0].sample_number.is_empty()); +} + +#[test] +fn mux_to_path_imports_path_only_avi_png_inputs() { + let png_frame_path = write_test_png_file("mux-avi-png-frame"); + let png_frame = fs::read(png_frame_path).unwrap(); + let avi_input = write_test_avi_png_file("mux-avi-png-input", 1, 1, 1, 25, &[&png_frame]); + let output_path = write_temp_file("mux-avi-png-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("PNG "), + ]), + ); + let stss_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 1); + assert_eq!(video_entries[0].height, 1); + assert_eq!(stss_boxes.len(), 1); + assert_eq!(stss_boxes[0].entry_count, 0); + assert!(stss_boxes[0].sample_number.is_empty()); +} + +#[test] +fn mux_to_path_imports_path_only_avi_div3_inputs() { + let avi_input = write_test_avi_video_tag_file( + "mux-avi-div3-input", + 640, + 360, + 1, + 25, + *b"DIV3", + &[b"avi-div3-a", b"avi-div3-b"], + ); + let output_path = write_temp_file("mux-avi-div3-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("DIV3"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("DIV3"), + fourcc("btrt"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stss_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("DIV3")); + assert_eq!(video_entries[0].width, 640); + assert_eq!(video_entries[0].height, 360); + assert_eq!(video_entries[0].compressorname[0], 11); + assert_eq!(&video_entries[0].compressorname[1..12], b"MS-MPEG4 V3"); + assert_eq!(btrt_boxes.len(), 1); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 25_000); + assert_eq!(stss_boxes.len(), 1); + assert_eq!(stss_boxes[0].entry_count, 0); + assert!(stss_boxes[0].sample_number.is_empty()); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"avi-div3-aavi-div3-b" + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_div4_inputs() { + let avi_input = write_test_avi_video_tag_file( + "mux-avi-div4-input", + 640, + 360, + 1, + 25, + *b"DIV4", + &[b"avi-div4-a", b"avi-div4-b"], + ); + let output_path = write_temp_file("mux-avi-div4-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("DIV3"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("DIV3"), + fourcc("btrt"), + ]), + ); + let stss_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("DIV3")); + assert_eq!(video_entries[0].width, 640); + assert_eq!(video_entries[0].height, 360); + assert_eq!(video_entries[0].compressorname[0], 11); + assert_eq!(&video_entries[0].compressorname[1..12], b"MS-MPEG4 V3"); + assert_eq!(btrt_boxes.len(), 1); + assert_eq!(stss_boxes.len(), 1); + assert_eq!(stss_boxes[0].entry_count, 0); + assert!(stss_boxes[0].sample_number.is_empty()); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"avi-div4-aavi-div4-b" + ); +} + +#[test] +fn mux_to_path_imports_path_only_avi_raw_bgr_inputs() { + let avi_input = write_test_avi_raw_bgr_file( + "mux-avi-raw-bgr-input", + 1, + 1, + 1, + 25, + &[b"\x11\x22\x33", b"\x44\x55\x66"], + ); + let output_path = write_temp_file("mux-avi-raw-bgr-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let mut reader = Cursor::new(&output_bytes); + let stsd_boxes = extract_box_bytes( + &mut reader, + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + ]), + ) + .unwrap(); + let stss_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(stsd_boxes.len(), 1); + assert_eq!( + u32::from_be_bytes(stsd_boxes[0][12..16].try_into().unwrap()), + 1 + ); + let entry_size = usize::try_from(u32::from_be_bytes( + stsd_boxes[0][16..20].try_into().unwrap(), + )) + .unwrap(); + let raw_entry = &stsd_boxes[0][16..16 + entry_size]; + assert_eq!(&raw_entry[4..8], b"uncv"); + assert_eq!(u16::from_be_bytes(raw_entry[32..34].try_into().unwrap()), 1); + assert_eq!(u16::from_be_bytes(raw_entry[34..36].try_into().unwrap()), 1); + let visible_len = usize::from(raw_entry[50]).min(31); + assert_eq!(&raw_entry[51..51 + visible_len], b"RawVideo"); + assert!(!raw_entry.windows(4).any(|window| window == b"btrt")); + let cmpd_type_offset = raw_entry + .windows(4) + .position(|window| window == b"cmpd") + .unwrap(); + assert_eq!(cmpd_type_offset, 90); + assert_eq!( + u32::from_be_bytes( + raw_entry[cmpd_type_offset - 4..cmpd_type_offset] + .try_into() + .unwrap() + ), + 18 + ); + assert_eq!( + &raw_entry[cmpd_type_offset + 4..cmpd_type_offset + 14], + &[0, 0, 0, 3, 0, 6, 0, 5, 0, 4] + ); + let uncc_type_offset = raw_entry + .windows(4) + .position(|window| window == b"uncC") + .unwrap(); + assert_eq!(uncc_type_offset, 108); + assert_eq!( + u32::from_be_bytes( + raw_entry[uncc_type_offset - 4..uncc_type_offset] + .try_into() + .unwrap() + ), + 59 + ); + assert_eq!( + &raw_entry[uncc_type_offset + 4..uncc_type_offset + 55], + &[ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 7, 0, 0, 0, 1, 7, 0, 0, 0, 2, 7, 0, 0, 0, 1, + 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] + ); + assert!(stss_boxes.is_empty()); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"\x11\x22\x33\x44\x55\x66" + ); +} + +#[test] +fn mux_to_path_imports_explicit_rawvideo_track_specs() { + for case in raw_video_test_cases() { + let input_bytes = build_test_raw_video_input_bytes(case, 2); + let input = write_temp_file_with_extension( + &format!("mux-{}-input", case.label), + "raw", + &input_bytes, + ); + let output_path = write_temp_file(&format!("mux-{}-output", case.label), &[]); + let spec = MuxTrackSpec::from_str(&format!( + "{}#rawvideo:size={}x{},spfmt={},fps=25/1", + input.display(), + case.width, + case.height, + case.spfmt, + )) + .unwrap(); + let request = MuxRequest::new(vec![spec]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let mut reader = Cursor::new(&output_bytes); + let stsd_boxes = extract_box_bytes( + &mut reader, + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + ]), + ) + .unwrap(); + assert_eq!(stsd_boxes.len(), 1); + let entry_size = usize::try_from(u32::from_be_bytes( + stsd_boxes[0][16..20].try_into().unwrap(), + )) + .unwrap(); + let raw_entry = &stsd_boxes[0][16..16 + entry_size]; + assert_eq!(&raw_entry[4..8], b"uncv"); + assert_eq!( + u16::from_be_bytes(raw_entry[32..34].try_into().unwrap()), + u16::try_from(case.width).unwrap() + ); + assert_eq!( + u16::from_be_bytes(raw_entry[34..36].try_into().unwrap()), + u16::try_from(case.height).unwrap() + ); + assert_eq!( + raw_entry.windows(4).any(|window| window == b"pasp"), + case.expect_pasp + ); + assert_eq!( + raw_entry.windows(4).any(|window| window == b"colr"), + case.expect_colr + ); + + let root_boxes = read_root_boxes(&output_bytes); + let mdat = root_boxes + .into_iter() + .find(|info| info.box_type() == fourcc("mdat")) + .unwrap(); + assert_eq!(mdat_payload(&output_bytes, mdat), input_bytes.as_slice()); + } +} + +#[test] +fn mux_to_path_imports_explicit_rawvideo_track_specs_with_reference_odd_dimensions() { + for (label, width, height, pixel_format) in [ + ( + "rawvideo-yuv420-odd", + 3, + 3, + MuxRawVideoPixelFormat::Yuv420p8, + ), + ( + "rawvideo-yvu420-odd", + 3, + 3, + MuxRawVideoPixelFormat::Yvu420p8, + ), + ( + "rawvideo-yuv422-odd", + 3, + 2, + MuxRawVideoPixelFormat::Yuv422p8, + ), + ( + "rawvideo-yuv42010-odd", + 3, + 3, + MuxRawVideoPixelFormat::Yuv420p10, + ), + ] { + let frame_payload = build_test_raw_video_frame_payload(pixel_format, width, height); + let input_bytes = build_test_raw_video_bytes(&frame_payload, 2); + let input = + write_temp_file_with_extension(&format!("mux-{label}-input"), "raw", &input_bytes); + let output_path = write_temp_file(&format!("mux-{label}-output"), &[]); + let spec = MuxTrackSpec::raw_video( + input.display().to_string(), + MuxRawVideoParams::new(width, height, pixel_format, 25, 1).unwrap(), + ); + let request = MuxRequest::new(vec![spec]); + + mux_to_path(&request, &output_path).unwrap(); + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + let mdat = root_boxes + .into_iter() + .find(|info| info.box_type() == fourcc("mdat")) + .unwrap(); + assert_eq!(mdat_payload(&output_bytes, mdat), input_bytes.as_slice()); + } +} + +#[test] +fn mux_to_path_imports_packed_10bit_rawvideo_track_specs_with_reference_block_flags() { + for case in [ + ( + "v210", + MuxRawVideoPixelFormat::V210, + 48_u32, + 2_u32, + b"v210".as_slice(), + &[0, 0, 0, 4, 0, 2, 0, 1, 0, 3, 0, 1][..], + 1_u8, + 1_u8, + 4_u8, + 0x38_u8, + ), + ( + "v410", + MuxRawVideoPixelFormat::Yuv444Packed10, + 2_u32, + 2_u32, + b"v410".as_slice(), + &[0, 0, 0, 3, 0, 2, 0, 1, 0, 3][..], + 0_u8, + 1_u8, + 4_u8, + 0x78_u8, + ), + ] { + let ( + label, + pixel_format, + width, + height, + profile, + cmpd_bytes, + sampling, + interleave, + block_size, + block_flags, + ) = case; + let frame_payload = build_test_raw_video_frame_payload(pixel_format, width, height); + let input_bytes = build_test_raw_video_bytes(&frame_payload, 2); + let input = write_temp_file_with_extension( + &format!("mux-rawvideo-{label}-input"), + "raw", + &input_bytes, + ); + let output_path = write_temp_file(&format!("mux-rawvideo-{label}-output"), &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::raw_video( + &input, + MuxRawVideoParams::new(width, height, pixel_format, 25, 1).unwrap(), + )]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let mut reader = Cursor::new(&output_bytes); + let stsd_boxes = extract_box_bytes( + &mut reader, + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + ]), + ) + .unwrap(); + let entry_size = usize::try_from(u32::from_be_bytes( + stsd_boxes[0][16..20].try_into().unwrap(), + )) + .unwrap(); + let raw_entry = &stsd_boxes[0][16..16 + entry_size]; + let cmpd_type_offset = raw_entry + .windows(4) + .position(|window| window == b"cmpd") + .unwrap(); + assert_eq!( + &raw_entry[cmpd_type_offset + 4..cmpd_type_offset + 4 + cmpd_bytes.len()], + cmpd_bytes + ); + let uncc_type_offset = raw_entry + .windows(4) + .position(|window| window == b"uncC") + .unwrap(); + assert_eq!( + &raw_entry[uncc_type_offset + 8..uncc_type_offset + 12], + profile + ); + let component_count = usize::try_from(u32::from_be_bytes( + raw_entry[uncc_type_offset + 12..uncc_type_offset + 16] + .try_into() + .unwrap(), + )) + .unwrap(); + let raw_layout_offset = uncc_type_offset + 16 + (component_count * 5); + assert_eq!(raw_entry[raw_layout_offset], sampling); + assert_eq!(raw_entry[raw_layout_offset + 1], interleave); + assert_eq!(raw_entry[raw_layout_offset + 2], block_size); + assert_eq!(raw_entry[raw_layout_offset + 3], block_flags); + } +} + +#[test] +fn mux_to_path_imports_path_only_avi_generic_passthrough_video_tags() { + let avi_input = write_test_avi_video_tag_file( + "mux-avi-generic-video-input", + 320, + 240, + 1, + 25, + *b"ZZZ1", + &[b"avi-generic-a", b"avi-generic-b"], + ); + let output_path = write_temp_file("mux-avi-generic-video-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let mut reader = Cursor::new(&output_bytes); + let stsd_boxes = extract_box_bytes( + &mut reader, + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + ]), + ) + .unwrap(); + let stss_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + let root_boxes = read_root_boxes(&output_bytes); + + assert_eq!(stsd_boxes.len(), 1); + assert_eq!( + u32::from_be_bytes(stsd_boxes[0][12..16].try_into().unwrap()), + 1 + ); + let entry_size = usize::try_from(u32::from_be_bytes( + stsd_boxes[0][16..20].try_into().unwrap(), + )) + .unwrap(); + let passthrough_entry = &stsd_boxes[0][16..16 + entry_size]; + assert_eq!(&passthrough_entry[4..8], b"ZZZ1"); + assert_eq!( + u16::from_be_bytes(passthrough_entry[32..34].try_into().unwrap()), + 320 + ); + assert_eq!( + u16::from_be_bytes(passthrough_entry[34..36].try_into().unwrap()), + 240 + ); + let visible_len = usize::from(passthrough_entry[50]).min(31); + assert_eq!( + &passthrough_entry[51..51 + visible_len], + b"Codec Not Supported" + ); + assert!(passthrough_entry.windows(4).any(|window| window == b"btrt")); + assert_eq!(stss_boxes.len(), 1); + assert_eq!(stss_boxes[0].entry_count, 0); + assert!(stss_boxes[0].sample_number.is_empty()); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"avi-generic-aavi-generic-b" + ); +} + +#[test] +fn mux_to_path_imports_path_only_program_stream_mp4v_inputs() { + let decoder_specific_info = build_test_mp4v_decoder_specific_info(320, 180); + let intra_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x00, 0xAA, 0xBB]; + let predictive_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x40, 0xCC, 0xDD]; + let first_payload = [&decoder_specific_info[..], &intra_frame[..]].concat(); + let ps_input = write_test_program_stream_mp4v_file( + "mux-program-stream-mp4v-input", + &[&first_payload, &predictive_frame], + ); + let output_path = write_temp_file("mux-program-stream-mp4v-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("mp4v")); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 25_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1_000); +} + +#[test] +fn mux_to_path_imports_path_only_program_stream_mpeg2v_inputs() { + let ps_input = write_test_program_stream_mpeg2v_file( + "mux-program-stream-mpeg2v-input", + &[b"ps-mpeg2v-a", b"ps-mpeg2v-b"], + ); + let output_path = write_temp_file("mux-program-stream-mpeg2v-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("mp4v")); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 180); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].duration_v0, 7_200); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 3_600); +} + +#[test] +fn mux_to_path_imports_path_only_program_stream_mpeg2v_inputs_with_pts_and_dts() { + let ps_input = write_test_program_stream_mpeg2v_pts_dts_file( + "mux-program-stream-mpeg2v-pts-dts-input", + &[b"ps-mpeg2v-a", b"ps-mpeg2v-b"], + ); + let output_path = write_temp_file("mux-program-stream-mpeg2v-pts-dts-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let ctts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("ctts"), + ]), + ); + let elst_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("edts"), + fourcc("elst"), + ]), + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(mdhd_boxes[0].duration_v0, 7_200); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 3_600, + }] + ); + assert_eq!( + ctts_boxes.len(), + 0, + "PTS==DTS retained program-stream fixture should not author ctts" + ); + assert_eq!( + elst_boxes.len(), + 0, + "PTS==DTS retained program-stream fixture should not author an edit list" + ); +} + +#[test] +fn mux_to_path_imports_path_only_program_stream_mp3_inputs() { + let ps_input = write_test_program_stream_mp3_file( + "mux-program-stream-mp3-input", + &[&[0x11; 96], &[0x22; 96]], + ); + let output_path = write_temp_file("mux-program-stream-mp3-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(".mp3"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc(".mp3")); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 2_160); +} + +#[test] +fn mux_to_path_imports_path_only_program_stream_mp2_inputs() { + let ps_input = write_test_program_stream_mp2_file( + "mux-program-stream-mp2-input", + &[&[0x11; 96], &[0x22; 96]], + ); + let output_path = write_temp_file("mux-program-stream-mp2-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(".mp3"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc(".mp3")); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 2_160, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_program_stream_ac3_inputs() { + let raw_input = write_test_ac3_file("mux-program-stream-ac3-raw-input", &[b"ps", b"ac3"]); + let expected_payload = fs::read(&raw_input).unwrap(); + let ps_input = + write_test_program_stream_ac3_file("mux-program-stream-ac3-input", &[b"ps", b"ac3"]); + let output_path = write_temp_file("mux-program-stream-ac3-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ac-3"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ac-3")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 2_880, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_program_stream_lpcm_inputs() { + let sample_a = [0x00_u8, 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x04]; + let sample_b = [0x00_u8, 0x05, 0x00, 0x06, 0x00, 0x07, 0x00, 0x08]; + let expected_payload = [&sample_a[..], &sample_b[..]].concat(); + let ps_input = write_test_program_stream_lpcm_file( + "mux-program-stream-lpcm-input", + &[&sample_a, &sample_b], + ); + let output_path = write_temp_file("mux-program-stream-lpcm-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + ]), + ); + let pcm_configs = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + fourcc("pcmC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ipcm")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(pcm_configs.len(), 1); + assert_eq!(pcm_configs[0].format_flags, 0); + assert_eq!(pcm_configs[0].pcm_sample_size, 16); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 2, + }] + ); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(stsz_boxes[0].sample_count, 2); + assert_eq!(stsz_boxes[0].sample_size, 8); + assert!(stsz_boxes[0].entry_size.is_empty()); +} + +#[test] +fn mux_to_path_imports_path_only_program_stream_h264_inputs() { + let ps_input = + write_test_program_stream_h264_file("mux-program-stream-h264-input", &[b"idr-sample"]); + let output_path = write_temp_file("mux-program-stream-h264-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 180); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 40); +} + +#[test] +fn mux_to_path_imports_path_only_program_stream_h264_open_ended_inputs() { + let ps_input = write_test_program_stream_h264_open_ended_file( + "mux-program-stream-h264-open-ended-input", + &[b"idr-sample", b"p-sample"], + ); + let output_path = write_temp_file("mux-program-stream-h264-open-ended-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + ]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 180); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(stsz_boxes[0].entry_size.len(), 2); +} + +#[test] +fn mux_to_path_imports_path_only_program_stream_h265_inputs() { + let ps_input = + write_test_program_stream_h265_file("mux-program-stream-h265-input", &[b"hevc-sample"]); + let output_path = write_temp_file("mux-program-stream-h265-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("hvc1"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 1920); + assert_eq!(video_entries[0].height, 1080); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 30); +} + +#[test] +fn mux_to_path_imports_path_only_program_stream_vvc_inputs() { + let ps_input = write_test_program_stream_vvc_file("mux-program-stream-vvc-input", &[]); + let output_path = write_temp_file("mux-program-stream-vvc-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("vvc1"), + ]), + ); + let vvc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("vvc1"), + fourcc("vvcC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 1280); + assert_eq!(video_entries[0].height, 720); + assert_eq!(vvc_boxes.len(), 1); + assert!(!vvc_boxes[0].decoder_configuration_record.is_empty()); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 25); + assert_eq!(mdhd_boxes[0].duration(), 2); +} + +#[test] +fn mux_to_path_imports_path_only_mpeg2v_inputs() { + let input_path = write_test_mpeg2v_file( + "mux-mpeg2v-input", + &build_test_mpeg2v_bytes(320, 180, &[b"mpeg2v-a", b"mpeg2v-b"]), + ); + let output_path = write_temp_file("mux-mpeg2v-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input_path)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + fourcc("btrt"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("mp4v")); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 180); + assert_eq!(video_entries[0].compressorname[0], 0); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 25_000); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("vide")); + assert_eq!(hdlr_boxes[0].name, "VideoHandler"); + assert_eq!(iods_boxes.len(), 1); + assert_eq!( + iods_boxes[0] + .initial_object_descriptor() + .unwrap() + .visual_profile_level_indication, + 0x0c + ); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(stsz_boxes[0].sample_count, 2); + assert_eq!(stsz_boxes[0].sample_size, 0); + assert_eq!(stsz_boxes[0].entry_size.len(), 2); + assert!(stsz_boxes[0].entry_size[0] > stsz_boxes[0].entry_size[1]); + assert!(btrt_boxes.is_empty()); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1_000); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_mp4v_inputs() { + let decoder_specific_info = build_test_mp4v_decoder_specific_info(320, 180); + let intra_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x00, 0xAA, 0xBB]; + let predictive_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x40, 0xCC, 0xDD]; + let first_payload = [&decoder_specific_info[..], &intra_frame[..]].concat(); + let ts_input = write_test_transport_stream_mp4v_file( + "mux-transport-stream-mp4v-input", + &[&first_payload, &predictive_frame], + ); + let output_path = write_temp_file("mux-transport-stream-mp4v-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + fourcc("esds"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("mp4v")); + assert_eq!(esds_boxes.len(), 1); + let decoder_config = esds_boxes[0].decoder_config_descriptor().unwrap(); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(decoder_config.buffer_size_db, 7); + assert_eq!(decoder_config.max_bitrate, 1_680); + assert_eq!(decoder_config.avg_bitrate, 1_680); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 3_000); +} + +#[test] +fn mux_to_path_rejects_transport_stream_pat_sections_with_bad_crc() { + let ts_input = + write_test_transport_stream_mp4v_file("mux-transport-stream-bad-pat-source", &[b"a"]); + let bad_ts_input = + corrupt_mpeg2ts_section_crc(&ts_input, 0x0000, "mux-transport-stream-bad-pat-input"); + let output_path = write_temp_file("mux-transport-stream-bad-pat-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&bad_ts_input)]); + + let error = mux_to_path(&request, &output_path).unwrap_err(); + assert!( + error + .to_string() + .contains("PAT section failed CRC32 validation"), + "{error}" + ); +} + +#[test] +fn mux_to_path_rejects_transport_stream_pmt_sections_with_bad_crc() { + let ts_input = + write_test_transport_stream_mp4v_file("mux-transport-stream-bad-pmt-source", &[b"a"]); + let bad_ts_input = + corrupt_mpeg2ts_section_crc(&ts_input, 0x0100, "mux-transport-stream-bad-pmt-input"); + let output_path = write_temp_file("mux-transport-stream-bad-pmt-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&bad_ts_input)]); + + let error = mux_to_path(&request, &output_path).unwrap_err(); + assert!( + error + .to_string() + .contains("PMT section failed CRC32 validation"), + "{error}" + ); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_mpeg2v_inputs() { + let ts_input = write_test_transport_stream_mpeg2v_file( + "mux-transport-stream-mpeg2v-input", + &[b"ts-mpeg2v-a", b"ts-mpeg2v-b"], + ); + let output_path = write_temp_file("mux-transport-stream-mpeg2v-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + let tkhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("tkhd")]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("mp4v")); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 180); + assert_eq!(video_entries[0].compressorname[0], 0); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("vide")); + assert_eq!(hdlr_boxes[0].name, "VideoHandler"); + assert_eq!(iods_boxes.len(), 1); + assert_eq!( + iods_boxes[0] + .initial_object_descriptor() + .unwrap() + .visual_profile_level_indication, + 0x0c + ); + assert_eq!(tkhd_boxes.len(), 1); + assert_eq!(tkhd_boxes[0].track_id, 0x0101); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 3_600); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_av1_inputs() { + let frame_a = build_test_av1_sequence_header_obu(320, 240); + let frame_b = build_test_av1_sequence_header_obu(320, 240); + let ts_input = write_test_transport_stream_av1_file( + "mux-transport-stream-av1-input", + &[&frame_a, &frame_b], + ); + let output_path = write_temp_file("mux-transport-stream-av1-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("av01"), + ]), + ); + let av1c_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("av01"), + fourcc("av1C"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("av01")); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 240); + assert_eq!(av1c_boxes.len(), 1); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0] + .entries + .iter() + .map(|entry| entry.sample_count) + .sum::(), + 2 + ); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 3_600); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_avs3_inputs() { + let ts_input = write_test_transport_stream_avs3_file( + "mux-transport-stream-avs3-input", + &[b"ts-avs3-a", b"ts-avs3-b"], + ); + let output_path = write_temp_file("mux-transport-stream-avs3-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avs3"), + ]), + ); + let av3c_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avs3"), + fourcc("av3c"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let stss_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avs3"), + fourcc("btrt"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("avs3")); + assert_eq!(video_entries[0].width, 0); + assert_eq!(video_entries[0].height, 0); + assert_eq!(av3c_boxes.len(), 1); + assert_eq!(av3c_boxes[0].configuration_version, 1); + assert_eq!(av3c_boxes[0].sequence_header_length, 6); + assert_eq!( + av3c_boxes[0].sequence_header, + vec![0x00, 0x00, 0x01, 0xB0, 0x20, 0x10] + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("vide")); + assert_eq!(hdlr_boxes[0].name, "VideoHandler"); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 3_600); + assert_eq!(stss_boxes.len(), 1); + assert_eq!(stss_boxes[0].entry_count, 0); + assert!(stss_boxes[0].sample_number.is_empty()); + assert_eq!(btrt_boxes.len(), 1); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_h264_inputs() { + let ts_input = + write_test_transport_stream_h264_file("mux-transport-stream-h264-input", &[b"idr-sample"]); + let output_path = write_temp_file("mux-transport-stream-h264-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 180); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 9_000, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_h265_inputs() { + let ts_input = + write_test_transport_stream_h265_file("mux-transport-stream-h265-input", &[b"hevc-sample"]); + let output_path = write_temp_file("mux-transport-stream-h265-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("hvc1"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 1920); + assert_eq!(video_entries[0].height, 1080); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 3_000, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_vvc_inputs() { + let ts_input = write_test_transport_stream_vvc_file("mux-transport-stream-vvc-input", &[]); + let raw_vvc_input = fixture_path("mux/raw_vvc_idr.vvc"); + let output_path = write_temp_file("mux-transport-stream-vvc-output", &[]); + let raw_output_path = write_temp_file("mux-transport-stream-vvc-reference-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + let raw_request = MuxRequest::new(vec![MuxTrackSpec::path(&raw_vvc_input)]); + + mux_to_path(&request, &output_path).unwrap(); + mux_to_path(&raw_request, &raw_output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let raw_output_bytes = fs::read(raw_output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + let raw_root_boxes = read_root_boxes(&raw_output_bytes); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("vvc1"), + ]), + ); + let vvc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("vvc1"), + fourcc("vvcC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let ctts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("ctts"), + ]), + ); + let edts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("edts")]), + ); + + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 1280); + assert_eq!(video_entries[0].height, 720); + assert_eq!(vvc_boxes.len(), 1); + assert!(!vvc_boxes[0].decoder_configuration_record.is_empty()); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(mdhd_boxes[0].duration(), 0); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 0, + }] + ); + assert!(ctts_boxes.is_empty()); + assert!(edts_boxes.is_empty()); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + mdat_payload(&raw_output_bytes, raw_root_boxes[2]) + ); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_ac3_inputs() { + let raw_input = write_test_ac3_file("mux-transport-stream-ac3-raw-input", &[b"ac3", b"ts"]); + let expected_payload = fs::read(&raw_input).unwrap(); + let ts_input = + write_test_transport_stream_ac3_file("mux-transport-stream-ac3-input", &[b"ac3", b"ts"]); + let output_path = write_temp_file("mux-transport-stream-ac3-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ac-3"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ac-3")); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 2_880, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_latm_inputs() { + let ts_input = write_test_transport_stream_latm_file( + "mux-transport-stream-latm-input", + &[b"abc", b"defg"], + ); + let output_path = write_temp_file("mux-transport-stream-latm-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"abcdefg"); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + fourcc("esds"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("mp4a")); + assert_eq!(esds_boxes.len(), 1); + assert_eq!( + esds_boxes[0] + .decoder_config_descriptor() + .unwrap() + .object_type_indication, + 0x40 + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 1_920, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_latm_inputs_with_other_data_present() { + let ts_input = write_test_transport_stream_latm_other_data_file( + "mux-transport-stream-latm-other-data-input", + &[b"abc", b"defg"], + ); + let output_path = write_temp_file("mux-transport-stream-latm-other-data-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"abcdefg"); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_mhas_inputs() { + let raw_input = write_test_mhas_file( + "mux-transport-stream-mhas-raw-input", + &[b"frame-one", b"frame-two"], + ); + let expected_payload = fs::read(&raw_input).unwrap(); + let ts_input = write_test_transport_stream_mhas_file( + "mux-transport-stream-mhas-input", + &[b"frame-one", b"frame-two"], + ); + let output_path = write_temp_file("mux-transport-stream-mhas-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mhm1"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("mhm1")); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 1_920, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_eac3_inputs() { + let raw_input = write_test_eac3_file("mux-transport-stream-eac3-raw-input", &[b"ec3", b"ts"]); + let expected_payload = fs::read(&raw_input).unwrap(); + let ts_input = + write_test_transport_stream_eac3_file("mux-transport-stream-eac3-input", &[b"ec3", b"ts"]); + let output_path = write_temp_file("mux-transport-stream-eac3-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ec-3"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ec-3")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 2_880, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_ac4_inputs() { + let expected_payload = build_test_ac4_sample_payload_bytes(2); + let ts_input = write_test_transport_stream_ac4_file("mux-transport-stream-ac4-input", 2); + let output_path = write_temp_file("mux-transport-stream-ac4-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ac-4"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ac-4")); + assert_eq!(mdhd_boxes.len(), 1); + assert!(mdhd_boxes[0].timescale > 0); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_truehd_inputs() { + let expected_payload = build_test_truehd_stream_bytes(&[b"abcdefgh", b"ijklmnop"]); + let ts_input = write_test_transport_stream_truehd_file( + "mux-transport-stream-truehd-input", + &[b"abcdefgh", b"ijklmnop"], + ); + let output_path = write_temp_file("mux-transport-stream-truehd-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mlpa"), + ]), + ); + let dmlp_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mlpa"), + fourcc("dmlp"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mlpa"), + fourcc("btrt"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("mlpa")); + assert_eq!(dmlp_boxes.len(), 1); + assert_eq!(btrt_boxes.len(), 1); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(btrt_boxes[0].buffer_size_db, 28); + assert_eq!(btrt_boxes[0].max_bitrate, 268_800); + assert_eq!(btrt_boxes[0].avg_bitrate, 268_800); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 75, + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_dts_inputs() { + let raw_input = write_test_dts_file("mux-transport-stream-dts-raw-input", 2); + let expected_payload = fs::read(&raw_input).unwrap(); + let ts_input = write_test_transport_stream_dts_file("mux-transport-stream-dts-input", 2); + let output_path = write_temp_file("mux-transport-stream-dts-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dtsx"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("dtsx")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(audio_entries[0].sample_rate, 0); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_dts_stream_type_inputs() { + let raw_input = write_test_dts_file("mux-transport-stream-dts-stream-type-raw-input", 2); + let expected_payload = fs::read(&raw_input).unwrap(); + let ts_input = write_test_transport_stream_dts_stream_type_file( + "mux-transport-stream-dts-stream-type-input", + 2, + ); + let output_path = write_temp_file("mux-transport-stream-dts-stream-type-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dtsx"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("dtsx")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(audio_entries[0].sample_rate, 0); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_dvb_subtitle_inputs() { + let ts_input = write_test_transport_stream_dvb_subtitle_file( + "mux-transport-stream-dvb-subtitle-input", + &[b"\x20sub-1", b"\x21sub-2"], + ); + let output_path = write_temp_file("mux-transport-stream-dvb-subtitle-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let subtitle_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dvbs"), + ]), + ); + let dvsc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dvbs"), + fourcc("dvsC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + let root_boxes = read_root_boxes(&output_bytes); + + assert_eq!(subtitle_entries.len(), 1); + assert_eq!(subtitle_entries[0].sample_entry.box_type, fourcc("dvbs")); + assert_eq!(dvsc_boxes.len(), 1); + assert_eq!(dvsc_boxes[0].composition_page_id, 0x0123); + assert_eq!(dvsc_boxes[0].ancillary_page_id, 0x0456); + assert_eq!(dvsc_boxes[0].subtitle_type, 0x10); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 1_000); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("subt")); + assert_eq!(hdlr_boxes[0].name, "SubtitleHandler"); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"\x20sub-1\x21sub-2" + ); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_dvb_teletext_inputs() { + let ts_input = write_test_transport_stream_dvb_teletext_file( + "mux-transport-stream-dvb-teletext-input", + &[b"\x10text-1", b"\x11text-2"], + ); + let output_path = write_temp_file("mux-transport-stream-dvb-teletext-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let subtitle_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dvbt"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + let root_boxes = read_root_boxes(&output_bytes); + + assert_eq!(subtitle_entries.len(), 1); + assert_eq!(subtitle_entries[0].sample_entry.box_type, fourcc("dvbt")); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 1_000); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("subt")); + assert_eq!(hdlr_boxes[0].name, "SubtitleHandler"); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"\x10text-1\x11text-2" + ); +} + +#[test] +fn mux_to_path_imports_path_only_vobsub_idx_inputs() { + let (idx_input, _sub_input) = write_test_vobsub_files( + "mux-vobsub-idx-input", + &[0, 1_000], + &[b"\xAA\xBB", b"\xCC\xDD"], + ); + let output_path = write_temp_file("mux-vobsub-idx-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&idx_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let subtitle_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4s"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4s"), + fourcc("esds"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let nmhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("nmhd"), + ]), + ); + let sthd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("sthd"), + ]), + ); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + let stsc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + let stco_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stco"), + ]), + ); + + assert_eq!(subtitle_entries.len(), 1); + assert_eq!(subtitle_entries[0].sample_entry.box_type, fourcc("mp4s")); + assert_eq!(esds_boxes.len(), 1); + let decoder_config = esds_boxes[0].decoder_config_descriptor().unwrap(); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(mdhd_boxes[0].duration_v0, 90_000); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("subp")); + assert_eq!(hdlr_boxes[0].name, "SubtitleHandler"); + assert_eq!(nmhd_boxes.len(), 1); + assert_eq!(sthd_boxes.len(), 0); + assert_eq!(iods_boxes.len(), 1); + let iods_descriptor = iods_boxes[0].initial_object_descriptor().unwrap(); + assert_eq!(iods_descriptor.audio_profile_level_indication, 0xff); + assert_eq!(iods_descriptor.visual_profile_level_indication, 0xff); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(stsz_boxes[0].sample_count, 2); + let expected_buffer_size = stsz_boxes[0].sample_size; + let expected_bitrate = expected_buffer_size + .checked_mul(stsz_boxes[0].sample_count) + .and_then(|value| value.checked_mul(8)) + .unwrap(); + assert_eq!(decoder_config.buffer_size_db, expected_buffer_size); + assert_eq!(decoder_config.max_bitrate, expected_bitrate); + assert_eq!(decoder_config.avg_bitrate, expected_bitrate); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries.len(), 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 90_000); + assert_eq!(stts_boxes[0].entries[1].sample_delta, 0); + assert_eq!(stsc_boxes.len(), 1); + assert_eq!(stsc_boxes[0].entries.len(), 2); + assert_eq!(stsc_boxes[0].entries[0].first_chunk, 1); + assert_eq!(stsc_boxes[0].entries[0].samples_per_chunk, 1); + assert_eq!(stsc_boxes[0].entries[1].first_chunk, 2); + assert_eq!(stsc_boxes[0].entries[1].samples_per_chunk, 1); + assert_eq!(stco_boxes.len(), 1); + assert_eq!(stco_boxes[0].entry_count, 2); +} + +#[test] +fn mux_to_path_imports_path_only_program_stream_vobsub_inputs() { + let ps_input = write_test_program_stream_vobsub_file( + "mux-program-stream-vobsub-input", + &[0, 1_000], + &[b"\xAA\xBB", b"\xCC\xDD"], + ); + let output_path = write_temp_file("mux-program-stream-vobsub-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let subtitle_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4s"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4s"), + fourcc("esds"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let nmhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("nmhd"), + ]), + ); + let sthd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("sthd"), + ]), + ); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + let stsc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + let stco_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stco"), + ]), + ); + + assert_eq!(subtitle_entries.len(), 1); + assert_eq!(subtitle_entries[0].sample_entry.box_type, fourcc("mp4s")); + assert_eq!(esds_boxes.len(), 1); + let decoder_config = esds_boxes[0].decoder_config_descriptor().unwrap(); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 90_000); + assert_eq!(mdhd_boxes[0].duration_v0, 90_000); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("subp")); + assert_eq!(hdlr_boxes[0].name, "SubtitleHandler"); + assert_eq!(nmhd_boxes.len(), 1); + assert_eq!(sthd_boxes.len(), 0); + assert_eq!(iods_boxes.len(), 1); + let iods_descriptor = iods_boxes[0].initial_object_descriptor().unwrap(); + assert_eq!(iods_descriptor.audio_profile_level_indication, 0xff); + assert_eq!(iods_descriptor.visual_profile_level_indication, 0xff); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(stsz_boxes[0].sample_count, 2); + let expected_buffer_size = stsz_boxes[0].sample_size; + let expected_bitrate = expected_buffer_size + .checked_mul(stsz_boxes[0].sample_count) + .and_then(|value| value.checked_mul(8)) + .unwrap(); + assert_eq!(decoder_config.buffer_size_db, expected_buffer_size); + assert_eq!(decoder_config.max_bitrate, expected_bitrate); + assert_eq!(decoder_config.avg_bitrate, expected_bitrate); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries.len(), 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 90_000); + assert_eq!(stts_boxes[0].entries[1].sample_delta, 0); + assert_eq!(stsc_boxes.len(), 1); + assert_eq!(stsc_boxes[0].entries.len(), 2); + assert_eq!(stsc_boxes[0].entries[0].first_chunk, 1); + assert_eq!(stsc_boxes[0].entries[0].samples_per_chunk, 1); + assert_eq!(stsc_boxes[0].entries[1].first_chunk, 2); + assert_eq!(stsc_boxes[0].entries[1].samples_per_chunk, 1); + assert_eq!(stco_boxes.len(), 1); + assert_eq!(stco_boxes[0].entry_count, 2); +} + +#[test] +fn mux_to_path_imports_path_only_transport_stream_mp3_inputs() { + let ts_input = write_test_transport_stream_mp3_file( + "mux-transport-stream-mp3-input", + &[&[0x33; 320], &[0x44; 320]], + ); + let output_path = write_temp_file("mux-transport-stream-mp3-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(".mp3"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc(".mp3")); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); +} + +#[test] +fn mux_to_path_imports_first_program_from_multi_program_transport_stream() { + let ts_input = write_test_transport_stream_multi_program_mp3_file( + "mux-transport-stream-multi-program-mp3-input", + &[&[0x55; 320], &[0x66; 320]], + ); + let output_path = write_temp_file("mux-transport-stream-multi-program-mp3-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(".mp3"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc(".mp3")); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); +} + +#[test] +fn mux_to_path_imports_first_program_from_multi_program_transport_stream_to_fragmented_output() { + let ts_input = write_test_transport_stream_multi_program_mp3_file( + "mux-fragmented-transport-stream-multi-program-mp3-input", + &[&[0x77; 320], &[0x88; 320]], + ); + let output_path = write_temp_file( + "mux-fragmented-transport-stream-multi-program-mp3-output", + &[], + ); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 1.0 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let trun_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("trun")]), + ); + + assert_eq!(trun_boxes.len(), 1); + assert_eq!(trun_boxes[0].sample_count, 2); +} + +#[test] +fn mux_to_path_selects_one_audio_track_from_avi_inputs() { + let first_chunk = [0_u8, 0, 0, 0, 1, 0, 1, 0]; + let second_chunk = [2_u8, 0, 2, 0, 3, 0, 3, 0]; + let avi_input = write_test_avi_pcm_file( + "mux-avi-select-input", + &[ + TestAviPcmStream { + sample_rate: 48_000, + channel_count: 2, + bits_per_sample: 16, + chunks: &[&first_chunk], + }, + TestAviPcmStream { + sample_rate: 48_000, + channel_count: 2, + bits_per_sample: 16, + chunks: &[&second_chunk], + }, + ], + ); + let output_path = write_temp_file("mux-avi-select-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::selected( + &avi_input, + MuxMp4TrackSelector::Audio { occurrence: 2 }, + )]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(hdlr_boxes.len(), 1); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), second_chunk); +} + +#[test] +fn copy_planned_payloads_uses_the_planned_output_order() { + let mut sources = [ + Cursor::new(b"AAAAhelloBBBBxy".to_vec()), + Cursor::new(b"zzzzSYNCtail".to_vec()), + ]; + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 2, 10, 4, 13, 2), + MuxStagedMediaItem::new(1, 1, 0, 5, 4, 4), + MuxStagedMediaItem::new(0, 2, 0, 4, 4, 5), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut output = Vec::new(); + copy_planned_payloads(&mut sources, &mut output, &plan).unwrap(); + + assert_eq!(output, b"helloSYNCxy"); +} + +#[test] +fn copy_planned_payloads_progressive_supports_non_seekable_readers() { + let mut first_source: &[u8] = b"AAAAhelloBBBBxy"; + let mut second_source: &[u8] = b"zzzzSYNCtail"; + let mut sources = [&mut first_source, &mut second_source]; + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 4, 4, 5), + MuxStagedMediaItem::new(1, 2, 5, 4, 4, 4), + MuxStagedMediaItem::new(0, 1, 10, 4, 13, 2), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut output = Vec::new(); + copy_planned_payloads_progressive(&mut sources, &mut output, &plan).unwrap(); + + assert_eq!(output, b"helloSYNCxy"); +} + +#[test] +fn copy_planned_payloads_progressive_rejects_backward_offsets_per_source() { + let mut source: &[u8] = b"AAAAhelloBBBBxy"; + let mut sources = [&mut source]; + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 4, 13, 2), + MuxStagedMediaItem::new(0, 1, 10, 4, 4, 5), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut output = Vec::new(); + let error = copy_planned_payloads_progressive(&mut sources, &mut output, &plan).unwrap_err(); + + assert_eq!( + error.to_string(), + "source index 0 would need to move backward from offset 15 to 4" + ); + assert!(matches!( + error, + MuxError::NonMonotonicSourceOffset { + source_index: 0, + previous_offset: 15, + next_offset: 4, + } + )); +} + +#[test] +fn copy_planned_payloads_to_path_matches_in_memory_output() { + let first_source = write_temp_file("mux-source-a", b"HEADvideoTAIL"); + let second_source = write_temp_file("mux-source-b", b"PREMaudPOST"); + let output_path = write_temp_file("mux-output-sync", &[]); + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 2, 10, 4, 4, 5), + MuxStagedMediaItem::new(1, 1, 0, 4, 4, 3), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + copy_planned_payloads_to_path(&[&first_source, &second_source], &output_path, &plan).unwrap(); + + assert_eq!(fs::read(output_path).unwrap(), b"audvideo"); +} + +#[test] +fn mux_to_path_merges_mp4_track_specs_and_uses_the_first_mp4_as_authority() { + let audio_input = build_audio_input_file("mux-request-audio-input", fourcc("dash"), &[b"aud"]); + let video_input = + build_video_input_file("mux-request-video-input", fourcc("isom"), &[b"video"]); + let output_path = write_temp_file("mux-request-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::mp4(&audio_input, MuxMp4TrackSelector::Audio { occurrence: 1 }), + MuxTrackSpec::mp4(&video_input, MuxMp4TrackSelector::Video), + ]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"audvideo"); + + let ftyp = extract_boxes::(&output_bytes, BoxPath::from([fourcc("ftyp")])); + assert_eq!(ftyp.len(), 1); + assert_eq!(ftyp[0].major_brand, fourcc("isom")); +} + +#[test] +fn mux_to_path_writes_flat_multiple_video_tracks() { + let first_video = + build_video_input_file("mux-flat-multi-video-first-input", fourcc("isom"), &[b"v1"]); + let audio = + build_audio_input_file("mux-flat-multi-video-audio-input", fourcc("dash"), &[b"a1"]); + let second_video = build_video_input_file( + "mux-flat-multi-video-second-input", + fourcc("isom"), + &[b"v2"], + ); + let output = write_temp_file("mux-flat-multi-video-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::mp4(&first_video, MuxMp4TrackSelector::Video), + MuxTrackSpec::mp4(&audio, MuxMp4TrackSelector::Audio { occurrence: 1 }), + MuxTrackSpec::mp4(&second_video, MuxMp4TrackSelector::Video), + ]); + + mux_to_path(&request, &output).unwrap(); + + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"v1a1v2"); + + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!( + hdlr_boxes + .iter() + .map(|hdlr| hdlr.handler_type) + .collect::>(), + vec![fourcc("vide"), fourcc("soun"), fourcc("vide")] + ); + + let tkhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("tkhd")]), + ); + assert_eq!( + tkhd_boxes + .iter() + .map(|tkhd| tkhd.track_id) + .collect::>(), + vec![1, 2, 3] + ); +} + +#[test] +fn mux_to_path_imports_flat_mp4_compact_sample_sizes() { + let input = build_audio_input_file( + "mux-flat-compact-size-source", + fourcc("isom"), + &[b"a", b"bc", b"def", b"ghij"], + ); + let input_bytes = fs::read(&input).unwrap(); + let sample_size_table_path = [ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]; + let (_, replaced_size) = find_first_box_at_path(&input_bytes, &sample_size_table_path).unwrap(); + + for (field_size, label) in [(4, "nibble"), (8, "byte"), (16, "word")] { + let mut replacement = encode_compact_sample_size_box(field_size, &[1, 2, 3, 4]); + replacement.extend(encode_free_padding_box(replaced_size - replacement.len())); + let compact_input_bytes = + replace_same_size_box_at_path(&input_bytes, &sample_size_table_path, &replacement); + let compact_input = write_temp_file( + &format!("mux-flat-compact-size-input-{label}"), + &compact_input_bytes, + ); + let output = write_temp_file(&format!("mux-flat-compact-size-output-{label}"), &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&compact_input)]); + + mux_to_path(&request, &output).unwrap(); + + let output_bytes = fs::read(output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"abcdefghij"); + + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(stsz_boxes[0].sample_count, 4); + assert_eq!(stsz_boxes[0].entry_size, vec![1, 2, 3, 4]); + } +} + +#[test] +fn mux_to_path_preserves_flat_mp4_multiple_sample_descriptions() { + let input = build_multi_description_audio_input_file("mux-flat-multi-description-input"); + let output = write_temp_file("mux-flat-multi-description-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]); + + mux_to_path(&request, &output).unwrap(); + + let output_bytes = fs::read(output).unwrap(); + let stsd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + ]), + ); + assert_eq!(stsd_boxes.len(), 1); + assert_eq!(stsd_boxes[0].entry_count, 2); + let stsc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + assert_eq!(stsc_boxes.len(), 1); + assert_eq!(stsc_boxes[0].entries[0].sample_description_index, 2); +} + +#[test] +fn mux_to_path_rejects_flat_mp4_stsc_sample_description_switching() { + let input = build_audio_input_file( + "mux-flat-stsc-description-switch-source", + fourcc("isom"), + &[b"a1", b"a2"], + ); + let patched = patch_first_stsc_sample_description_index(&fs::read(input).unwrap(), 2); + let patched_input = write_temp_file("mux-flat-stsc-description-switch-input", &patched); + let output = write_temp_file("mux-flat-stsc-description-switch-output", b"unchanged"); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &patched_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]); + + let error = mux_to_path(&request, &output).unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains("sample description index 2 with 1 sample entries"), + "{message}" + ); + assert!(matches!(error, MuxError::UnsupportedTrackImport { .. })); + assert_eq!(fs::read(output).unwrap(), b"unchanged"); +} + +#[test] +fn mux_into_path_preserves_an_existing_mp4_destination() { + let destination = + build_video_input_file("mux-destination-video-input", fourcc("isom"), &[b"video"]); + let audio_input = write_test_adts_file("mux-destination-audio-input", &[b"aud"]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&audio_input)]); + + mux_into_path(&request, &destination).unwrap(); + + let output_bytes = fs::read(&destination).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(hdlr_boxes.len(), 2); +} + +#[test] +fn mux_into_path_appends_compatible_audio_to_existing_destination_track() { + let destination = build_audio_input_file( + "mux-destination-append-audio-base", + fourcc("isom"), + &[b"a1"], + ); + let audio_input = + build_audio_input_file("mux-destination-append-audio-new", fourcc("isom"), &[b"a2"]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]); + + mux_into_path(&request, &destination).unwrap(); + + let output_bytes = fs::read(&destination).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"a1a2"); + + let tkhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("tkhd")]), + ); + assert_eq!(tkhd_boxes.len(), 1); + assert_eq!(tkhd_boxes[0].track_id, 1); + + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(stsz_boxes[0].sample_count, 2); +} + +#[test] +fn mux_into_path_appends_audio_with_only_noncritical_sample_entry_difference() { + let first_btrt = encode_supported_box( + &Btrt { + buffer_size_db: 1, + max_bitrate: 2, + avg_bitrate: 3, + }, + &[], + ); + let second_btrt = encode_supported_box( + &Btrt { + buffer_size_db: 4, + max_bitrate: 5, + avg_bitrate: 6, + }, + &[], + ); + let destination = write_single_track_mp4_input( + "mux-destination-append-audio-btrt-base", + &MuxFileConfig::new(1_000).with_major_brand(fourcc("isom")), + MuxTrackConfig::new_audio( + 1, + 1_000, + audio_sample_entry_box_with_children("mp4a", &first_btrt), + ), + &[TestMuxSample { + bytes: b"a1", + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }], + ); + let audio_input = write_single_track_mp4_input( + "mux-destination-append-audio-btrt-new", + &MuxFileConfig::new(1_000).with_major_brand(fourcc("isom")), + MuxTrackConfig::new_audio( + 1, + 1_000, + audio_sample_entry_box_with_children("mp4a", &second_btrt), + ), + &[TestMuxSample { + bytes: b"a2", + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }], + ); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]); + + mux_into_path(&request, &destination).unwrap(); + + let output_bytes = fs::read(&destination).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"a1a2"); + let stsd = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + ]), + ); + assert_eq!(stsd.len(), 1); + assert_eq!(stsd[0].entry_count, 1); +} + +#[test] +fn mux_to_path_rewrites_local_data_reference_samples_into_self_contained_output() { + let (input, _reference_media) = build_external_reference_audio_input_file( + "mux-external-reference-audio", + b"xxa1a2", + &[2, 2], + ); + let output_path = write_temp_file("mux-external-reference-audio-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"a1a2"); + let dref_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("dinf"), + fourcc("dref"), + ]), + ); + assert_eq!(dref_boxes.len(), 1); + assert_eq!(dref_boxes[0].entry_count, 1); + let sample_entries = extract_box_bytes( + &mut Cursor::new(output_bytes), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + ]), + ) + .unwrap(); + assert_eq!(&sample_entries[0][14..16], &1_u16.to_be_bytes()); +} + +#[test] +fn mux_to_path_rejects_missing_local_data_reference_before_output_mutation() { + let (input, reference_media) = build_external_reference_audio_input_file( + "mux-external-reference-missing-audio", + b"xxa1a2", + &[2, 2], + ); + fs::remove_file(reference_media).unwrap(); + let output_path = write_temp_file("mux-external-reference-missing-output", b"unchanged"); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]); + + let error = mux_to_path(&request, &output_path).unwrap_err(); + + assert!( + error + .to_string() + .contains("failed to inspect referenced media"), + "{error}" + ); + assert_eq!(fs::read(output_path).unwrap(), b"unchanged"); +} + +#[test] +fn mux_into_path_adds_compatible_video_as_separate_destination_track() { + let destination = build_video_input_file( + "mux-destination-append-video-base", + fourcc("isom"), + &[b"v1"], + ); + let video_input = + build_video_input_file("mux-destination-append-video-new", fourcc("isom"), &[b"v2"]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &video_input, + MuxMp4TrackSelector::Video, + )]); + + mux_into_path(&request, &destination).unwrap(); + + let output_bytes = fs::read(&destination).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"v1v2"); + + let tkhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("tkhd")]), + ); + assert_eq!(tkhd_boxes.len(), 2); + assert_eq!( + tkhd_boxes + .iter() + .map(|tkhd| tkhd.track_id) + .collect::>(), + vec![1, 2] + ); + + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + assert_eq!(stsz_boxes.len(), 2); + assert_eq!( + stsz_boxes + .iter() + .map(|stsz| stsz.sample_count) + .collect::>(), + vec![1, 1] + ); +} + +#[test] +fn mux_into_path_adds_compatible_video_with_noncritical_difference_as_separate_track() { + let first_btrt = encode_supported_box( + &Btrt { + buffer_size_db: 1, + max_bitrate: 2, + avg_bitrate: 3, + }, + &[], + ); + let second_btrt = encode_supported_box( + &Btrt { + buffer_size_db: 4, + max_bitrate: 5, + avg_bitrate: 6, + }, + &[], + ); + let destination = write_single_track_mp4_input( + "mux-destination-append-video-btrt-base", + &MuxFileConfig::new(1_000).with_major_brand(fourcc("isom")), + MuxTrackConfig::new_video( + 1, + 1_000, + 640, + 360, + video_sample_entry_box_with_children("avc1", &first_btrt), + ), + &[TestMuxSample { + bytes: b"v1", + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }], + ); + let video_input = write_single_track_mp4_input( + "mux-destination-append-video-btrt-new", + &MuxFileConfig::new(1_000).with_major_brand(fourcc("isom")), + MuxTrackConfig::new_video( + 1, + 1_000, + 640, + 360, + video_sample_entry_box_with_children("avc1", &second_btrt), + ), + &[TestMuxSample { + bytes: b"v2", + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }], + ); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &video_input, + MuxMp4TrackSelector::Video, + )]); + + mux_into_path(&request, &destination).unwrap(); + + let output_bytes = fs::read(&destination).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"v1v2"); + let stsd = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + ]), + ); + assert_eq!(stsd.len(), 2); + assert_eq!( + stsd.iter().map(|stsd| stsd.entry_count).collect::>(), + vec![1, 1] + ); +} + +#[test] +fn mux_into_path_adds_video_with_distinct_metadata_as_separate_track() { + let first_pasp = encode_supported_box( + &Pasp { + h_spacing: 1, + v_spacing: 1, + }, + &[], + ); + let second_pasp = encode_supported_box( + &Pasp { + h_spacing: 4, + v_spacing: 3, + }, + &[], + ); + let destination = write_single_track_mp4_input( + "mux-destination-append-video-pasp-base", + &MuxFileConfig::new(1_000).with_major_brand(fourcc("isom")), + MuxTrackConfig::new_video( + 1, + 1_000, + 640, + 360, + video_sample_entry_box_with_children("avc1", &first_pasp), + ), + &[TestMuxSample { + bytes: b"v1", + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }], + ); + let video_input = write_single_track_mp4_input( + "mux-destination-append-video-pasp-new", + &MuxFileConfig::new(1_000).with_major_brand(fourcc("isom")), + MuxTrackConfig::new_video( + 1, + 1_000, + 640, + 360, + video_sample_entry_box_with_children("avc1", &second_pasp), + ), + &[TestMuxSample { + bytes: b"v2", + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }], + ); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &video_input, + MuxMp4TrackSelector::Video, + )]); + + mux_into_path(&request, &destination).unwrap(); + + let output_bytes = fs::read(&destination).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"v1v2"); + + let stsd = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + ]), + ); + assert_eq!(stsd.len(), 2); + assert_eq!( + stsd.iter().map(|stsd| stsd.entry_count).collect::>(), + vec![1, 1] + ); + + let stsc = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + assert_eq!(stsc.len(), 2); + assert_eq!(stsc[0].entries.len(), 1); + assert_eq!(stsc[0].entries[0].first_chunk, 1); + assert_eq!(stsc[0].entries[0].sample_description_index, 1); + assert_eq!(stsc[1].entries.len(), 1); + assert_eq!(stsc[1].entries[0].first_chunk, 1); + assert_eq!(stsc[1].entries[0].sample_description_index, 1); +} + +#[test] +fn mux_into_path_rejects_ambiguous_compatible_destination_append_targets() { + let first_destination_track = build_audio_input_file( + "mux-destination-append-ambiguous-audio-a", + fourcc("isom"), + &[b"a1"], + ); + let second_destination_track = build_audio_input_file( + "mux-destination-append-ambiguous-audio-b", + fourcc("isom"), + &[b"a2"], + ); + let destination = write_temp_file("mux-destination-append-ambiguous-output", &[]); + let seed_request = MuxRequest::new(vec![ + MuxTrackSpec::mp4( + &first_destination_track, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + ), + MuxTrackSpec::mp4( + &second_destination_track, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + ), + ]); + mux_to_path(&seed_request, &destination).unwrap(); + + let incoming_track = build_audio_input_file( + "mux-destination-append-ambiguous-audio-incoming", + fourcc("isom"), + &[b"a3"], + ); + let append_request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &incoming_track, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]); + let before_update = fs::read(&destination).unwrap(); + + let error = mux_into_path(&append_request, &destination).unwrap_err(); + + assert_eq!( + error.to_string(), + "invalid mux destination mode `update-or-create-destination`: destination update found multiple compatible append targets for one audio track" + ); + assert!(matches!(error, MuxError::InvalidDestinationMode { .. })); + assert_eq!(fs::read(destination).unwrap(), before_update); +} + +#[cfg(feature = "async")] +#[tokio::test] +async fn mux_into_path_async_preserves_an_existing_mp4_destination() { + let destination = build_video_input_file( + "mux-destination-async-video-input", + fourcc("isom"), + &[b"video"], + ); + let audio_input = write_test_adts_file("mux-destination-async-audio-input", &[b"aud"]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&audio_input)]); + + mp4forge::mux::mux_into_path_async(&request, &destination) + .await + .unwrap(); + + let output_bytes = fs::read(&destination).unwrap(); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(hdlr_boxes.len(), 2); +} + +#[test] +fn mux_to_path_rejects_duration_modes_for_flat_layout() { + let audio_input = + build_audio_input_file("mux-flat-duration-audio-input", fourcc("dash"), &[b"aud"]); + let output_path = write_temp_file("mux-flat-duration-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 0.25 }); + + let error = mux_to_path(&request, &output_path).unwrap_err(); + + assert_eq!( + error.to_string(), + "invalid mux layout `flat`: flat output does not support `--fragment_duration`; use `--layout fragmented` instead" + ); + assert!(matches!( + error, + MuxError::InvalidOutputLayout { layout: "flat", .. } + )); +} + +#[test] +fn mux_to_path_requires_one_duration_mode_for_fragmented_layout() { + let audio_input = build_audio_input_file( + "mux-fragmented-no-duration-input", + fourcc("dash"), + &[b"aud"], + ); + let output_path = write_temp_file("mux-fragmented-no-duration-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented); + + let error = mux_to_path(&request, &output_path).unwrap_err(); + + assert_eq!( + error.to_string(), + "invalid mux layout `fragmented`: fragmented output requires exactly one of `--segment_duration` or `--fragment_duration`" + ); + assert!(matches!( + error, + MuxError::InvalidOutputLayout { + layout: "fragmented", + .. + } + )); +} + +#[test] +fn mux_to_path_rejects_fragmented_multiple_video_tracks() { + let output = write_temp_file("mux-fragmented-multi-video-reject-output", b"unchanged"); + let request = MuxRequest::new(vec![ + MuxTrackSpec::selected("first.mp4", MuxMp4TrackSelector::Video), + MuxTrackSpec::selected("second.mp4", MuxMp4TrackSelector::Video), + ]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 1.0 }); + + let error = mux_to_path(&request, &output).unwrap_err(); + + assert_eq!( + error.to_string(), + "fragmented output supports at most one video track per mux output, but 2 were requested" + ); + assert!(matches!(error, MuxError::MultipleVideoTracks { count: 2 })); + assert_eq!(fs::read(output).unwrap(), b"unchanged"); +} + +#[test] +fn mux_to_path_rejects_fragmented_path_only_mp4_with_multiple_video_tracks() { + let first_video = build_video_input_file( + "mux-fragmented-path-only-multi-video-first-input", + fourcc("isom"), + &[b"v1"], + ); + let second_video = build_video_input_file( + "mux-fragmented-path-only-multi-video-second-input", + fourcc("isom"), + &[b"v2"], + ); + let multi_video_source = write_temp_file("mux-fragmented-path-only-multi-video-source", &[]); + let seed_request = MuxRequest::new(vec![ + MuxTrackSpec::mp4(&first_video, MuxMp4TrackSelector::Video), + MuxTrackSpec::mp4(&second_video, MuxMp4TrackSelector::Video), + ]); + mux_to_path(&seed_request, &multi_video_source).unwrap(); + + let output = write_temp_file("mux-fragmented-path-only-multi-video-output", b"unchanged"); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&multi_video_source)]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 1.0 }); + + let error = mux_to_path(&request, &output).unwrap_err(); + + assert_eq!( + error.to_string(), + "fragmented output supports at most one video track per mux output, but 2 were requested" + ); + assert!(matches!(error, MuxError::MultipleVideoTracks { count: 2 })); + assert_eq!(fs::read(output).unwrap(), b"unchanged"); +} + +#[test] +fn mux_to_path_rejects_fragmented_import_missing_decode_time() { + let audio_input = build_audio_input_file( + "mux-fragmented-missing-decode-time-source", + fourcc("dash"), + &[b"a1"], + ); + let fragmented_input = write_temp_file("mux-fragmented-missing-decode-time-fragmented", &[]); + let fragmented_request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 1.0 }); + mux_to_path(&fragmented_request, &fragmented_input).unwrap(); + + let fragmented_bytes = fs::read(&fragmented_input).unwrap(); + let decode_time_path = [fourcc("moof"), fourcc("traf"), fourcc("tfdt")]; + let (_, decode_time_size) = + find_first_box_at_path(&fragmented_bytes, &decode_time_path).unwrap(); + let patched_bytes = replace_same_size_box_at_path( + &fragmented_bytes, + &decode_time_path, + &encode_free_padding_box(decode_time_size), + ); + let patched_input = write_temp_file("mux-fragmented-missing-decode-time-input", &patched_bytes); + let output = write_temp_file("mux-fragmented-missing-decode-time-output", b"unchanged"); + let import_request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &patched_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]); + + let error = mux_to_path(&import_request, &output).unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains("non-empty fragmented run without tfdt decode time"), + "{message}" + ); + assert!(matches!(error, MuxError::UnsupportedTrackImport { .. })); + assert_eq!(fs::read(output).unwrap(), b"unchanged"); +} + +#[test] +fn mux_to_path_rejects_fragmented_import_sample_description_switching() { + let audio_input = build_audio_input_file( + "mux-fragmented-description-switch-source", + fourcc("dash"), + &[b"a1"], + ); + let fragmented_input = write_temp_file("mux-fragmented-description-switch-fragmented", &[]); + let fragmented_request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 1.0 }); + mux_to_path(&fragmented_request, &fragmented_input).unwrap(); + + let patched = + patch_first_tfhd_sample_description_index(&fs::read(fragmented_input).unwrap(), 2); + let patched_input = write_temp_file("mux-fragmented-description-switch-input", &patched); + let output = write_temp_file("mux-fragmented-description-switch-output", b"unchanged"); + let import_request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &patched_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]); + + let error = mux_to_path(&import_request, &output).unwrap_err(); + let message = error.to_string(); + + assert!(message.contains("sample description index 2"), "{message}"); + assert!(matches!(error, MuxError::UnsupportedTrackImport { .. })); + assert_eq!(fs::read(output).unwrap(), b"unchanged"); +} + +#[test] +fn mux_to_path_imports_fragmented_sample_description_index() { + let input = build_multi_description_audio_input_file("mux-frag-description-index-source"); + let fragmented_input = write_temp_file("mux-frag-description-index-fragmented", &[]); + let fragmented_request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 1.0 }); + mux_to_path(&fragmented_request, &fragmented_input).unwrap(); + + let fragmented_bytes = fs::read(&fragmented_input).unwrap(); + let init_stsd = extract_boxes::( + &fragmented_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + ]), + ); + assert_eq!(init_stsd.len(), 1); + assert_eq!(init_stsd[0].entry_count, 2); + let tfhd = extract_boxes::( + &fragmented_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfhd")]), + ); + assert_eq!(tfhd.len(), 1); + assert_eq!(tfhd[0].sample_description_index, 2); + + let output = write_temp_file("mux-frag-description-index-output", &[]); + let import_request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &fragmented_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]); + mux_to_path(&import_request, &output).unwrap(); + + let output_bytes = fs::read(output).unwrap(); + let output_stsd = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + ]), + ); + assert_eq!(output_stsd.len(), 1); + assert_eq!(output_stsd[0].entry_count, 2); + let output_stsc = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + assert_eq!(output_stsc.len(), 1); + assert_eq!(output_stsc[0].entries[0].sample_description_index, 2); +} + +#[test] +fn mux_to_path_writes_fragmented_multi_track_output() { + let audio_input = build_audio_input_file( + "mux-fragmented-multi-audio-input", + fourcc("dash"), + &[b"a1", b"a2", b"a3"], + ); + let video_input = build_video_input_file( + "mux-fragmented-multi-video-input", + fourcc("isom"), + &[b"v1", b"v2", b"v3"], + ); + let output_path = write_temp_file("mux-fragmented-multi-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::mp4(&audio_input, MuxMp4TrackSelector::Audio { occurrence: 1 }), + MuxTrackSpec::mp4(&video_input, MuxMp4TrackSelector::Video), + ]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 0.015 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("sidx"), + fourcc("moof"), + fourcc("mdat"), + fourcc("moof"), + fourcc("mdat"), + ] + ); + assert_eq!(mdat_payload(&output_bytes, root_boxes[4]), b"a1a2v1v2"); + assert_eq!(mdat_payload(&output_bytes, root_boxes[6]), b"a3v3"); + + let tkhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("tkhd")]), + ); + assert_eq!(tkhd_boxes.len(), 2); + assert_eq!( + tkhd_boxes + .iter() + .map(|tkhd| tkhd.track_id) + .collect::>(), + vec![1, 2] + ); + + let trex_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("mvex"), fourcc("trex")]), + ); + assert_eq!(trex_boxes.len(), 2); + assert_eq!( + trex_boxes + .iter() + .map(|trex| trex.track_id) + .collect::>(), + vec![1, 2] + ); + + let mehd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("mvex"), fourcc("mehd")]), + ); + assert_eq!(mehd_boxes.len(), 1); + assert_eq!(mehd_boxes[0].fragment_duration_v0, 30); + + let tfhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfhd")]), + ); + assert_eq!( + tfhd_boxes + .iter() + .map(|tfhd| tfhd.track_id) + .collect::>(), + vec![1, 2, 1, 2] + ); + + let trun_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("trun")]), + ); + assert_eq!( + trun_boxes + .iter() + .map(|trun| trun.sample_count) + .collect::>(), + vec![2, 2, 1, 1] + ); + assert!(trun_boxes[1].data_offset > trun_boxes[0].data_offset); + assert!(trun_boxes[3].data_offset > trun_boxes[2].data_offset); + + let sidx_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("sidx")])); + assert_eq!(sidx_boxes.len(), 1); + assert_eq!(sidx_boxes[0].reference_id, 2); + assert_eq!(sidx_boxes[0].references.len(), 1); + assert_eq!(sidx_boxes[0].references[0].subsegment_duration, 30); + assert!(sidx_boxes[0].references[0].starts_with_sap); + assert_eq!( + u64::from(sidx_boxes[0].references[0].referenced_size), + root_boxes[3].size() + root_boxes[4].size() + root_boxes[5].size() + root_boxes[6].size() + ); +} + +#[test] +fn mux_to_path_writes_fragmented_root_metadata_before_media_fragment() { + let audio_input = build_audio_input_file( + "mux-fragmented-root-metadata-input", + fourcc("dash"), + &[b"a1", b"a2", b"a3"], + ); + let output_path = write_temp_file("mux-fragmented-root-metadata-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 0.015 }) + .with_fragment_event_message(MuxFragmentEventMessage::new_v0( + 0, + "urn:mp4forge:test", + "first-fragment", + 1_000, + 7, + 20, + 99, + b"event-data".as_slice(), + )) + .with_producer_reference_time( + MuxProducerReferenceTime::new(0, 1, 0x1234_5678_90ab_cdef, 123).with_flags(1), + ); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("sidx"), + fourcc("emsg"), + fourcc("prft"), + fourcc("moof"), + fourcc("mdat"), + fourcc("moof"), + fourcc("mdat"), + ] + ); + assert_eq!(mdat_payload(&output_bytes, root_boxes[6]), b"a1a2"); + assert_eq!(mdat_payload(&output_bytes, root_boxes[8]), b"a3"); + + let emsg_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("emsg")])); + assert_eq!(emsg_boxes.len(), 1); + assert_eq!(emsg_boxes[0].version(), 0); + assert_eq!(emsg_boxes[0].scheme_id_uri, "urn:mp4forge:test"); + assert_eq!(emsg_boxes[0].value, "first-fragment"); + assert_eq!(emsg_boxes[0].timescale, 1_000); + assert_eq!(emsg_boxes[0].presentation_time_delta, 7); + assert_eq!(emsg_boxes[0].event_duration, 20); + assert_eq!(emsg_boxes[0].id, 99); + assert_eq!(emsg_boxes[0].message_data, b"event-data"); + + let prft_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("prft")])); + assert_eq!(prft_boxes.len(), 1); + assert_eq!(prft_boxes[0].version(), 1); + assert_eq!(prft_boxes[0].flags(), 1); + assert_eq!(prft_boxes[0].reference_track_id, 1); + assert_eq!(prft_boxes[0].ntp_timestamp, 0x1234_5678_90ab_cdef); + assert_eq!(prft_boxes[0].media_time(), 123); + + let sidx_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("sidx")])); + assert_eq!(sidx_boxes.len(), 1); + assert_eq!( + u64::from(sidx_boxes[0].references[0].referenced_size), + root_boxes[3].size() + + root_boxes[4].size() + + root_boxes[5].size() + + root_boxes[6].size() + + root_boxes[7].size() + + root_boxes[8].size() + ); +} + +#[test] +fn mux_to_path_writes_later_fragment_metadata_versions() { + let audio_input = build_audio_input_file( + "mux-fragmented-later-metadata-input", + fourcc("dash"), + &[b"a1", b"a2", b"a3"], + ); + let output_path = write_temp_file("mux-fragmented-later-metadata-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 0.015 }) + .with_fragment_event_message(MuxFragmentEventMessage::new_v1( + 1, + "urn:mp4forge:test", + "second-fragment", + 1_000, + 20, + 10, + 100, + b"v1-event".as_slice(), + )) + .with_producer_reference_time( + MuxProducerReferenceTime::new(1, 1, 0x2234_5678_90ab_cdef, 20).with_version(0), + ); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("sidx"), + fourcc("moof"), + fourcc("mdat"), + fourcc("emsg"), + fourcc("prft"), + fourcc("moof"), + fourcc("mdat"), + ] + ); + assert_eq!(mdat_payload(&output_bytes, root_boxes[4]), b"a1a2"); + assert_eq!(mdat_payload(&output_bytes, root_boxes[8]), b"a3"); + + let emsg_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("emsg")])); + assert_eq!(emsg_boxes.len(), 1); + assert_eq!(emsg_boxes[0].version(), 1); + assert_eq!(emsg_boxes[0].presentation_time, 20); + assert_eq!(emsg_boxes[0].event_duration, 10); + assert_eq!(emsg_boxes[0].id, 100); + assert_eq!(emsg_boxes[0].message_data, b"v1-event"); + + let prft_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("prft")])); + assert_eq!(prft_boxes.len(), 1); + assert_eq!(prft_boxes[0].version(), 0); + assert_eq!(prft_boxes[0].media_time(), 20); + + let sidx_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("sidx")])); + assert_eq!(sidx_boxes.len(), 1); + assert_eq!( + u64::from(sidx_boxes[0].references[0].referenced_size), + root_boxes[3].size() + + root_boxes[4].size() + + root_boxes[5].size() + + root_boxes[6].size() + + root_boxes[7].size() + + root_boxes[8].size() + ); +} + +#[test] +fn mux_to_path_rejects_invalid_fragmented_root_metadata() { + let audio_input = build_audio_input_file( + "mux-fragmented-invalid-root-metadata-input", + fourcc("dash"), + &[b"a1"], + ); + + let event_output = write_temp_file("mux-fragmented-invalid-event-output", &[]); + let event_request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 1.0 }) + .with_fragment_event_message(MuxFragmentEventMessage::new_v0( + 0, + "urn:mp4forge:test", + "", + 0, + 0, + 1, + 1, + Vec::new(), + )); + let event_error = mux_to_path(&event_request, &event_output).unwrap_err(); + assert_eq!( + event_error.to_string(), + "invalid mux layout `fragmented`: fragment event message timescale must be greater than zero" + ); + + let prft_output = write_temp_file("mux-fragmented-invalid-prft-output", &[]); + let prft_request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 1.0 }) + .with_producer_reference_time(MuxProducerReferenceTime::new(0, 0, 0, 0)); + let prft_error = mux_to_path(&prft_request, &prft_output).unwrap_err(); + assert_eq!( + prft_error.to_string(), + "invalid mux layout `fragmented`: producer reference time requires a nonzero reference track id" + ); + + let flags_output = write_temp_file("mux-fragmented-invalid-prft-flags-output", &[]); + let flags_input = build_audio_input_file( + "mux-fragmented-invalid-prft-flags-input", + fourcc("dash"), + &[b"a1"], + ); + let flags_request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &flags_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 1.0 }) + .with_producer_reference_time( + MuxProducerReferenceTime::new(0, 1, 0, 0).with_flags(0x0100_0000), + ); + let flags_error = mux_to_path(&flags_request, &flags_output).unwrap_err(); + assert_eq!( + flags_error.to_string(), + "invalid mux layout `fragmented`: producer reference time flags must fit in 24 bits" + ); +} + +#[test] +fn mux_to_path_writes_fragmented_audio_audio_output() { + let first_audio = build_audio_input_file( + "mux-fragmented-audio-audio-first-input", + fourcc("dash"), + &[b"a1", b"a2", b"a3"], + ); + let second_audio = build_audio_input_file( + "mux-fragmented-audio-audio-second-input", + fourcc("dash"), + &[b"b1", b"b2", b"b3"], + ); + let output_path = write_temp_file("mux-fragmented-audio-audio-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::mp4(&first_audio, MuxMp4TrackSelector::Audio { occurrence: 1 }), + MuxTrackSpec::mp4(&second_audio, MuxMp4TrackSelector::Audio { occurrence: 1 }), + ]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 0.015 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[4]), b"a1a2b1b2"); + assert_eq!(mdat_payload(&output_bytes, root_boxes[6]), b"a3b3"); + let tfhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfhd")]), + ); + assert_eq!( + tfhd_boxes + .iter() + .map(|tfhd| tfhd.track_id) + .collect::>(), + vec![1, 2, 1, 2] + ); + let sidx_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("sidx")])); + assert_eq!(sidx_boxes[0].reference_id, 1); +} + +#[test] +fn mux_to_path_writes_fragmented_video_text_output() { + let video_input = build_video_input_file( + "mux-fragmented-video-text-video-input", + fourcc("isom"), + &[b"v1", b"v2", b"v3"], + ); + let text_input = build_wvtt_input_file( + "mux-fragmented-video-text-text-input", + fourcc("dash"), + &[b"t1", b"t2", b"t3"], + ); + let output_path = write_temp_file("mux-fragmented-video-text-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::mp4(&video_input, MuxMp4TrackSelector::Video), + MuxTrackSpec::mp4(&text_input, MuxMp4TrackSelector::Text { occurrence: 1 }), + ]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 0.015 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let tfhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfhd")]), + ); + assert_eq!( + tfhd_boxes + .iter() + .map(|tfhd| tfhd.track_id) + .collect::>(), + vec![1, 2, 1, 2] + ); + let sidx_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("sidx")])); + assert_eq!(sidx_boxes[0].reference_id, 1); +} + +#[test] +fn mux_to_path_writes_fragmented_text_boundary_and_empty_interval_samples() { + let text_samples = [ + TestMuxSample { + bytes: b"lead", + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }, + TestMuxSample { + bytes: b"", + duration: 20, + composition_time_offset: 0, + is_sync_sample: true, + }, + TestMuxSample { + bytes: b"edge", + duration: 0, + composition_time_offset: 0, + is_sync_sample: true, + }, + TestMuxSample { + bytes: b"tail", + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + let text_input = write_single_track_mp4_input( + "mux-fragmented-text-boundary-empty-input", + &MuxFileConfig::new(1_000) + .with_major_brand(fourcc("dash")) + .with_compatible_brand(fourcc("mp42")), + MuxTrackConfig::new_text(1, 1_000, 0, 0, wvtt_sample_entry_box()), + &text_samples, + ); + let output_path = write_temp_file("mux-fragmented-text-boundary-empty-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &text_input, + MuxMp4TrackSelector::Text { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 0.01 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + let payload = root_boxes + .iter() + .filter(|info| info.box_type() == fourcc("mdat")) + .flat_map(|info| mdat_payload(&output_bytes, *info).to_vec()) + .collect::>(); + assert_eq!(payload, b"leadedgetail"); + let trun_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("trun")]), + ); + assert_eq!( + trun_boxes.iter().map(|trun| trun.sample_count).sum::(), + 4 + ); +} + +#[test] +fn mux_to_path_writes_fragmented_mismatched_timescale_tracks() { + let audio_samples = [ + TestMuxSample { + bytes: b"a1", + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + TestMuxSample { + bytes: b"a2", + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + TestMuxSample { + bytes: b"a3", + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + let audio_input = build_imported_track_input_file( + "mux-fragmented-mismatched-audio-input", + &MuxFileConfig::new(44_100).with_major_brand(fourcc("isom")), + &MuxTrackConfig::new_audio(1, 44_100, audio_sample_entry_box()), + 3_072, + &audio_samples, + ); + let video_input = build_video_input_file( + "mux-fragmented-mismatched-video-input", + fourcc("isom"), + &[b"v1", b"v2", b"v3"], + ); + let output_path = write_temp_file("mux-fragmented-mismatched-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::mp4(&audio_input, MuxMp4TrackSelector::Audio { occurrence: 1 }), + MuxTrackSpec::mp4(&video_input, MuxMp4TrackSelector::Video), + ]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 0.05 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!( + mdhd_boxes + .iter() + .map(|mdhd| mdhd.timescale) + .collect::>(), + vec![44_100, 1_000] + ); + let mehd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("mvex"), fourcc("mehd")]), + ); + assert_eq!(mehd_boxes.len(), 1); + assert!(mehd_boxes[0].fragment_duration_v0 > 0); +} + +#[test] +fn mux_to_path_writes_fragmented_sparse_track_fragments() { + let audio_input = build_audio_input_file( + "mux-fragmented-sparse-audio-input", + fourcc("dash"), + &[b"a1", b"a2", b"a3"], + ); + let video_input = build_video_input_file( + "mux-fragmented-sparse-video-input", + fourcc("isom"), + &[b"v1"], + ); + let output_path = write_temp_file("mux-fragmented-sparse-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::mp4(&audio_input, MuxMp4TrackSelector::Audio { occurrence: 1 }), + MuxTrackSpec::mp4(&video_input, MuxMp4TrackSelector::Video), + ]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 0.015 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let tfhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfhd")]), + ); + assert_eq!( + tfhd_boxes + .iter() + .map(|tfhd| tfhd.track_id) + .collect::>(), + vec![1, 2, 1] + ); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[4]), b"a1a2v1"); + assert_eq!(mdat_payload(&output_bytes, root_boxes[6]), b"a3"); +} + +#[test] +fn mux_to_path_writes_fragmented_single_track_output() { + let audio_input = build_audio_input_file( + "mux-fragment-source", + fourcc("isom"), + &[b"one", b"two", b"three"], + ); + let output_path = write_temp_file("mux-fragment-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 0.015 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("sidx"), + fourcc("moof"), + fourcc("mdat"), + fourcc("moof"), + fourcc("mdat"), + ] + ); + + let ftyp_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("ftyp")])); + assert_eq!(ftyp_boxes.len(), 1); + assert_eq!(ftyp_boxes[0].major_brand, fourcc("mp41")); + assert!(ftyp_boxes[0].compatible_brands.contains(&fourcc("dash"))); + assert!(ftyp_boxes[0].compatible_brands.contains(&fourcc("cmfc"))); + + let mvhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("mvhd")]), + ); + assert_eq!(mvhd_boxes.len(), 1); + assert_eq!(mvhd_boxes[0].duration_v0, 0); + + let tkhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("tkhd")]), + ); + assert_eq!(tkhd_boxes.len(), 1); + assert_eq!(tkhd_boxes[0].duration_v0, 0); + + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].duration_v0, 0); + + let mvex_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("mvex")]), + ); + assert_eq!(mvex_boxes.len(), 1); + let mehd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("mvex"), fourcc("mehd")]), + ); + assert_eq!(mehd_boxes.len(), 1); + assert_eq!(mehd_boxes[0].fragment_duration_v0, 30); + let trex_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("mvex"), fourcc("trex")]), + ); + assert_eq!(trex_boxes.len(), 1); + assert_eq!(trex_boxes[0].default_sample_duration, 10); + + let edts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("edts")]), + ); + assert!(edts_boxes.is_empty()); + let elst_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("edts"), + fourcc("elst"), + ]), + ); + assert!(elst_boxes.is_empty()); + + let meta_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("meta")]), + ); + assert_eq!(meta_boxes.len(), 1); + let id32_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("meta"), fourcc("ID32")]), + ); + assert_eq!(id32_boxes.len(), 1); + assert!(!id32_boxes[0].id3v2_data.is_empty()); + + let sidx_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("sidx")])); + assert_eq!(sidx_boxes.len(), 1); + assert_eq!(sidx_boxes[0].reference_count, 1); + assert_eq!(sidx_boxes[0].references.len(), 1); + + let tfdt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfdt")]), + ); + assert_eq!(tfdt_boxes.len(), 2); + assert_eq!(tfdt_boxes[0].base_media_decode_time_v0, 0); + assert_eq!(tfdt_boxes[1].base_media_decode_time_v0, 20); + + let tfhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfhd")]), + ); + assert_eq!(tfhd_boxes.len(), 2); + + let trun_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("trun")]), + ); + assert_eq!(trun_boxes.len(), 2); + assert_eq!(trun_boxes[0].sample_count, 2); + assert_eq!(trun_boxes[1].sample_count, 1); +} + +#[test] +fn mux_fragmented_to_paths_writes_recombinable_init_and_media_outputs() { + let audio_input = build_audio_input_file( + "mux-fragmented-split-source", + fourcc("isom"), + &[b"one", b"two", b"three"], + ); + let combined_output = write_temp_file("mux-fragmented-split-combined-output", &[]); + let output_dir = temp_output_dir("mux-fragmented-split-output-dir"); + let init_output = output_dir.join("init.mp4"); + let media_output = output_dir.join("media.mp4"); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 0.015 }); + + mux_to_path(&request, &combined_output).unwrap(); + mux_fragmented_to_paths(&request, &init_output, &media_output).unwrap(); + + let combined_bytes = fs::read(combined_output).unwrap(); + let init_bytes = fs::read(init_output).unwrap(); + let media_bytes = fs::read(media_output).unwrap(); + let recombined = [init_bytes.as_slice(), media_bytes.as_slice()].concat(); + let init_boxes = read_root_boxes(&init_bytes); + let media_boxes = read_root_boxes(&media_bytes); + assert_eq!( + init_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![fourcc("ftyp"), fourcc("moov")] + ); + assert_eq!( + media_boxes + .iter() + .map(BoxInfo::box_type) + .collect::>(), + vec![ + fourcc("sidx"), + fourcc("moof"), + fourcc("mdat"), + fourcc("moof"), + fourcc("mdat"), + ] + ); + let mut combined_normalized = combined_bytes.clone(); + let mut recombined_normalized = recombined; + normalize_mp4_time_fields(&mut combined_normalized); + normalize_mp4_time_fields(&mut recombined_normalized); + assert_eq!(combined_normalized, recombined_normalized); +} + +#[test] +fn mux_fragmented_to_paths_rejects_existing_split_output_without_mutation() { + let audio_input = build_audio_input_file( + "mux-fragmented-split-existing-source", + fourcc("isom"), + &[b"one"], + ); + let output_dir = temp_output_dir("mux-fragmented-split-existing-output-dir"); + let init_output = output_dir.join("init.mp4"); + let media_output = output_dir.join("media.mp4"); + fs::create_dir_all(&output_dir).unwrap(); + fs::write(&media_output, b"unchanged").unwrap(); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 1.0 }); + + let error = mux_fragmented_to_paths(&request, &init_output, &media_output).unwrap_err(); + + assert_eq!( + error.to_string(), + "invalid mux destination mode `create-new`: separate fragmented media output path already exists" + ); + assert!(!init_output.exists()); + assert_eq!(fs::read(media_output).unwrap(), b"unchanged"); +} + +#[test] +fn write_fragmented_mp4_mux_split_matches_single_writer_output() { + let first_source = write_temp_file("mux-low-level-fragmented-split-source", b"a1a2a3"); + let mut combined_sources = [std::fs::File::open(&first_source).unwrap()]; + let mut split_sources = [std::fs::File::open(&first_source).unwrap()]; + let file_config = MuxFileConfig::new(1_000).with_major_brand(fourcc("isom")); + let track_configs = vec![MuxTrackConfig::new_audio( + 1, + 1_000, + audio_sample_entry_box(), + )]; + let plan = plan_staged_media_items_with_chunk_sample_counts( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 10, 0, 2).with_sync_sample(true), + MuxStagedMediaItem::new(0, 1, 10, 10, 2, 2).with_sync_sample(true), + MuxStagedMediaItem::new(0, 1, 20, 10, 4, 2).with_sync_sample(true), + ], + MuxInterleavePolicy::DecodeTime, + [(1, vec![1, 1, 1])], + ) + .unwrap(); + let mut combined = Cursor::new(Vec::new()); + let mut init = Cursor::new(Vec::new()); + let mut media = Cursor::new(Vec::new()); + + mp4forge::mux::write_fragmented_mp4_mux( + &mut combined_sources, + &mut combined, + &file_config, + &track_configs, + false, + &plan, + ) + .unwrap(); + write_fragmented_mp4_mux_split( + &mut split_sources, + &mut init, + &mut media, + &file_config, + &track_configs, + false, + &plan, + ) + .unwrap(); + + let mut combined_bytes = combined.into_inner(); + let mut recombined = [init.into_inner(), media.into_inner()].concat(); + normalize_mp4_time_fields(&mut combined_bytes); + normalize_mp4_time_fields(&mut recombined); + assert_eq!(combined_bytes, recombined); +} + +#[test] +fn write_fragmented_mp4_mux_segmented_writes_segment_type_and_local_indexes() { + let first_source = write_temp_file("mux-low-level-fragmented-segmented-source", b"a1a2a3"); + let mut sources = [std::fs::File::open(&first_source).unwrap()]; + let file_config = MuxFileConfig::new(1_000).with_major_brand(fourcc("isom")); + let track_configs = vec![MuxTrackConfig::new_audio( + 1, + 1_000, + audio_sample_entry_box(), + )]; + let plan = plan_staged_media_items_with_chunk_sample_counts( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 10, 0, 2).with_sync_sample(true), + MuxStagedMediaItem::new(0, 1, 10, 10, 2, 2).with_sync_sample(true), + MuxStagedMediaItem::new(0, 1, 20, 10, 4, 2).with_sync_sample(true), + ], + MuxInterleavePolicy::DecodeTime, + [(1, vec![2, 1])], + ) + .unwrap(); + let mut init = Cursor::new(Vec::new()); + let mut media = Cursor::new(Vec::new()); + + write_fragmented_mp4_mux_segmented( + &mut sources, + &mut init, + &mut media, + &file_config, + &track_configs, + &plan, + ) + .unwrap(); + + let init_bytes = init.into_inner(); + let media_bytes = media.into_inner(); + let init_boxes = read_root_boxes(&init_bytes); + let media_boxes = read_root_boxes(&media_bytes); + assert_eq!( + init_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![fourcc("ftyp"), fourcc("moov")] + ); + assert_eq!( + media_boxes + .iter() + .map(BoxInfo::box_type) + .collect::>(), + vec![ + fourcc("styp"), + fourcc("sidx"), + fourcc("moof"), + fourcc("mdat"), + fourcc("styp"), + fourcc("sidx"), + fourcc("moof"), + fourcc("mdat"), + ] + ); + + let styp_start = + usize::try_from(media_boxes[0].offset() + media_boxes[0].header_size()).unwrap(); + let styp_end = usize::try_from(media_boxes[0].offset() + media_boxes[0].size()).unwrap(); + let styp_payload = &media_bytes[styp_start..styp_end]; + assert!(styp_payload.windows(4).any(|window| window == b"cmfs")); + assert!(!styp_payload.windows(4).any(|window| window == b"cmfc")); + + let sidx_boxes = extract_boxes::(&media_bytes, BoxPath::from([fourcc("sidx")])); + assert_eq!(sidx_boxes.len(), 2); + assert_eq!(sidx_boxes[0].first_offset(), 0); + assert_eq!(sidx_boxes[1].first_offset(), 0); + assert_eq!(sidx_boxes[0].reference_count, 1); + assert_eq!(sidx_boxes[1].reference_count, 1); + assert_eq!( + u64::from(sidx_boxes[0].references[0].referenced_size), + media_boxes[2].size() + media_boxes[3].size() + ); + assert_eq!( + u64::from(sidx_boxes[1].references[0].referenced_size), + media_boxes[6].size() + media_boxes[7].size() + ); +} + +#[test] +fn write_fragmented_mp4_mux_chunked_flushes_after_index_and_fragments() { + let first_source = write_temp_file("mux-low-level-fragmented-chunked-source", b"a1a2a3"); + let mut chunked_sources = [std::fs::File::open(&first_source).unwrap()]; + let mut single_sources = [std::fs::File::open(&first_source).unwrap()]; + let file_config = MuxFileConfig::new(1_000).with_major_brand(fourcc("isom")); + let track_configs = vec![MuxTrackConfig::new_audio( + 1, + 1_000, + audio_sample_entry_box(), + )]; + let plan = plan_staged_media_items_with_chunk_sample_counts( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 10, 0, 2).with_sync_sample(true), + MuxStagedMediaItem::new(0, 1, 10, 10, 2, 2).with_sync_sample(true), + MuxStagedMediaItem::new(0, 1, 20, 10, 4, 2).with_sync_sample(true), + ], + MuxInterleavePolicy::DecodeTime, + [(1, vec![1, 1, 1])], + ) + .unwrap(); + let mut chunked = FlushCountingWriter::default(); + let mut single = Cursor::new(Vec::new()); + + write_fragmented_mp4_mux_chunked( + &mut chunked_sources, + &mut chunked, + &file_config, + &track_configs, + false, + &plan, + ) + .unwrap(); + mp4forge::mux::write_fragmented_mp4_mux( + &mut single_sources, + &mut single, + &file_config, + &track_configs, + false, + &plan, + ) + .unwrap(); + + assert_eq!(chunked.flush_count, 4); + let mut chunked_bytes = chunked.bytes; + let mut single_bytes = single.into_inner(); + normalize_mp4_time_fields(&mut chunked_bytes); + normalize_mp4_time_fields(&mut single_bytes); + assert_eq!(chunked_bytes, single_bytes); +} + +#[test] +fn mux_to_path_flat_mode_preserves_imported_edit_media_time() { + let samples = std::iter::repeat_n( + TestMuxSample { + bytes: b"aaaa", + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + 3, + ) + .collect::>(); + let input = build_imported_track_input_file_with_edit_media_time( + "mux-flat-edit-media-time", + &MuxFileConfig::new(44_100) + .with_major_brand(fourcc("isom")) + .with_compatible_brand(fourcc("mp42")), + &MuxTrackConfig::new_audio(1, 44_100, audio_sample_entry_box()), + 2_048, + 1_024, + &samples, + ); + let output_path = write_temp_file("mux-flat-edit-media-time-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let mvhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("mvhd")]), + ); + let tkhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("tkhd")]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let elst_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("edts"), + fourcc("elst"), + ]), + ); + assert_eq!(mvhd_boxes.len(), 1); + assert_eq!(mvhd_boxes[0].duration_v0, 2_048); + assert_eq!(tkhd_boxes.len(), 1); + assert_eq!(tkhd_boxes[0].duration_v0, 2_048); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].duration_v0, 3_072); + assert_eq!(elst_boxes.len(), 1); + assert_eq!(elst_boxes[0].entries.len(), 1); + assert_eq!(elst_boxes[0].entries[0].segment_duration_v0, 2_048); + assert_eq!(elst_boxes[0].entries[0].media_time_v0, 1_024); +} + +#[test] +fn mux_to_path_flat_mode_preserves_multi_entry_edit_list_when_timescale_matches() { + let samples = [ + TestMuxSample { + bytes: b"aaaa", + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }, + TestMuxSample { + bytes: b"bbbb", + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }, + TestMuxSample { + bytes: b"cccc", + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + let edit_entries = [ + ElstEntry { + segment_duration_v0: 5, + media_time_v0: -1, + media_rate_integer: 1, + ..ElstEntry::default() + }, + ElstEntry { + segment_duration_v0: 20, + media_time_v0: 10, + media_rate_integer: 1, + ..ElstEntry::default() + }, + ElstEntry { + segment_duration_v0: 5, + media_time_v0: 20, + media_rate_integer: 0, + ..ElstEntry::default() + }, + ]; + let input = build_imported_track_input_file_with_edit_entries( + "mux-flat-multi-entry-edit-list", + &MuxFileConfig::new(1_000) + .with_major_brand(fourcc("isom")) + .with_compatible_brand(fourcc("mp42")), + &MuxTrackConfig::new_audio(1, 1_000, audio_sample_entry_box()), + 30, + Some(&edit_entries), + &samples, + ); + let output_path = write_temp_file("mux-flat-multi-entry-edit-list-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let elst_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("edts"), + fourcc("elst"), + ]), + ); + assert_eq!(elst_boxes.len(), 1); + assert_eq!(elst_boxes[0].entry_count, 3); + assert_eq!(elst_boxes[0].entries, edit_entries); +} + +#[test] +fn mux_to_path_fragmented_segment_mode_honors_imported_edit_media_time() { + let samples = std::iter::repeat_n( + TestMuxSample { + bytes: b"aaaa", + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + 120, + ) + .collect::>(); + let input = build_imported_track_input_file_with_edit_media_time( + "mux-fragment-segment-edit-shift", + &MuxFileConfig::new(44_100) + .with_major_brand(fourcc("isom")) + .with_compatible_brand(fourcc("mp42")), + &MuxTrackConfig::new_audio(1, 44_100, audio_sample_entry_box()), + 121_856, + 1_024, + &samples, + ); + let output_path = write_temp_file("mux-fragment-segment-edit-shift-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Segment { seconds: 1.0 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("sidx"), + fourcc("moof"), + fourcc("mdat"), + fourcc("moof"), + fourcc("mdat"), + fourcc("moof"), + fourcc("mdat"), + ] + ); + let sidx_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("sidx")])); + assert_eq!(sidx_boxes.len(), 1); + assert_eq!(sidx_boxes[0].references.len(), 3); + assert_eq!( + u64::from(sidx_boxes[0].references[0].referenced_size), + root_boxes[3].size() + root_boxes[4].size() + ); + let mehd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("mvex"), fourcc("mehd")]), + ); + let trun_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("trun")]), + ); + let tfdt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfdt")]), + ); + assert_eq!(mehd_boxes.len(), 1); + assert_eq!(mehd_boxes[0].fragment_duration_v0, 122_880); + assert_eq!( + trun_boxes + .iter() + .map(|trun| trun.sample_count) + .collect::>(), + vec![45, 43, 32] + ); + assert_eq!( + tfdt_boxes + .iter() + .map(|tfdt| tfdt.base_media_decode_time()) + .collect::>(), + vec![0, 46_080, 90_112] + ); +} + +#[test] +fn mux_to_path_fragmented_video_mehd_uses_presentation_duration_for_imported_edits() { + let samples = std::iter::repeat_n( + TestMuxSample { + bytes: b"v001", + duration: 1_000, + composition_time_offset: 0, + is_sync_sample: true, + }, + 3, + ) + .collect::>(); + let input = build_imported_track_input_file_with_edit_media_time( + "mux-fragment-video-edit-duration", + &MuxFileConfig::new(1_000) + .with_major_brand(fourcc("isom")) + .with_compatible_brand(fourcc("mp42")), + &MuxTrackConfig::new_video(1, 1_000, 640, 360, video_sample_entry_box()), + 2_500, + 500, + &samples, + ); + let output_path = write_temp_file("mux-fragment-video-edit-duration-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4(&input, MuxMp4TrackSelector::Video)]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 10.0 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let mehd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("mvex"), fourcc("mehd")]), + ); + assert_eq!(mehd_boxes.len(), 1); + assert_eq!(mehd_boxes[0].fragment_duration_v0, 2_500); +} + +#[test] +fn mux_to_path_fragmented_direct_inputs_use_generic_handler_names() { + let vp8_input = write_test_vp8_ivf_file( + "mux-fragmented-direct-vp8-input", + 640, + 360, + &[0, 1], + &[ + &build_test_vp8_keyframe(640, 360, 1, b"vp8-a"), + &build_test_vp8_keyframe(640, 360, 1, b"vp8-b"), + ], + ); + let ac3_input = write_test_ac3_file("mux-fragmented-direct-ac3-input", &[b"ac3"]); + + for (label, input, duration_mode, expected_handler_name) in [ + ( + "vp8", + vp8_input.as_path(), + MuxDurationMode::Fragment { seconds: 1.0 }, + "VideoHandler", + ), + ( + "ac3", + ac3_input.as_path(), + MuxDurationMode::Segment { seconds: 1.0 }, + "SoundHandler", + ), + ] { + let output_path = write_temp_file(&format!("mux-fragmented-direct-{label}-output"), &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(input)]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(duration_mode); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(hdlr_boxes.len(), 1, "{label}"); + assert_eq!(hdlr_boxes[0].name, expected_handler_name, "{label}"); + } +} + +#[test] +fn mux_to_path_fragmented_imported_vp8_empty_stss_stays_sync() { + let vp8_input = write_test_vp8_ivf_file( + "mux-fragmented-imported-vp8-input", + 640, + 360, + &[0], + &[&build_test_vp8_keyframe(640, 360, 1, b"vp8-keyframe")], + ); + let flat_source = write_temp_file("mux-fragmented-imported-vp8-source", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&vp8_input)]), + &flat_source, + ) + .unwrap(); + + let output_path = write_temp_file("mux-fragmented-imported-vp8-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &flat_source, + MuxMp4TrackSelector::Video, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 10.0 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let sidx_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("sidx")])); + let tfhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfhd")]), + ); + assert_eq!(sidx_boxes.len(), 1); + assert_eq!(sidx_boxes[0].references.len(), 1); + assert!(sidx_boxes[0].references[0].starts_with_sap); + assert_eq!(sidx_boxes[0].references[0].sap_type, 1); + assert_eq!(tfhd_boxes.len(), 1); + assert_eq!(tfhd_boxes[0].default_sample_flags, 0); +} + +#[test] +fn mux_to_path_fragmented_imported_opus_uses_track_timescale() { + let opus_input = + write_test_ogg_opus_file("mux-fragmented-imported-opus-input", &[b"abc", b"def"]); + let flat_source = write_temp_file("mux-fragmented-imported-opus-source", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&opus_input)]), + &flat_source, + ) + .unwrap(); + + let output_path = write_temp_file("mux-fragmented-imported-opus-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &flat_source, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 10.0 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let mvhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("mvhd")]), + ); + let mehd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("mvex"), fourcc("mehd")]), + ); + let sidx_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("sidx")])); + assert_eq!(mvhd_boxes.len(), 1); + assert_eq!(mvhd_boxes[0].timescale, 48_000); + assert_eq!(mehd_boxes.len(), 1); + assert_eq!(mehd_boxes[0].fragment_duration_v0, 960); + assert_eq!(sidx_boxes.len(), 1); + assert_eq!(sidx_boxes[0].timescale, 48_000); + assert_eq!(sidx_boxes[0].references.len(), 1); + assert_eq!(sidx_boxes[0].references[0].subsegment_duration, 648); +} + +#[test] +fn mux_to_path_fragmented_imported_eac3_groups_fragment_references() { + let payloads = std::iter::repeat_n(b"ec3".as_slice(), 375).collect::>(); + let raw_input = write_test_eac3_file("mux-fragment-imported-eac3-raw-input", &payloads); + let flat_source = write_temp_file("mux-fragment-imported-eac3-flat-source", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&raw_input)]), + &flat_source, + ) + .unwrap(); + + let output_path = write_temp_file("mux-fragment-imported-eac3-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &flat_source, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 5.0 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let sidx_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("sidx")])); + let trun_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("trun")]), + ); + assert_eq!(sidx_boxes.len(), 1); + assert_eq!(sidx_boxes[0].references.len(), 2); + assert_eq!( + trun_boxes + .iter() + .map(|trun| trun.sample_count) + .collect::>(), + vec![157, 31, 157, 30] + ); +} + +#[test] +fn mux_to_path_fragmented_imported_alac_uses_dominant_trex_duration() { + let input = build_imported_track_input_file( + "mux-fragment-imported-alac", + &MuxFileConfig::new(44_100) + .with_major_brand(fourcc("isom")) + .with_compatible_brand(fourcc("mp42")), + &MuxTrackConfig::new_audio( + 1, + 44_100, + audio_sample_entry_box_with_children( + "alac", + &[ + encode_raw_box(fourcc("alac"), &[0; 20]), + encode_supported_box(&mp4forge::boxes::iso14496_12::Btrt::default(), &[]), + ] + .concat(), + ), + ), + 10_240, + &[ + TestMuxSample { + bytes: b"one", + duration: 4_096, + composition_time_offset: 0, + is_sync_sample: true, + }, + TestMuxSample { + bytes: b"two", + duration: 4_096, + composition_time_offset: 0, + is_sync_sample: true, + }, + TestMuxSample { + bytes: b"tri", + duration: 2_048, + composition_time_offset: 0, + is_sync_sample: true, + }, + ], + ); + let output_path = write_temp_file("mux-fragment-imported-alac-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 10.0 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let trex_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("mvex"), fourcc("trex")]), + ); + let sample_entry_boxes = extract_box_bytes( + &mut Cursor::new(&output_bytes), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("alac"), + ]), + ) + .unwrap(); + assert_eq!(trex_boxes[0].default_sample_duration, 4_096); + assert_eq!(sample_entry_boxes.len(), 1); + assert_eq!(sample_entry_boxes[0].len(), 64); +} + +#[test] +fn mux_to_path_fragmented_segment_mode_aligns_video_boundaries_to_sync_samples() { + let samples = (0..82) + .map(|index| TestMuxSample { + bytes: b"vfrm", + duration: 1_001, + composition_time_offset: if matches!(index, 0 | 30 | 60) { + 2_002 + } else if index % 2 == 1 { + 3_003 + } else { + 1_001 + }, + is_sync_sample: matches!(index, 0 | 30 | 60), + }) + .collect::>(); + let input = build_imported_track_input_file_with_edit_media_time( + "mux-fragment-segment-video-sync-boundaries", + &MuxFileConfig::new(30_000) + .with_major_brand(fourcc("isom")) + .with_compatible_brand(fourcc("mp42")), + &MuxTrackConfig::new_video( + 1, + 30_000, + 640, + 360, + video_sample_entry_box_with_type("avc1"), + ), + 82_082, + 2_002, + &samples, + ); + let output_path = write_temp_file("mux-fragment-segment-video-sync-boundaries-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4(&input, MuxMp4TrackSelector::Video)]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Segment { seconds: 1.0 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let trun_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("trun")]), + ); + let tfdt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfdt")]), + ); + assert_eq!( + trun_boxes + .iter() + .map(|trun| trun.sample_count) + .collect::>(), + vec![30, 30, 22] + ); + assert_eq!( + tfdt_boxes + .iter() + .map(|tfdt| tfdt.base_media_decode_time()) + .collect::>(), + vec![0, 30_030, 60_060] + ); +} + +#[test] +fn mux_to_path_fragmented_imported_dtsx_preserves_udts_child_boxes() { + let input = build_imported_track_input_file( + "mux-fragment-imported-dtsx", + &MuxFileConfig::new(48_000) + .with_major_brand(fourcc("isom")) + .with_compatible_brand(fourcc("mp42")), + &MuxTrackConfig::new_audio( + 1, + 48_000, + audio_sample_entry_box_with_children("dtsx", &encode_raw_box(fourcc("udts"), &[0; 8])), + ), + 3_072, + &[ + TestMuxSample { + bytes: b"dtsx", + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + TestMuxSample { + bytes: b"more", + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + TestMuxSample { + bytes: b"data", + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + ], + ); + let output_path = write_temp_file("mux-fragment-imported-dtsx-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 10.0 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let sample_entry_boxes = extract_box_bytes( + &mut Cursor::new(&output_bytes), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dtsx"), + ]), + ) + .unwrap(); + assert_eq!(sample_entry_boxes.len(), 1); + assert_eq!(sample_entry_boxes[0].len(), 52); + assert!( + sample_entry_boxes[0] + .windows(4) + .any(|bytes| bytes == b"udts") + ); + assert!( + !sample_entry_boxes[0] + .windows(4) + .any(|bytes| bytes == b"btrt") + ); +} + +#[test] +fn mux_to_path_fragmented_imported_dtsc_preserves_existing_ddts() { + let expected_ddts = Ddts { + sampling_frequency: 48_000, + max_bitrate: 1_536_000, + avg_bitrate: 768_000, + sample_depth: 16, + frame_duration: 1, + stream_construction: 0, + core_lfe_present: false, + core_layout: 0, + core_size: 1_024, + stereo_downmix: false, + representation_type: 0, + channel_layout: 3, + multi_asset_flag: false, + lbr_duration_mod: false, + }; + let input = build_imported_track_input_file( + "mux-fragment-imported-dtsc-preserve-ddts", + &MuxFileConfig::new(48_000) + .with_major_brand(fourcc("isom")) + .with_compatible_brand(fourcc("mp42")), + &MuxTrackConfig::new_audio( + 1, + 48_000, + audio_sample_entry_box_with_children( + "dtsc", + &[ + encode_supported_box(&expected_ddts, &[]), + encode_supported_box(&Btrt::default(), &[]), + ] + .concat(), + ), + ), + 3_072, + &[ + TestMuxSample { + bytes: b"one", + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + TestMuxSample { + bytes: b"two", + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + TestMuxSample { + bytes: b"tri", + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + ], + ); + let output_path = write_temp_file("mux-fragment-imported-dtsc-preserve-ddts-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 10.0 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let ddts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dtsc"), + fourcc("ddts"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dtsc"), + fourcc("btrt"), + ]), + ); + assert_eq!(ddts_boxes, vec![expected_ddts]); + assert!(btrt_boxes.is_empty()); +} + +#[test] +fn mux_to_path_fragmented_imported_flac_preserves_dfla_and_strips_btrt() { + let mut expected_dfla = DfLa::default(); + expected_dfla.metadata_blocks = vec![FlacMetadataBlock { + last_metadata_block_flag: true, + block_type: 0, + length: 34, + block_data: vec![0; 34], + }]; + let input = build_imported_track_input_file( + "mux-fragment-imported-flac-preserve-dfla", + &MuxFileConfig::new(48_000) + .with_major_brand(fourcc("isom")) + .with_compatible_brand(fourcc("mp42")), + &MuxTrackConfig::new_audio( + 1, + 48_000, + audio_sample_entry_box_with_children( + "fLaC", + &[ + encode_supported_box(&expected_dfla, &[]), + encode_supported_box(&Btrt::default(), &[]), + ] + .concat(), + ), + ), + 2_048, + &[ + TestMuxSample { + bytes: b"flac-a", + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + TestMuxSample { + bytes: b"flac-b", + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }, + ], + ); + let output_path = write_temp_file("mux-fragment-imported-flac-preserve-dfla-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 10.0 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let dfla_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + fourcc("dfLa"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + fourcc("btrt"), + ]), + ); + assert_eq!(dfla_boxes, vec![expected_dfla]); + assert!(btrt_boxes.is_empty()); +} + +#[test] +fn mux_to_path_fragmented_raw_flac_preserves_dfla_and_strips_btrt() { + let flac_input = write_test_flac_file("mux-fragment-raw-flac-input", b"flac-frame"); + let output_path = write_temp_file("mux-fragment-raw-flac-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&flac_input)]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 10.0 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let dfla_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + fourcc("dfLa"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + fourcc("btrt"), + ]), + ); + assert_eq!(dfla_boxes.len(), 1); + assert!(btrt_boxes.is_empty()); +} + +#[test] +fn mux_to_path_fragmented_ogg_flac_split_header_strips_dfla() { + let flac_input = write_test_ogg_flac_split_header_file( + "mux-fragment-ogg-flac-split-input", + &[b"abc", b"def"], + ); + let output_path = write_temp_file("mux-fragment-ogg-flac-split-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&flac_input)]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 10.0 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let dfla_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + fourcc("dfLa"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + fourcc("btrt"), + ]), + ); + assert!(dfla_boxes.is_empty()); + assert!(btrt_boxes.is_empty()); +} + +#[test] +fn mux_to_path_fragmented_raw_mhas_strips_btrt() { + let mhas_input = + write_test_mhas_file("mux-fragment-raw-mhas-input", &[b"frame-one", b"frame-two"]); + let output_path = write_temp_file("mux-fragment-raw-mhas-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&mhas_input)]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 10.0 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mhm1"), + fourcc("btrt"), + ]), + ); + assert!(btrt_boxes.is_empty()); +} + +#[test] +fn mux_to_path_imports_mp4_text_track_selectors() { + let text_input = build_wvtt_input_file("mux-text-selector-input", fourcc("dash"), &[b"wvtt"]); + let output_path = write_temp_file("mux-text-selector-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &text_input, + MuxMp4TrackSelector::Text { occurrence: 1 }, + )]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"wvtt"); + + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("text")); + + let nmhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("nmhd"), + ]), + ); + assert_eq!(nmhd_boxes.len(), 1); +} + +#[test] +fn mux_to_path_imports_mp4_text_occurrence_selectors() { + let text_input = build_mixed_text_input_file("mux-text-occurrence-input", fourcc("isom")); + let output_path = write_temp_file("mux-text-occurrence-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &text_input, + MuxMp4TrackSelector::Text { occurrence: 2 }, + )]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"stpp"); + + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("subt")); + + let sthd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("sthd"), + ]), + ); + assert_eq!(sthd_boxes.len(), 1); +} + +#[test] +fn mux_to_path_imports_mp4_track_id_selectors_for_text_tracks() { + let text_input = build_mixed_text_input_file("mux-text-trackid-input", fourcc("mp42")); + let output_path = write_temp_file("mux-text-trackid-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &text_input, + MuxMp4TrackSelector::TrackId { track_id: 2 }, + )]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"stpp"); +} + +#[test] +fn mux_to_path_preserves_language_and_handler_names_in_mixed_subtitle_jobs() { + let video_input = build_video_input_file_with_metadata( + "mux-mixed-video-input", + fourcc("isom"), + "avc1", + *b"und", + "PrimaryVideoHandler", + &[b"video"], + ); + let audio_input = build_audio_input_file_with_metadata( + "mux-mixed-audio-input", + fourcc("dash"), + "mp4a", + *b"eng", + "EnglishAudioHandler", + &[b"aud"], + ); + let text_input = build_mixed_text_input_file("mux-mixed-text-input", fourcc("mp42")); + let output_path = write_temp_file("mux-mixed-subtitle-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::mp4(&video_input, MuxMp4TrackSelector::Video), + MuxTrackSpec::mp4(&audio_input, MuxMp4TrackSelector::Audio { occurrence: 1 }), + MuxTrackSpec::mp4(&text_input, MuxMp4TrackSelector::Text { occurrence: 1 }), + MuxTrackSpec::mp4(&text_input, MuxMp4TrackSelector::Text { occurrence: 2 }), + ]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"videoaudwvttstpp" + ); + + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(hdlr_boxes.len(), 4); + assert_eq!( + hdlr_boxes + .iter() + .map(|box_value| box_value.handler_type) + .collect::>(), + vec![ + fourcc("vide"), + fourcc("soun"), + fourcc("text"), + fourcc("subt"), + ] + ); + assert_eq!( + hdlr_boxes + .iter() + .map(|box_value| box_value.name.as_str()) + .collect::>(), + vec![ + "PrimaryVideoHandler", + "EnglishAudioHandler", + "EnglishCaptionHandler", + "FrenchSubtitleHandler", + ] + ); + + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(mdhd_boxes.len(), 4); + assert_eq!( + mdhd_boxes + .iter() + .map(|box_value| decode_mdhd_language(box_value.language)) + .collect::>(), + vec![*b"und", *b"eng", *b"eng", *b"fra"] + ); +} + +#[test] +fn mux_to_path_imports_mp4_broader_video_codec_track_families() { + for sample_entry_type in ["avc1", "hvc1", "av01", "vp08", "vp09", "dvh1", "dvhe"] { + let input = build_video_input_file_with_type( + &format!("mux-video-family-{sample_entry_type}"), + fourcc("isom"), + sample_entry_type, + &[sample_entry_type.as_bytes()], + ); + let output_path = + write_temp_file(&format!("mux-video-family-{sample_entry_type}-out"), &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4(&input, MuxMp4TrackSelector::Video)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + sample_entry_type.as_bytes() + ); + + let entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(sample_entry_type), + ]), + ); + assert_eq!(entries.len(), 1, "{sample_entry_type}"); + assert_eq!(entries[0].sample_entry.box_type, fourcc(sample_entry_type)); + assert_eq!(entries[0].width, 640, "{sample_entry_type}"); + assert_eq!(entries[0].height, 360, "{sample_entry_type}"); + } +} + +#[test] +fn mux_to_path_imports_mp4_broader_audio_codec_track_families() { + for sample_entry_type in [ + "mp4a", "ac-3", "ec-3", "ac-4", "alac", "dtsc", "dtse", "dtsh", "dtsl", "dtsm", "dtsx", + "dts-", "dtsy", "fLaC", "Opus", "iamf", "mha1", "mhm1", + ] { + let input = build_audio_input_file_with_type( + &format!("mux-audio-family-{sample_entry_type}"), + fourcc("isom"), + sample_entry_type, + &[sample_entry_type.as_bytes()], + ); + let output_path = + write_temp_file(&format!("mux-audio-family-{sample_entry_type}-out"), &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + sample_entry_type.as_bytes() + ); + + let entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(sample_entry_type), + ]), + ); + assert_eq!(entries.len(), 1, "{sample_entry_type}"); + assert_eq!(entries[0].sample_entry.box_type, fourcc(sample_entry_type)); + assert_eq!(entries[0].channel_count, 2, "{sample_entry_type}"); + } +} + +#[test] +fn mux_to_path_imports_raw_aac_adts_inputs() { + let aac_input = write_test_adts_file("mux-raw-aac-input", &[b"abc", b"defg"]); + let output_path = write_temp_file("mux-raw-aac-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&aac_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"abcdefg"); + + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].name, "SoundHandler"); +} + +#[test] +fn mux_to_path_flat_auto_profile_interleaves_long_raw_aac_inputs() { + let payloads = (0..45).map(|_| b"abcdef".as_slice()).collect::>(); + let aac_input = write_test_adts_file("mux-raw-aac-interleaved-input", &payloads); + let output_path = write_temp_file("mux-raw-aac-interleaved-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&aac_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let esds_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + fourcc("esds"), + ]), + ); + let stsc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + let stco_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stco"), + ]), + ); + + assert_eq!(esds_boxes.len(), 1); + let decoder_config = esds_boxes[0].decoder_config_descriptor().unwrap(); + assert_eq!(decoder_config.buffer_size_db, 6); + assert_eq!(decoder_config.max_bitrate, 2_160); + assert_eq!(decoder_config.avg_bitrate, 2_064); + assert_eq!(stsc_boxes.len(), 1); + assert_eq!(stco_boxes.len(), 1); + assert_eq!(stsc_boxes[0].entries.len(), 2); + assert_eq!(stsc_boxes[0].entries[0].first_chunk, 1); + assert_eq!(stsc_boxes[0].entries[0].samples_per_chunk, 21); + assert_eq!(stsc_boxes[0].entries[1].first_chunk, 3); + assert_eq!(stsc_boxes[0].entries[1].samples_per_chunk, 3); + assert_eq!(stco_boxes[0].entry_count, 3); +} + +#[test] +fn mux_to_path_flat_auto_profile_interleaves_long_raw_mp3_inputs() { + let payloads = (0..43).map(|_| b"abcdef".as_slice()).collect::>(); + let mp3_input = write_test_mp3_file("mux-raw-mp3-interleaved-input", &payloads); + let output_path = write_temp_file("mux-raw-mp3-interleaved-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&mp3_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let stsc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + let stco_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stco"), + ]), + ); + + assert_eq!(stsc_boxes.len(), 1); + assert_eq!(stco_boxes.len(), 1); + assert_eq!(stsc_boxes[0].entries.len(), 2); + assert_eq!(stsc_boxes[0].entries[0].first_chunk, 1); + assert_eq!(stsc_boxes[0].entries[0].samples_per_chunk, 20); + assert_eq!(stsc_boxes[0].entries[1].first_chunk, 3); + assert_eq!(stsc_boxes[0].entries[1].samples_per_chunk, 3); + assert_eq!(stco_boxes[0].entry_count, 3); +} + +#[test] +fn mux_to_path_flat_auto_profile_authors_avc_plus_mp3_import_style_iods_profiles() { + let h264_input = write_test_h264_annexb_file("mux-flat-h264-mp3-iods-h264-input", &[b"idr"]); + let mp3_input = write_test_mp3_file("mux-flat-h264-mp3-iods-mp3-input", &[b"abcdef"]); + let output_path = write_temp_file("mux-flat-h264-mp3-iods-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&h264_input), + MuxTrackSpec::path(&mp3_input), + ]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + assert_eq!(iods_boxes.len(), 1); + let descriptor = iods_boxes[0].initial_object_descriptor().unwrap(); + assert_eq!(descriptor.audio_profile_level_indication, 0xff); + assert_eq!(descriptor.visual_profile_level_indication, 0x15); +} + +#[test] +fn mux_to_path_flat_auto_profile_keeps_avc_plus_aac_visual_profile_at_7f() { + let h264_input = write_test_h264_annexb_file("mux-flat-h264-aac-iods-h264-input", &[b"idr"]); + let aac_input = write_test_adts_file("mux-flat-h264-aac-iods-aac-input", &[b"abcdef"]); + let output_path = write_temp_file("mux-flat-h264-aac-iods-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&h264_input), + MuxTrackSpec::path(&aac_input), + ]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + assert_eq!(iods_boxes.len(), 1); + let descriptor = iods_boxes[0].initial_object_descriptor().unwrap(); + assert_eq!(descriptor.audio_profile_level_indication, 0x29); + assert_eq!(descriptor.visual_profile_level_indication, 0x7f); +} + +#[test] +fn mux_to_path_flat_single_sample_h264_plus_aac_omits_video_lead_in_boxes() { + let h264_input = write_test_h264_annexb_file("mux-flat-h264-aac-lead-in-h264-input", &[b"idr"]); + let aac_input = write_test_adts_file("mux-flat-h264-aac-lead-in-aac-input", &[b"abcdef"]); + let output_path = write_temp_file("mux-flat-h264-aac-lead-in-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&h264_input), + MuxTrackSpec::path(&aac_input), + ]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_ctts = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("ctts"), + ]), + ); + let video_elst = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("edts"), + fourcc("elst"), + ]), + ); + let video_btrt = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + fourcc("btrt"), + ]), + ); + assert!(video_ctts.is_empty()); + assert!(video_elst.is_empty()); + assert!(video_btrt.is_empty()); +} + +#[test] +fn mux_to_path_flat_single_sample_h264_multi_audio_omits_video_lead_in_boxes() { + let h264_input = + write_test_h264_annexb_file("mux-flat-h264-multi-audio-lead-in-h264-input", &[b"idr"]); + let aac_input = + write_test_adts_file("mux-flat-h264-multi-audio-lead-in-aac-input", &[b"abcdef"]); + let mp3_input = + write_test_mp3_file("mux-flat-h264-multi-audio-lead-in-mp3-input", &[b"abcdef"]); + let output_path = write_temp_file("mux-flat-h264-multi-audio-lead-in-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&h264_input), + MuxTrackSpec::path(&aac_input), + MuxTrackSpec::path(&mp3_input), + ]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let video_ctts = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("ctts"), + ]), + ); + let video_elst = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("edts"), + fourcc("elst"), + ]), + ); + let video_btrt = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + fourcc("btrt"), + ]), + ); + + assert!(video_ctts.is_empty()); + assert!(video_elst.is_empty()); + assert!(video_btrt.is_empty()); +} + +#[test] +fn mux_to_path_flat_auto_profile_keeps_avc_plus_aac_plus_ac3_visual_profile_at_7f() { + let h264_input = + write_test_h264_annexb_file("mux-flat-h264-aac-ac3-iods-h264-input", &[b"idr"]); + let aac_input = write_test_adts_file("mux-flat-h264-aac-ac3-iods-aac-input", &[b"abcdef"]); + let ac3_input = write_test_ac3_file("mux-flat-h264-aac-ac3-iods-ac3-input", &[b"ac3"]); + let output_path = write_temp_file("mux-flat-h264-aac-ac3-iods-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&h264_input), + MuxTrackSpec::path(&aac_input), + MuxTrackSpec::path(&ac3_input), + ]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + assert_eq!(iods_boxes.len(), 1); + let descriptor = iods_boxes[0].initial_object_descriptor().unwrap(); + assert_eq!(descriptor.audio_profile_level_indication, 0x29); + assert_eq!(descriptor.visual_profile_level_indication, 0x7f); +} + +#[test] +fn mux_to_path_flat_auto_profile_authors_avc_plus_speex_import_style_iods_profiles() { + let h264_input = write_test_h264_annexb_file("mux-flat-h264-speex-iods-h264-input", &[b"idr"]); + let speex_input = write_test_ogg_speex_file("mux-flat-h264-speex-iods-speex-input", &[b"abc"]); + let output_path = write_temp_file("mux-flat-h264-speex-iods-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&h264_input), + MuxTrackSpec::path(&speex_input), + ]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + assert_eq!(iods_boxes.len(), 1); + let descriptor = iods_boxes[0].initial_object_descriptor().unwrap(); + assert_eq!(descriptor.audio_profile_level_indication, 0xff); + assert_eq!(descriptor.visual_profile_level_indication, 0x15); +} + +#[test] +fn mux_to_path_flat_auto_profile_authors_direct_mp4v_import_style_iods_profiles() { + let decoder_specific_info = build_test_mp4v_decoder_specific_info(320, 180); + let intra_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x00, 0xAA, 0xBB]; + let predictive_frame = [0x00_u8, 0x00, 0x01, 0xB6, 0x40, 0xCC, 0xDD]; + let mut elementary = decoder_specific_info; + elementary.extend_from_slice(&intra_frame); + elementary.extend_from_slice(&predictive_frame); + let mp4v_input = write_test_mp4v_file("mux-flat-mp4v-iods-input", &elementary); + let output_path = write_temp_file("mux-flat-mp4v-iods-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&mp4v_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + assert_eq!(iods_boxes.len(), 1); + let descriptor = iods_boxes[0].initial_object_descriptor().unwrap(); + assert_eq!(descriptor.audio_profile_level_indication, 0xff); + assert_eq!(descriptor.visual_profile_level_indication, 0x01); +} + +#[test] +fn mux_to_path_flat_auto_profile_authors_direct_ogg_theora_import_style_iods_profiles() { + let theora_input = + write_test_ogg_theora_file("mux-flat-theora-iods-input", &[b"frame-a", b"frame-b"]); + let output_path = write_temp_file("mux-flat-theora-iods-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&theora_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + assert_eq!(iods_boxes.len(), 1); + let descriptor = iods_boxes[0].initial_object_descriptor().unwrap(); + assert_eq!(descriptor.audio_profile_level_indication, 0xff); + assert_eq!(descriptor.visual_profile_level_indication, 0xfe); +} + +#[test] +fn mux_to_path_flat_auto_profile_authors_avc_plus_amr_import_style_iods_profiles() { + let h264_input = write_test_h264_annexb_file("mux-flat-h264-amr-iods-h264-input", &[b"idr"]); + let amr_input = write_test_amr_file("mux-flat-h264-amr-iods-amr-input", &[b"abc", b"def"]); + let output_path = write_temp_file("mux-flat-h264-amr-iods-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&h264_input), + MuxTrackSpec::path(&amr_input), + ]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + assert_eq!(iods_boxes.len(), 1); + let descriptor = iods_boxes[0].initial_object_descriptor().unwrap(); + assert_eq!(descriptor.audio_profile_level_indication, 0xfe); + assert_eq!(descriptor.visual_profile_level_indication, 0x15); +} + +#[test] +fn mux_to_path_flat_auto_profile_authors_theora_plus_aac_import_style_iods_profiles() { + let theora_input = write_test_ogg_theora_file( + "mux-flat-theora-aac-iods-theora-input", + &[b"frame-a", b"frame-b"], + ); + let aac_input = write_test_adts_file("mux-flat-theora-aac-iods-aac-input", &[b"abcdef"]); + let output_path = write_temp_file("mux-flat-theora-aac-iods-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&theora_input), + MuxTrackSpec::path(&aac_input), + ]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + assert_eq!(iods_boxes.len(), 1); + let descriptor = iods_boxes[0].initial_object_descriptor().unwrap(); + assert_eq!(descriptor.audio_profile_level_indication, 0x29); + assert_eq!(descriptor.visual_profile_level_indication, 0xfe); +} + +#[test] +fn mux_to_path_flat_auto_profile_authors_avc_plus_qcp_import_style_iods_profiles() { + let h264_input = write_test_h264_annexb_file("mux-flat-h264-qcp-iods-h264-input", &[b"idr"]); + let qcp_input = write_test_qcp_constant_file( + "mux-flat-h264-qcp-iods-qcp-input", + TestQcpCodecKind::Qcelp, + &[b"abc", b"def"], + ); + let output_path = write_temp_file("mux-flat-h264-qcp-iods-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&h264_input), + MuxTrackSpec::path(&qcp_input), + ]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + assert_eq!(iods_boxes.len(), 1); + let descriptor = iods_boxes[0].initial_object_descriptor().unwrap(); + assert_eq!(descriptor.audio_profile_level_indication, 0xfe); + assert_eq!(descriptor.visual_profile_level_indication, 0x15); +} + +#[test] +fn mux_to_path_flat_auto_profile_authors_avc_plus_mhas_import_style_iods_profiles() { + let h264_input = write_test_h264_annexb_file("mux-flat-h264-mhas-iods-h264-input", &[b"idr"]); + let mhas_input = write_test_mhas_file("mux-flat-h264-mhas-iods-mhas-input", &[b"frame-one"]); + let output_path = write_temp_file("mux-flat-h264-mhas-iods-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&h264_input), + MuxTrackSpec::path(&mhas_input), + ]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + assert_eq!(iods_boxes.len(), 1); + let descriptor = iods_boxes[0].initial_object_descriptor().unwrap(); + assert_eq!(descriptor.audio_profile_level_indication, 0xfe); + assert_eq!(descriptor.visual_profile_level_indication, 0x15); +} + +#[test] +fn mux_to_path_flat_auto_profile_authors_direct_mhas_import_style_iods_profiles() { + let mhas_input = write_test_mhas_file("mux-flat-mhas-iods-input", &[b"frame-one"]); + let output_path = write_temp_file("mux-flat-mhas-iods-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&mhas_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + assert_eq!(iods_boxes.len(), 1); + let descriptor = iods_boxes[0].initial_object_descriptor().unwrap(); + assert_eq!(descriptor.audio_profile_level_indication, 0x0c); + assert_eq!(descriptor.visual_profile_level_indication, 0xff); +} + +#[test] +fn mux_to_path_flat_auto_profile_omits_direct_transport_stream_mhas_iods() { + let ts_input = write_test_transport_stream_mhas_file( + "mux-flat-transport-stream-mhas-iods-input", + &[b"frame-one", b"frame-two"], + ); + let output_path = write_temp_file("mux-flat-transport-stream-mhas-iods-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let iods_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + assert!(iods_boxes.is_empty()); +} + +#[test] +fn mux_to_path_flat_auto_profile_preserves_terminal_mp3_chunk_run_boundary() { + let payloads = (0..171).map(|_| b"abcdef".as_slice()).collect::>(); + let mp3_input = write_test_mp3_44100_file("mux-raw-mp3-terminal-run-input", &payloads); + let output_path = write_temp_file("mux-raw-mp3-terminal-run-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&mp3_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let stsc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + let stco_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stco"), + ]), + ); + + assert_eq!(stsc_boxes.len(), 1); + assert_eq!(stco_boxes.len(), 1); + assert_eq!(stsc_boxes[0].entries.len(), 2); + assert_eq!(stsc_boxes[0].entries[0].first_chunk, 1); + assert_eq!(stsc_boxes[0].entries[0].samples_per_chunk, 19); + assert_eq!(stsc_boxes[0].entries[1].first_chunk, 9); + assert_eq!(stsc_boxes[0].entries[1].samples_per_chunk, 19); + assert_eq!(stco_boxes[0].entry_count, 9); +} + +#[test] +fn mux_to_path_imports_path_only_latm_inputs() { + let latm_input = write_test_latm_file("mux-raw-latm-input", &[b"abc", b"defg"]); + let output_path = write_temp_file("mux-raw-latm-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&latm_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"abcdefg"); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + fourcc("esds"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("mp4a")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(esds_boxes.len(), 1); + assert_eq!( + esds_boxes[0] + .decoder_config_descriptor() + .unwrap() + .object_type_indication, + 0x40 + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1_024); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].name, "SoundHandler"); +} + +#[test] +fn mux_to_path_imports_path_only_usac_latm_inputs() { + let first_payload = b"\x80abc"; + let second_payload = b"\x00defg"; + let latm_input = write_test_usac_latm_file( + "mux-raw-usac-latm-input", + &[first_payload.as_slice(), second_payload.as_slice()], + ); + let output_path = write_temp_file("mux-raw-usac-latm-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&latm_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + [first_payload.as_slice(), second_payload.as_slice()].concat() + ); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + fourcc("esds"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("mp4a")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(esds_boxes.len(), 1); + assert_eq!( + esds_boxes[0] + .decoder_config_descriptor() + .unwrap() + .object_type_indication, + 0x40 + ); + assert_eq!(esds_boxes[0].decoder_specific_info().unwrap().len(), 3); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 1_024, + }] + ); + + let probed = probe_codec_detailed_bytes(&output_bytes).unwrap(); + assert_eq!(probed.tracks.len(), 1); + match &probed.tracks[0].codec_details { + TrackCodecDetails::Mp4Audio(details) => { + assert_eq!(details.object_type_indication, 0x40); + assert_eq!(details.audio_object_type, 42); + assert_eq!(details.channel_count, 2); + assert_eq!(details.sample_rate, Some(48_000)); + } + other => panic!("expected mp4 audio codec details, found {other:?}"), + } +} + +#[test] +fn mux_to_path_imports_path_only_truehd_inputs() { + let truehd_input = write_test_truehd_file("mux-raw-truehd-input", &[b"abcdefgh", b"ijklmnop"]); + let output_path = write_temp_file("mux-raw-truehd-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&truehd_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let expected_payload = fs::read(&truehd_input).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mlpa"), + ]), + ); + let dmlp_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mlpa"), + fourcc("dmlp"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("mlpa")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(audio_entries[0].sample_rate, 48_000); + assert_eq!(dmlp_boxes.len(), 1); + assert_eq!(dmlp_boxes[0].format_info, 0); + assert_eq!(dmlp_boxes[0].peak_data_rate, 0); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mlpa"), + fourcc("btrt"), + ]), + ); + assert_eq!(btrt_boxes.len(), 1); + assert_eq!(btrt_boxes[0].buffer_size_db, 40); + assert_eq!(btrt_boxes[0].max_bitrate, 384_000); + assert_eq!(btrt_boxes[0].avg_bitrate, 384_000); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 40); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].name, "SoundHandler"); +} + +#[test] +fn mux_to_path_imports_path_only_raw_ac4_inputs() { + let ac4_input = write_test_ac4_file("mux-raw-ac4-input", 2); + let output_path = write_temp_file("mux-raw-ac4-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ac4_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ac-4"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let dac4_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ac-4"), + fourcc("dac4"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ac-4"), + fourcc("btrt"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ac-4")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(dac4_boxes.len(), 1); + assert_eq!(dac4_boxes[0].data.len(), 29); + assert_eq!(btrt_boxes.len(), 1); + assert_eq!(btrt_boxes[0].buffer_size_db, 348); + assert_eq!(btrt_boxes[0].max_bitrate, 83_432); + assert_eq!(btrt_boxes[0].avg_bitrate, 83_432); + assert!(mdhd_boxes[0].timescale > 0); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert!(stts_boxes[0].entries[0].sample_delta > 0); +} + +#[test] +fn mux_to_path_imports_path_only_raw_amr_inputs() { + let amr_input = write_test_amr_file("mux-raw-amr-input", &[b"one", b"two"]); + let input_bytes = fs::read(&amr_input).unwrap(); + let output_path = write_temp_file("mux-raw-amr-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&amr_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + &input_bytes[6..] + ); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("samr"), + ]), + ); + let damr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("samr"), + fourcc("damr"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("samr")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(damr_boxes.len(), 1); + assert_eq!(damr_boxes[0].vendor, 0); + assert_eq!(damr_boxes[0].frames_per_sample, 1); + assert_ne!(damr_boxes[0].mode_set, 0); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 8_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 160); +} + +#[test] +fn mux_to_path_imports_path_only_raw_amr_wb_inputs() { + let amr_input = write_test_amr_wb_file("mux-raw-amr-wb-input", &[b"wide", b"band"]); + let input_bytes = fs::read(&amr_input).unwrap(); + let output_path = write_temp_file("mux-raw-amr-wb-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&amr_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + &input_bytes[9..] + ); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("sawb"), + ]), + ); + let damr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("sawb"), + fourcc("damr"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("sawb")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(damr_boxes.len(), 1); + assert_eq!(damr_boxes[0].vendor, 0); + assert_eq!(damr_boxes[0].frames_per_sample, 1); + assert_ne!(damr_boxes[0].mode_set, 0); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 16_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 320); +} + +#[test] +fn mux_to_path_imports_path_only_qcelp_qcp_inputs() { + let packet_one = b"QCP1"; + let packet_two = b"QCP2"; + let qcp_input = write_test_qcp_constant_file( + "mux-raw-qcelp-input", + TestQcpCodecKind::Qcelp, + &[&packet_one[..], &packet_two[..]], + ); + let output_path = write_temp_file("mux-raw-qcelp-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&qcp_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + [packet_one.as_slice(), packet_two.as_slice()].concat() + ); + let ftyp_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("ftyp")])); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("sqcp"), + ]), + ); + let dqcp_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("sqcp"), + fourcc("dqcp"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(ftyp_boxes.len(), 1); + assert_eq!(ftyp_boxes[0].major_brand, fourcc("3g2a")); + assert_eq!(ftyp_boxes[0].minor_version, 65_536); + assert_eq!( + ftyp_boxes[0].compatible_brands, + vec![fourcc("isom"), fourcc("3g2a")] + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("sqcp")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(dqcp_boxes.len(), 1); + assert_eq!(dqcp_boxes[0].vendor, 0); + assert_eq!(dqcp_boxes[0].frames_per_sample, 1); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 8_000); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 160 + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_evrc_qcp_inputs() { + let packet_one = (3_u8, &b"EVR"[..]); + let packet_two = (7_u8, &b"C12X"[..]); + let qcp_input = write_test_qcp_variable_file( + "mux-raw-evrc-input", + TestQcpCodecKind::Evrc, + &[packet_one, packet_two], + ); + let output_path = write_temp_file("mux-raw-evrc-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&qcp_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + [ + &[packet_one.0][..], + packet_one.1, + &[packet_two.0][..], + packet_two.1 + ] + .concat() + ); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("sevc"), + ]), + ); + let devc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("sevc"), + fourcc("devc"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("sevc")); + assert_eq!(devc_boxes.len(), 1); + assert_eq!(devc_boxes[0].vendor, 0); + assert_eq!(devc_boxes[0].frames_per_sample, 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 160 + }] + ); +} + +#[test] +fn mux_to_path_imports_path_only_smv_qcp_inputs() { + let packet_one = b"SMVA"; + let packet_two = b"SMVB"; + let qcp_input = write_test_qcp_constant_file( + "mux-raw-smv-input", + TestQcpCodecKind::Smv, + &[&packet_one[..], &packet_two[..]], + ); + let output_path = write_temp_file("mux-raw-smv-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&qcp_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + [packet_one.as_slice(), packet_two.as_slice()].concat() + ); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ssmv"), + ]), + ); + let dsmv_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ssmv"), + fourcc("dsmv"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ssmv")); + assert_eq!(dsmv_boxes.len(), 1); + assert_eq!(dsmv_boxes[0].vendor, 0); + assert_eq!(dsmv_boxes[0].frames_per_sample, 1); +} + +#[test] +fn mux_to_path_flat_auto_profile_authors_avc_plus_qcp_import_style_brands() { + let h264_input = write_test_h264_annexb_file("mux-flat-h264-qcp-brand-h264-input", &[b"idr"]); + let qcp_input = write_test_qcp_constant_file( + "mux-flat-h264-qcp-brand-qcp-input", + TestQcpCodecKind::Qcelp, + &[&b"QCP1"[..]], + ); + let output_path = write_temp_file("mux-flat-h264-qcp-brand-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&h264_input), + MuxTrackSpec::path(&qcp_input), + ]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let ftyp_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("ftyp")])); + assert_eq!(ftyp_boxes.len(), 1); + assert_eq!(ftyp_boxes[0].major_brand, fourcc("3g2a")); + assert_eq!(ftyp_boxes[0].minor_version, 65_536); + assert_eq!( + ftyp_boxes[0].compatible_brands, + vec![fourcc("isom"), fourcc("avc1"), fourcc("3g2a")] + ); +} + +#[test] +fn mux_to_path_imports_path_only_mhas_inputs() { + let mhas_input = write_test_mhas_file("mux-raw-mhas-input", &[b"frame-one", b"frame-two"]); + let expected_payload = fs::read(&mhas_input).unwrap(); + let output_path = write_temp_file("mux-raw-mhas-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&mhas_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mhm1"), + ]), + ); + let mhac_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mhm1"), + fourcc("mhaC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mhm1"), + fourcc("btrt"), + ]), + ); + let stss_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("mhm1")); + assert_eq!(audio_entries[0].channel_count, 0); + assert!(mhac_boxes.is_empty()); + assert_eq!(btrt_boxes.len(), 1); + assert!(btrt_boxes[0].buffer_size_db > 0); + assert!(btrt_boxes[0].max_bitrate > 0); + assert!(btrt_boxes[0].avg_bitrate > 0); + assert_eq!(stss_boxes.len(), 1); + assert_eq!(stss_boxes[0].entry_count, 1); + assert_eq!(stss_boxes[0].sample_number, vec![1]); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1_024); +} + +#[test] +fn mux_to_path_imports_path_only_raw_flac_inputs() { + let flac_input = write_test_flac_file("mux-raw-flac-input", b"flac-frame"); + let output_path = write_temp_file("mux-raw-flac-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&flac_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let input_bytes = fs::read(&flac_input).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + &input_bytes[42..] + ); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + fourcc("btrt"), + ]), + ); + let dfla_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + fourcc("dfLa"), + ]), + ); + let dfla_box_bytes = extract_box_bytes( + &mut Cursor::new(&output_bytes), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + fourcc("dfLa"), + ]), + ) + .unwrap(); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("fLaC")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(dfla_boxes.len(), 1); + assert_eq!(dfla_box_bytes.len(), 1); + assert_eq!(dfla_boxes[0].metadata_blocks.len(), 1); + assert_eq!(dfla_boxes[0].metadata_blocks[0].block_type, 0); + assert_eq!(dfla_boxes[0].metadata_blocks[0].length, 34); + assert_eq!(dfla_box_bytes[0][12], 0x00); + assert_eq!(btrt_boxes.len(), 1); + assert!(btrt_boxes[0].buffer_size_db > 0); + assert!(btrt_boxes[0].max_bitrate > 0); + assert!(btrt_boxes[0].avg_bitrate > 0); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 1); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1_024); +} + +#[test] +fn mux_to_path_imports_path_only_multi_frame_raw_flac_inputs() { + let flac_input = write_test_flac_file_with_frames( + "mux-raw-flac-multi-input", + &[b"frame-a", b"frame-b", b"frame-c"], + ); + let output_path = write_temp_file("mux-raw-flac-multi-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&flac_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + let stsc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 3); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1_024); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(stsz_boxes[0].sample_count, 3); + assert_eq!(stsc_boxes.len(), 1); + assert_eq!(stsc_boxes[0].entry_count, 1); + assert_eq!(stsc_boxes[0].entries.len(), 1); + assert_eq!( + stsc_boxes[0].entries[0], + StscEntry { + first_chunk: 1, + samples_per_chunk: 3, + sample_description_index: 1, + } + ); +} + +#[test] +fn mux_to_path_flat_auto_profile_preserves_terminal_flac_chunk_run_boundary_in_multi_audio_merge() { + let h264_input = + write_test_h264_annexb_file("mux-flat-multi-audio-flac-h264-input", &[b"h264-sample"]); + let flac_frames = [ + b"frame-00".as_slice(), + b"frame-01".as_slice(), + b"frame-02".as_slice(), + b"frame-03".as_slice(), + b"frame-04".as_slice(), + b"frame-05".as_slice(), + b"frame-06".as_slice(), + b"frame-07".as_slice(), + b"frame-08".as_slice(), + b"frame-09".as_slice(), + ]; + let flac_input = write_test_flac_file_with_frames_and_block_size( + "mux-flat-multi-audio-flac-audio-input", + 48_000, + 5_880, + &flac_frames, + ); + let opus_input = + write_test_ogg_opus_file("mux-flat-multi-audio-opus-input", &[b"opus-a", b"opus-b"]); + let output_path = write_temp_file("mux-flat-multi-audio-flac-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&h264_input), + MuxTrackSpec::path(&flac_input), + MuxTrackSpec::path(&opus_input), + ]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + let stsc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + assert_eq!( + hdlr_boxes + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(), + vec!["VideoHandler", "SoundHandler", "SoundHandler"] + ); + let flac_track_index = 1; + assert_eq!(stsc_boxes.len(), 3); + assert_eq!(stsc_boxes[flac_track_index].entry_count, 3); + assert_eq!( + stsc_boxes[flac_track_index].entries, + vec![ + StscEntry { + first_chunk: 1, + samples_per_chunk: 4, + sample_description_index: 1, + }, + StscEntry { + first_chunk: 2, + samples_per_chunk: 3, + sample_description_index: 1, + }, + StscEntry { + first_chunk: 3, + samples_per_chunk: 3, + sample_description_index: 1, + }, + ] + ); +} + +#[test] +fn mux_to_path_imports_path_only_ogg_flac_inputs() { + let flac_input = write_test_ogg_flac_file("mux-raw-ogg-flac-input", &[b"abc", b"def"]); + let output_path = write_temp_file("mux-raw-ogg-flac-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&flac_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let dfla_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + fourcc("dfLa"), + ]), + ); + let dfla_box_bytes = extract_box_bytes( + &mut Cursor::new(&output_bytes), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + fourcc("dfLa"), + ]), + ) + .unwrap(); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("fLaC")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(dfla_boxes, vec![DfLa::default()]); + assert_eq!(dfla_box_bytes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 1_000); + assert_eq!( + stts_boxes[0].entries, + vec![ + SttsEntry { + sample_count: 1, + sample_delta: 1, + }, + SttsEntry { + sample_count: 1, + sample_delta: 0, + }, + ] + ); +} + +#[test] +fn mux_to_path_imports_path_only_ogg_flac_mapping_header_inputs() { + let flac_input = + write_test_ogg_flac_mapping_file("mux-raw-ogg-flac-mapping-input", &[b"abc", b"def"]); + let output_path = write_temp_file("mux-raw-ogg-flac-mapping-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&flac_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let dfla_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + fourcc("dfLa"), + ]), + ); + let dfla_box_bytes = extract_box_bytes( + &mut Cursor::new(&output_bytes), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + fourcc("dfLa"), + ]), + ) + .unwrap(); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("fLaC")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(dfla_boxes, vec![DfLa::default()]); + assert_eq!(dfla_box_bytes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 1_000); + assert_eq!( + stts_boxes[0].entries, + vec![ + SttsEntry { + sample_count: 1, + sample_delta: 1, + }, + SttsEntry { + sample_count: 1, + sample_delta: 0, + }, + ] + ); +} + +#[test] +fn mux_to_path_imports_path_only_ogg_flac_split_header_inputs() { + let flac_input = + write_test_ogg_flac_split_header_file("mux-raw-ogg-flac-split-input", &[b"abc", b"def"]); + let output_path = write_temp_file("mux-raw-ogg-flac-split-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&flac_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fLaC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("fLaC")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(mdhd_boxes[0].timescale, 1_000); + assert_eq!(stts_boxes[0].entries.len(), 2); + assert_eq!(stts_boxes[0].entries[0].sample_count, 1); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1); + assert_eq!(stts_boxes[0].entries[1].sample_count, 1); + assert_eq!(stts_boxes[0].entries[1].sample_delta, 0); +} + +#[test] +fn mux_to_path_imports_path_only_ogg_opus_inputs() { + let opus_input = write_test_ogg_opus_file("mux-raw-opus-input", &[b"abc", b"def"]); + let output_path = write_temp_file("mux-raw-opus-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&opus_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"\0abc\0def"); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("Opus"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("Opus"), + fourcc("btrt"), + ]), + ); + let elst_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("edts"), + fourcc("elst"), + ]), + ); + let sgpd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("sgpd"), + ]), + ); + let sbgp_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("sbgp"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("Opus")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(btrt_boxes.len(), 1); + assert!(btrt_boxes[0].buffer_size_db > 0); + assert!(btrt_boxes[0].max_bitrate > 0); + assert!(btrt_boxes[0].avg_bitrate > 0); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(mdhd_boxes[0].duration_v0, 960); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 480); + assert_eq!(elst_boxes.len(), 1); + assert_eq!(elst_boxes[0].entries.len(), 1); + assert_eq!(elst_boxes[0].entries[0].segment_duration_v0, 8); + assert_eq!(elst_boxes[0].entries[0].media_time_v0, 312); + assert_eq!(sgpd_boxes.len(), 1); + assert_eq!(sgpd_boxes[0].grouping_type, fourcc("roll")); + assert_eq!(sgpd_boxes[0].default_length, 2); + assert_eq!(sgpd_boxes[0].entry_count, 1); + assert_eq!(sgpd_boxes[0].roll_distances, vec![3_840]); + assert_eq!(sbgp_boxes.len(), 1); + assert_eq!(sbgp_boxes[0].grouping_type, u32::from_be_bytes(*b"roll")); + assert_eq!(sbgp_boxes[0].entry_count, 1); + assert_eq!(sbgp_boxes[0].entries.len(), 1); + assert_eq!(sbgp_boxes[0].entries[0].sample_count, 2); + assert_eq!(sbgp_boxes[0].entries[0].group_description_index, 1); +} + +#[test] +fn mux_to_path_imports_generated_nhml_sidecar_inputs() { + let opus_input = write_test_ogg_opus_file("mux-nhml-sidecar-input", &[b"abc", b"def"]); + let reference_output = write_temp_file("mux-nhml-sidecar-reference", &[]); + let sidecar_output = write_temp_file("mux-nhml-sidecar-output", &[]); + + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&opus_input)]), + &reference_output, + ) + .unwrap(); + + let report = inspect_direct_ingest_path(&opus_input).unwrap(); + let mut rendered = Vec::new(); + write_report(&mut rendered, &report, DirectIngestReportFormat::Nhml).unwrap(); + let sidecar_path = write_temp_file_with_extension("mux-nhml-sidecar", "nhml", &rendered); + + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&sidecar_path)]), + &sidecar_output, + ) + .unwrap(); + + assert_eq!( + fs::read(&sidecar_output).unwrap(), + fs::read(&reference_output).unwrap() + ); +} + +#[test] +fn mux_to_path_imports_generated_nhnt_sidecar_inputs() { + let opus_input = write_test_ogg_opus_file("mux-nhnt-sidecar-input", &[b"abc", b"def"]); + let reference_output = write_temp_file("mux-nhnt-sidecar-reference", &[]); + let sidecar_output = write_temp_file("mux-nhnt-sidecar-output", &[]); + + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&opus_input)]), + &reference_output, + ) + .unwrap(); + + let report = inspect_direct_ingest_packets(&opus_input).unwrap(); + let mut rendered = Vec::new(); + write_packet_report(&mut rendered, &report, DirectIngestReportFormat::Nhnt).unwrap(); + let sidecar_path = write_temp_file_with_extension("mux-nhnt-sidecar", "nhnt", &rendered); + + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&sidecar_path)]), + &sidecar_output, + ) + .unwrap(); + + assert_eq!( + fs::read(&sidecar_output).unwrap(), + fs::read(&reference_output).unwrap() + ); +} + +#[test] +fn mux_to_path_imports_local_dash_templates_with_representation_tokens() { + let source_input = build_video_input_file( + "mux-dash-template-source", + fourcc("isom"), + &[b"dash-template-frame"], + ); + let manifest_dir = temp_output_dir("mux-dash-template-manifest"); + fs::create_dir_all(&manifest_dir).unwrap(); + let segment_path = manifest_dir.join("video_64000_1.mp4"); + fs::copy(&source_input, &segment_path).unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let manifest_output = write_temp_file("mux-dash-template-manifest-output", &[]); + + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]), + &manifest_output, + ) + .unwrap(); + + let output_bytes = fs::read(&manifest_output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"dash-template-frame" + ); + + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("avc1")); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 1_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 10, + }] + ); +} + +#[test] +fn mux_to_path_inherits_adaptation_set_dash_template_tokens() { + let source_input = build_video_input_file( + "mux-dash-adaptation-template-source", + fourcc("isom"), + &[b"dash-adaptation-template-frame"], + ); + let manifest_dir = temp_output_dir("mux-dash-adaptation-template-manifest"); + fs::create_dir_all(&manifest_dir).unwrap(); + let segment_path = manifest_dir.join("video_64000_1.mp4"); + fs::copy(&source_input, &segment_path).unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let manifest_output = write_temp_file("mux-dash-adaptation-template-output", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]), + &manifest_output, + ) + .unwrap(); + + let output_bytes = fs::read(&manifest_output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"dash-adaptation-template-frame" + ); + + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 1_000); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 10, + }] + ); +} + +#[test] +fn mux_to_path_imports_local_dash_templates_with_time_tokens() { + let source_input = build_video_input_file( + "mux-dash-time-template-source", + fourcc("isom"), + &[b"dash-time-frame"], + ); + let manifest_dir = temp_output_dir("mux-dash-time-template-manifest"); + fs::create_dir_all(&manifest_dir).unwrap(); + fs::copy(&source_input, manifest_dir.join("segment_900.mp4")).unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let output_path = write_temp_file("mux-dash-time-template-output", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]), + &output_path, + ) + .unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free") + ] + ); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"dash-time-frame" + ); + + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 1_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 10, + }] + ); +} + +#[test] +fn mux_to_path_inherits_adaptation_set_dash_segment_list() { + let source_input = build_video_input_file( + "mux-dash-adaptation-list-source", + fourcc("isom"), + &[b"dash-adaptation-list-frame"], + ); + let manifest_dir = temp_output_dir("mux-dash-adaptation-list-manifest"); + fs::create_dir_all(&manifest_dir).unwrap(); + fs::copy(&source_input, manifest_dir.join("segment.mp4")).unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let output_path = write_temp_file("mux-dash-adaptation-list-output", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]), + &output_path, + ) + .unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"dash-adaptation-list-frame" + ); +} + +#[test] +fn mux_to_path_imports_local_dash_number_templates_with_formatting_and_literal_dollars() { + let source_input = build_video_input_file( + "mux-dash-number-template-source", + fourcc("isom"), + &[b"dash-number-frame"], + ); + let manifest_dir = temp_output_dir("mux-dash-number-template-manifest"); + fs::create_dir_all(&manifest_dir).unwrap(); + fs::copy( + &source_input, + manifest_dir.join("literal_$video_064000_001.mp4"), + ) + .unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let output_path = write_temp_file("mux-dash-number-template-output", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]), + &output_path, + ) + .unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free") + ] + ); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"dash-number-frame" + ); + + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 1_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 10, + }] + ); +} + +#[test] +fn mux_to_path_imports_multi_period_local_dash_segment_lists_with_stacked_base_urls() { + let first_input = build_video_input_file( + "mux-dash-multi-period-source-a", + fourcc("isom"), + &[b"dash-period-one"], + ); + let second_input = build_video_input_file( + "mux-dash-multi-period-source-b", + fourcc("isom"), + &[b"dash-period-two"], + ); + let manifest_dir = temp_output_dir("mux-dash-multi-period-manifest"); + fs::create_dir_all(manifest_dir.join("root/period-one")).unwrap(); + fs::create_dir_all(manifest_dir.join("root/period-two")).unwrap(); + fs::copy( + &first_input, + manifest_dir.join("root/period-one/segment.mp4"), + ) + .unwrap(); + fs::copy( + &second_input, + manifest_dir.join("root/period-two/segment.mp4"), + ) + .unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "\n", + "\n", + " root/\n", + " \n", + " period-one/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " period-two/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let output_path = write_temp_file("mux-dash-multi-period-output", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]), + &output_path, + ) + .unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"dash-period-onedash-period-two" + ); + + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 1_000); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 10, + }] + ); +} + +#[test] +fn mux_to_path_imports_single_period_local_dash_dtsx_with_preserved_brands_and_no_single_sample_btrt() + { + let source_input = build_dtsx_dash_segment_input_file("mux-dash-dtsx-single-period-source"); + let manifest_dir = temp_output_dir("mux-dash-dtsx-single-period-manifest"); + fs::create_dir_all(manifest_dir.join("audio")).unwrap(); + fs::copy(&source_input, manifest_dir.join("audio/segment.mp4")).unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "\n", + "\n", + " \n", + " \n", + " \n", + " audio/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let output_path = write_temp_file("mux-dash-dtsx-single-period-output", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]), + &output_path, + ) + .unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let ftyp_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("ftyp")])); + let sample_entry_boxes = extract_box_bytes( + &mut Cursor::new(&output_bytes), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dtsx"), + ]), + ) + .unwrap(); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let free_boxes = extract_box_bytes( + &mut Cursor::new(&output_bytes), + None, + BoxPath::from([fourcc("free")]), + ) + .unwrap(); + assert_eq!(ftyp_boxes.len(), 1); + assert_eq!(ftyp_boxes[0].major_brand, fourcc("isom")); + assert_eq!(ftyp_boxes[0].minor_version, 1); + assert_eq!( + ftyp_boxes[0].compatible_brands, + vec![fourcc("isom"), fourcc("iso8"), fourcc("dtsx")] + ); + assert_eq!(free_boxes.len(), 1); + assert!(free_boxes[0][8..].iter().all(|byte| *byte == 0)); + assert_eq!(sample_entry_boxes.len(), 1); + assert_eq!(sample_entry_boxes[0].len(), 52); + assert!( + sample_entry_boxes[0] + .windows(4) + .any(|bytes| bytes == b"udts") + ); + assert!( + !sample_entry_boxes[0] + .windows(4) + .any(|bytes| bytes == b"btrt") + ); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 1_024, + }] + ); +} + +#[test] +fn mux_to_path_imports_multi_period_local_dash_dtsx_with_preserved_stts_boundaries() { + let first_input = build_dtsx_dash_segment_input_file("mux-dash-dtsx-multi-period-source-a"); + let second_input = build_dtsx_dash_segment_input_file("mux-dash-dtsx-multi-period-source-b"); + let manifest_dir = temp_output_dir("mux-dash-dtsx-multi-period-manifest"); + fs::create_dir_all(manifest_dir.join("root/period-one")).unwrap(); + fs::create_dir_all(manifest_dir.join("root/period-two")).unwrap(); + fs::copy( + &first_input, + manifest_dir.join("root/period-one/segment.mp4"), + ) + .unwrap(); + fs::copy( + &second_input, + manifest_dir.join("root/period-two/segment.mp4"), + ) + .unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "\n", + "\n", + " root/\n", + " \n", + " period-one/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " period-two/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let output_path = write_temp_file("mux-dash-dtsx-multi-period-output", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]), + &output_path, + ) + .unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let ftyp_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("ftyp")])); + let sample_entry_boxes = extract_box_bytes( + &mut Cursor::new(&output_bytes), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dtsx"), + ]), + ) + .unwrap(); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let free_boxes = extract_box_bytes( + &mut Cursor::new(&output_bytes), + None, + BoxPath::from([fourcc("free")]), + ) + .unwrap(); + assert_eq!(ftyp_boxes.len(), 1); + assert_eq!(ftyp_boxes[0].major_brand, fourcc("isom")); + assert_eq!(ftyp_boxes[0].minor_version, 1); + assert_eq!( + ftyp_boxes[0].compatible_brands, + vec![fourcc("isom"), fourcc("iso8"), fourcc("dtsx")] + ); + assert_eq!(free_boxes.len(), 1); + assert!(free_boxes[0][8..].iter().all(|byte| *byte == 0)); + assert_eq!(sample_entry_boxes.len(), 1); + assert_eq!(sample_entry_boxes[0].len(), 72); + assert!( + sample_entry_boxes[0] + .windows(4) + .any(|bytes| bytes == b"udts") + ); + assert!( + sample_entry_boxes[0] + .windows(4) + .any(|bytes| bytes == b"btrt") + ); + assert_eq!( + stts_boxes[0].entries, + vec![ + SttsEntry { + sample_count: 1, + sample_delta: 1, + }, + SttsEntry { + sample_count: 1, + sample_delta: 1_023, + }, + ] + ); +} + +#[test] +fn mux_to_path_imports_period_root_dash_segment_lists_with_nested_base_urls() { + let source_input = build_video_input_file( + "mux-dash-period-root-source", + fourcc("isom"), + &[b"dash-period-root-frame"], + ); + let manifest_dir = temp_output_dir("mux-dash-period-root-manifest"); + fs::create_dir_all(manifest_dir.join("adaptation/video")).unwrap(); + fs::copy( + &source_input, + manifest_dir.join("adaptation/video/segment.mp4"), + ) + .unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "\n", + " \n", + " adaptation/\n", + " \n", + " video/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let output_path = write_temp_file("mux-dash-period-root-output", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]), + &output_path, + ) + .unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"dash-period-root-frame" + ); + + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 1_000); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 10, + }] + ); +} + +#[test] +fn mux_to_path_imports_compact_local_dash_segment_lists_with_inline_tags() { + let source_input = build_video_input_file( + "mux-dash-compact-source", + fourcc("isom"), + &[b"dash-compact-frame"], + ); + let manifest_dir = temp_output_dir("mux-dash-compact-manifest"); + fs::create_dir_all(manifest_dir.join("root/adaptation/video")).unwrap(); + fs::copy( + &source_input, + manifest_dir.join("root/adaptation/video/segment.mp4"), + ) + .unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "", + "root/adaptation/", + "video/", + "", + "" + ), + ) + .unwrap(); + + let output_path = write_temp_file("mux-dash-compact-output", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]), + &output_path, + ) + .unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"dash-compact-frame" + ); +} + +#[test] +fn mux_to_path_imports_local_dash_segment_lists_with_wrapped_base_url_text() { + let source_input = build_video_input_file( + "mux-dash-wrapped-base-url-source", + fourcc("isom"), + &[b"dash-wrapped-base-url-frame"], + ); + let manifest_dir = temp_output_dir("mux-dash-wrapped-base-url-manifest"); + fs::create_dir_all(manifest_dir.join("root/adaptation/video")).unwrap(); + fs::copy( + &source_input, + manifest_dir.join("root/adaptation/video/segment.mp4"), + ) + .unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "\n", + "\n", + " \n", + "root/\n", + " \n", + " \n", + " \n", + " \n", + "adaptation/\n", + " \n", + " \n", + " \n", + "video/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let output_path = write_temp_file("mux-dash-wrapped-base-url-output", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]), + &output_path, + ) + .unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"dash-wrapped-base-url-frame" + ); +} + +#[test] +fn mux_to_path_imports_path_only_saf_aac_inputs() { + let saf_input = write_test_saf_aac_file("mux-saf-aac-input", &[b"abc", b"defg"]); + let output_path = write_temp_file("mux-saf-aac-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&saf_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"abcdefg"); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + fourcc("esds"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("mp4a")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(audio_entries[0].sample_rate, 48_000 << 16); + assert_eq!(esds_boxes.len(), 1); + assert_eq!( + esds_boxes[0] + .decoder_config_descriptor() + .unwrap() + .object_type_indication, + 0x40 + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1_024); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("soun")); +} + +#[test] +fn mux_to_path_imports_path_only_saf_scene_inputs() { + let saf_input = + write_test_saf_scene_plus_mp4v_file("mux-saf-scene-input", &[b"scene-a", b"scene-b"], &[]); + let output_path = write_temp_file("mux-saf-scene-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&saf_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let scene_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4s"), + ]), + ); + let scene_esds_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4s"), + fourcc("esds"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let vmhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("vmhd"), + ]), + ); + let nmhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("nmhd"), + ]), + ); + + assert_eq!(scene_entries.len(), 1); + assert_eq!(scene_entries[0].sample_entry.box_type, fourcc("mp4s")); + assert_eq!(scene_esds_boxes.len(), 1); + assert_eq!( + scene_esds_boxes[0] + .decoder_config_descriptor() + .unwrap() + .object_type_indication, + 0x01 + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 1_000); + assert_eq!(vmhd_boxes.len(), 1); + assert!(nmhd_boxes.is_empty()); + assert!( + hdlr_boxes + .iter() + .any(|hdlr| { hdlr.handler_type == fourcc("sdsm") && hdlr.name == "SceneHandler" }) + ); +} + +#[test] +fn mux_to_path_imports_path_only_saf_mp4v_inputs() { + let saf_input = + write_test_saf_scene_plus_mp4v_file("mux-saf-mp4v-input", &[], &[b"video-a", b"video-b"]); + let output_path = write_temp_file("mux-saf-mp4v-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&saf_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].sample_entry.box_type, fourcc("mp4v")); + assert_eq!(video_entries[0].width, 320); + assert_eq!(video_entries[0].height, 180); + assert!( + hdlr_boxes + .iter() + .any(|hdlr| hdlr.handler_type == fourcc("vide")) + ); +} + +#[test] +fn mux_to_path_imports_path_only_wave_pcm_inputs() { + let pcm_input = write_test_wave_pcm_file( + "mux-raw-wave-pcm-input", + &[[-1_000, 1_000], [2_000, -2_000], [3_000, -3_000]], + ); + let output_path = write_temp_file("mux-raw-wave-pcm-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&pcm_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + let expected_payload = fs::read(&pcm_input).unwrap()[44..].to_vec(); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + ]), + ); + let pcm_configs = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + fourcc("pcmC"), + ]), + ); + let chnl_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + fourcc("chnl"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ipcm")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(pcm_configs.len(), 1); + assert_eq!(pcm_configs[0].format_flags, 1); + assert_eq!(pcm_configs[0].pcm_sample_size, 16); + assert_eq!(chnl_boxes.len(), 1); + assert_eq!( + chnl_boxes[0].data, + vec![0, 0, 0, 0, 1, 2, 0, 0, 0, 0, 0, 0, 0, 0] + ); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 3); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(stsz_boxes[0].sample_count, 3); + assert_eq!(stsz_boxes[0].sample_size, 4); +} + +#[test] +fn mux_to_path_imports_path_only_aiff_pcm_inputs() { + let frames = [[-1_000, 1_000], [2_000, -2_000], [3_000, -3_000]]; + let pcm_input = write_test_aiff_pcm_file("mux-raw-aiff-pcm-input", &frames); + let output_path = write_temp_file("mux-raw-aiff-pcm-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&pcm_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + let expected_payload = frames + .into_iter() + .flat_map(|frame| frame.into_iter().flat_map(i16::to_be_bytes)) + .collect::>(); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + ]), + ); + let pcm_configs = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + fourcc("pcmC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + let stsc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + let stco_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stco"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ipcm")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(pcm_configs.len(), 1); + assert_eq!(pcm_configs[0].format_flags, 0); + assert_eq!(pcm_configs[0].pcm_sample_size, 16); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(mdhd_boxes[0].duration(), 3); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 3); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(stsz_boxes[0].sample_count, 3); + assert_eq!(stsz_boxes[0].sample_size, 4); + assert_eq!(stsc_boxes.len(), 1); + assert_eq!(stsc_boxes[0].entries.len(), 1); + assert_eq!(stsc_boxes[0].entries[0].samples_per_chunk, 3); + assert_eq!(stco_boxes.len(), 1); + assert_eq!(stco_boxes[0].entry_count, 1); +} + +#[test] +fn mux_to_path_imports_path_only_aifc_pcm_inputs() { + let frames = [[-1_000, 1_000], [2_000, -2_000]]; + let pcm_input = write_test_aifc_pcm_file("mux-raw-aifc-pcm-input", &frames); + let output_path = write_temp_file("mux-raw-aifc-pcm-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&pcm_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + let expected_payload = frames + .into_iter() + .flat_map(|frame| frame.into_iter().flat_map(i16::to_be_bytes)) + .collect::>(); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + let stsc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + let stco_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stco"), + ]), + ); + let pcm_configs = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + fourcc("pcmC"), + ]), + ); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(mdhd_boxes[0].duration(), 0); + assert_eq!(pcm_configs.len(), 1); + assert_eq!(pcm_configs[0].format_flags, 0); + assert_eq!(pcm_configs[0].pcm_sample_size, 16); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 0); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(stsz_boxes[0].sample_count, 2); + assert_eq!(stsz_boxes[0].sample_size, 4); + assert_eq!(stsc_boxes.len(), 1); + assert!( + stsc_boxes[0] + .entries + .iter() + .all(|entry| entry.samples_per_chunk == 1) + ); + assert_eq!(stco_boxes.len(), 1); + assert_eq!(stco_boxes[0].entry_count, 2); +} + +#[test] +fn mux_to_path_imports_path_only_aifc_float64_inputs() { + let frames = [&[0.5_f64, -0.5_f64][..], &[1.25_f64, -1.25_f64][..]]; + let input = write_test_aifc_float64_file("mux-raw-aifc-float64-input", 48_000, 2, &frames); + let output_path = write_temp_file("mux-raw-aifc-float64-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + let expected_payload = frames + .iter() + .flat_map(|frame| frame.iter().flat_map(|sample| sample.to_be_bytes())) + .collect::>(); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fpcm"), + ]), + ); + let pcm_configs = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fpcm"), + fourcc("pcmC"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("fpcm")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(audio_entries[0].sample_size, 64); + let sample_entry_boxes = extract_box_bytes( + &mut Cursor::new(&output_bytes), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("fpcm"), + ]), + ) + .unwrap(); + assert_eq!(sample_entry_boxes.len(), 1); + assert_eq!(&sample_entry_boxes[0][18..22], &[0, 0, 0, 0]); + assert_eq!(pcm_configs.len(), 1); + assert_eq!(pcm_configs[0].format_flags, 1); + assert_eq!(pcm_configs[0].pcm_sample_size, 64); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1); +} + +#[test] +fn mux_to_path_imports_path_only_aifc_alaw_inputs() { + let packets = [&[0xD5_u8, 0x55, 0x26, 0xA6][..]]; + let input = write_test_aifc_alaw_file("mux-raw-aifc-alaw-input", 8_000, 1, &packets); + let output_path = write_temp_file("mux-raw-aifc-alaw-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + let expected_payload = decode_companded_pcm_payload(packets[0], decode_alaw_pcm_sample); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + ]), + ); + let pcm_configs = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + fourcc("pcmC"), + ]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ipcm")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_size, 16); + assert_eq!(pcm_configs.len(), 1); + assert_eq!(pcm_configs[0].format_flags, 1); + assert_eq!(pcm_configs[0].pcm_sample_size, 16); + assert_eq!(stsz_boxes[0].sample_count, 4); + assert_eq!(stsz_boxes[0].sample_size, 2); +} + +#[test] +fn mux_to_path_imports_path_only_aifc_alaw_inputs_with_packed_16_bit_declaration() { + let packets = [&[0xD5_u8, 0x55, 0x26, 0xA6][..]]; + let input = write_test_aifc_alaw_file_with_declared_bits( + "mux-raw-aifc-alaw-16-input", + 8_000, + 1, + 16, + &packets, + ); + let output_path = write_temp_file("mux-raw-aifc-alaw-16-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), packets[0]); + + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stsz_boxes[0].sample_count, 2); + assert_eq!(stsz_boxes[0].sample_size, 2); +} + +#[test] +fn mux_to_path_imports_path_only_aifc_ulaw_inputs() { + let packets = [&[0xFF_u8, 0x7F, 0xDB, 0x5B][..]]; + let input = write_test_aifc_ulaw_file("mux-raw-aifc-ulaw-input", 8_000, 1, &packets); + let output_path = write_temp_file("mux-raw-aifc-ulaw-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + let expected_payload = decode_companded_pcm_payload(packets[0], decode_ulaw_pcm_sample); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), expected_payload); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + ]), + ); + let pcm_configs = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ipcm"), + fourcc("pcmC"), + ]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("ipcm")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(audio_entries[0].sample_size, 16); + assert_eq!(pcm_configs.len(), 1); + assert_eq!(pcm_configs[0].format_flags, 1); + assert_eq!(pcm_configs[0].pcm_sample_size, 16); + assert_eq!(stsz_boxes[0].sample_count, 4); + assert_eq!(stsz_boxes[0].sample_size, 2); +} + +#[test] +fn mux_to_path_imports_path_only_aifc_ulaw_inputs_with_packed_16_bit_declaration() { + let packets = [&[0xFF_u8, 0x7F, 0xDB, 0x5B][..]]; + let input = write_test_aifc_ulaw_file_with_declared_bits( + "mux-raw-aifc-ulaw-16-input", + 8_000, + 1, + 16, + &packets, + ); + let output_path = write_temp_file("mux-raw-aifc-ulaw-16-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), packets[0]); + + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stsz_boxes[0].sample_count, 2); + assert_eq!(stsz_boxes[0].sample_size, 2); +} + +#[test] +fn mux_to_path_imports_path_only_ogg_vorbis_inputs() { + let vorbis_input = write_test_ogg_vorbis_file("mux-raw-vorbis-input", &[b"abc", b"def"]); + let output_path = write_temp_file("mux-raw-vorbis-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&vorbis_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"\x02abc\x02def" + ); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4a"), + fourcc("esds"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("mp4a")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(esds_boxes.len(), 1); + assert!(esds_boxes[0].es_descriptor().is_some()); + assert_eq!( + esds_boxes[0] + .decoder_config_descriptor() + .unwrap() + .object_type_indication, + 0xDD + ); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 64); +} + +#[test] +fn mux_to_path_imports_path_only_ogg_speex_inputs() { + let speex_input = write_test_ogg_speex_file("mux-raw-speex-input", &[b"abc", b"def"]); + let output_path = write_temp_file("mux-raw-speex-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&speex_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"abcdef"); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("spex"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("spex"), + fourcc("btrt"), + ]), + ); + let sample_entry_boxes = extract_box_bytes( + &mut Cursor::new(&output_bytes), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("spex"), + ]), + ) + .unwrap(); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("spex")); + assert_eq!(audio_entries[0].channel_count, 0); + assert_eq!(sample_entry_boxes.len(), 1); + assert_eq!(&sample_entry_boxes[0][20..24], b"mp4f"); + assert_eq!(btrt_boxes.len(), 1); + assert!(btrt_boxes[0].buffer_size_db > 0); + assert!(btrt_boxes[0].max_bitrate > 0); + assert!(btrt_boxes[0].avg_bitrate > 0); + assert_eq!(mdhd_boxes[0].timescale, 16_000); + assert_eq!(stts_boxes[0].entries.len(), 2); + assert_eq!(stts_boxes[0].entries[0].sample_count, 1); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1); + assert_eq!(stts_boxes[0].entries[1].sample_count, 1); + assert_eq!(stts_boxes[0].entries[1].sample_delta, 0); +} + +#[test] +fn mux_to_path_rejects_ogg_pages_with_bad_crc() { + let speex_input = write_test_ogg_speex_file("mux-raw-speex-bad-crc-input", &[b"abc", b"def"]); + let mut input_bytes = fs::read(&speex_input).unwrap(); + let first_payload_offset = 27 + usize::from(input_bytes[26]); + input_bytes[first_payload_offset] ^= 0x01; + fs::write(&speex_input, input_bytes).unwrap(); + let output_path = write_temp_file("mux-raw-speex-bad-crc-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&speex_input)]); + + let error = mux_to_path(&request, &output_path).unwrap_err(); + match error { + MuxError::UnsupportedTrackImport { message, .. } => { + assert!(message.contains("failed CRC validation")); + } + other => panic!("expected unsupported-track error, got {other:?}"), + } +} + +#[test] +fn mux_to_path_imports_path_only_ogg_theora_inputs() { + let theora_input = + write_test_ogg_theora_file("mux-raw-theora-input", &[b"frame-a", b"frame-b"]); + let output_path = write_temp_file("mux-raw-theora-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&theora_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + b"\x00frame-a\x00frame-b" + ); + + let visual_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + ]), + ); + let esds_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + fourcc("esds"), + ]), + ); + let pasp_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("mp4v"), + fourcc("pasp"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(visual_entries.len(), 1); + assert_eq!(visual_entries[0].sample_entry.box_type, fourcc("mp4v")); + assert_eq!(visual_entries[0].width, 320); + assert_eq!(visual_entries[0].height, 240); + assert_eq!(esds_boxes.len(), 1); + assert!(esds_boxes[0].es_descriptor().is_some()); + assert_eq!( + esds_boxes[0] + .decoder_config_descriptor() + .unwrap() + .object_type_indication, + 0xDF + ); + assert_eq!(pasp_boxes.len(), 1); + assert_eq!(pasp_boxes[0].h_spacing, 4); + assert_eq!(pasp_boxes[0].v_spacing, 3); + assert_eq!(mdhd_boxes[0].timescale, 30_000); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1_001); +} + +#[test] +fn mux_to_path_imports_path_only_jpeg_inputs() { + let jpeg_input = write_test_jpeg_file("mux-raw-jpeg-input"); + let output_path = write_temp_file("mux-raw-jpeg-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&jpeg_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let input_bytes = fs::read(&jpeg_input).unwrap(); + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), input_bytes); + let ftyp_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("ftyp")])); + + let visual_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("jpeg"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(ftyp_boxes.len(), 1); + assert_eq!(ftyp_boxes[0].major_brand, fourcc("isom")); + assert_eq!(ftyp_boxes[0].compatible_brands, vec![fourcc("isom")]); + assert_eq!(visual_entries.len(), 1); + assert_eq!(visual_entries[0].sample_entry.box_type, fourcc("jpeg")); + assert_eq!(visual_entries[0].width, 1); + assert_eq!(visual_entries[0].height, 1); + assert_eq!(visual_entries[0].horizresolution, 72); + assert_eq!(visual_entries[0].vertresolution, 72); + assert_eq!(mdhd_boxes[0].timescale, 1_000); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 1); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1_000); +} + +#[test] +fn mux_to_path_imports_path_only_h263_inputs() { + let h263_input = write_test_h263_file("mux-raw-h263-input", &[b"frame-a", b"frame-b"]); + let output_path = write_temp_file("mux-raw-h263-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&h263_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let input_bytes = fs::read(&h263_input).unwrap(); + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), input_bytes); + + let visual_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("s263"), + ]), + ); + let d263_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("s263"), + fourcc("d263"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let ftyp_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("ftyp")])); + assert_eq!(ftyp_boxes.len(), 1); + assert_eq!(ftyp_boxes[0].major_brand, fourcc("isom")); + assert_eq!( + ftyp_boxes[0].compatible_brands, + vec![fourcc("isom"), fourcc("3gg6"), fourcc("3gg5")] + ); + assert_eq!(visual_entries.len(), 1); + assert_eq!(visual_entries[0].sample_entry.box_type, fourcc("s263")); + assert_eq!(visual_entries[0].width, 176); + assert_eq!(visual_entries[0].height, 144); + assert_eq!(visual_entries[0].compressorname[0], 0); + assert_eq!(d263_boxes.len(), 1); + assert_eq!(d263_boxes[0].vendor, 0); + assert_eq!(d263_boxes[0].decoder_version, 0); + assert_eq!(d263_boxes[0].h263_level, 10); + assert_eq!(d263_boxes[0].h263_profile, 0); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 1_200_000); + assert_eq!( + stts_boxes[0].entries, + vec![ + SttsEntry { + sample_count: 1, + sample_delta: 48_000, + }, + SttsEntry { + sample_count: 1, + sample_delta: 40_040, + }, + ] + ); +} + +#[test] +fn mux_to_path_imports_path_only_png_inputs() { + let png_input = write_test_png_file("mux-raw-png-input"); + let output_path = write_temp_file("mux-raw-png-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&png_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let input_bytes = fs::read(&png_input).unwrap(); + let output_bytes = fs::read(&output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), input_bytes); + + let visual_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("png "), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(visual_entries.len(), 1); + assert_eq!(visual_entries[0].sample_entry.box_type, fourcc("png ")); + assert_eq!(visual_entries[0].width, 1); + assert_eq!(visual_entries[0].height, 1); + assert_eq!(visual_entries[0].horizresolution, 72); + assert_eq!(visual_entries[0].vertresolution, 72); + assert_eq!(mdhd_boxes[0].timescale, 1_000); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 1); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1_000); +} + +#[test] +fn mux_to_path_imports_path_only_iamf_inputs() { + let iamf_input = write_test_iamf_file("mux-raw-iamf-input", &[b"frame-one", b"frame-two"]); + let output_path = write_temp_file("mux-raw-iamf-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&iamf_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("iamf"), + ]), + ); + let iacb_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("iamf"), + fourcc("iacb"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("iamf")); + assert_eq!(audio_entries[0].channel_count, 0); + assert_eq!(audio_entries[0].sample_size, 0); + assert_eq!(audio_entries[0].sample_rate, 0); + assert_eq!(iacb_boxes.len(), 1); + assert_eq!(iacb_boxes[0].configuration_version, 1); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(mdhd_boxes[0].duration(), 4_294_967_296); + assert_eq!(stts_boxes[0].entries.len(), 2); + assert_eq!(stts_boxes[0].entries[0].sample_count, 1); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1); + assert_eq!(stts_boxes[0].entries[1].sample_count, 1); + assert_eq!(stts_boxes[0].entries[1].sample_delta, u32::MAX); +} + +#[test] +fn mux_to_path_imports_path_only_caf_alac_inputs() { + let alac_input = write_test_caf_alac_file("mux-raw-alac-input", &[b"ABCD", b"EFGH"]); + let output_path = write_temp_file("mux-raw-alac-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&alac_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), b"ABCDEFGH"); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("alac"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("alac"), + fourcc("btrt"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("alac")); + assert_eq!(audio_entries[0].channel_count, 2); + assert_eq!(btrt_boxes.len(), 1); + assert!(btrt_boxes[0].buffer_size_db > 0); + assert!(btrt_boxes[0].max_bitrate > 0); + assert!(btrt_boxes[0].avg_bitrate > 0); + assert_eq!(mdhd_boxes[0].timescale, 48_000); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1_024); +} + +#[test] +fn mux_to_path_imports_path_only_variable_packet_caf_alac_inputs() { + let packet_a = vec![b'A'; 1_977]; + let packet_b = vec![b'B'; 254]; + let alac_input = write_test_caf_alac_variable_packet_file( + "mux-raw-alac-variable-input", + &[packet_a.as_slice(), packet_b.as_slice()], + ); + let output_path = write_temp_file("mux-raw-alac-variable-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&alac_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + let payload = mdat_payload(&output_bytes, root_boxes[2]); + assert_eq!(payload.len(), packet_a.len() + packet_b.len()); + assert_eq!(&payload[..packet_a.len()], packet_a.as_slice()); + assert_eq!(&payload[packet_a.len()..], packet_b.as_slice()); + + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("alac"), + ]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc("alac")); + assert_eq!(audio_entries[0].channel_count, 1); + assert_eq!(mdhd_boxes[0].timescale, 44_100); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 4_096); +} + +#[test] +fn mux_to_path_imports_path_only_raw_h265_annexb_inputs() { + let h265_input = write_test_h265_annexb_file("mux-raw-h265-input", &[b"hevc"]); + let output_path = write_temp_file("mux-raw-h265-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&h265_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![ + fourcc("ftyp"), + fourcc("moov"), + fourcc("mdat"), + fourcc("free"), + ] + ); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + &[0, 0, 0, 6, 0x26, 0x01, b'h', b'e', b'v', b'c'] + ); + + let hvc1 = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("hvc1"), + ]), + ); + assert_eq!(hvc1.len(), 1); + assert_eq!(hvc1[0].sample_entry.box_type, fourcc("hvc1")); + assert_eq!(hvc1[0].width, 1920); + assert_eq!(hvc1[0].height, 1080); + + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].name, "VideoHandler"); + + let pasp_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("hvc1"), + fourcc("pasp"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("hvc1"), + fourcc("btrt"), + ]), + ); + assert_eq!(pasp_boxes.len(), 1); + assert_eq!(pasp_boxes[0].h_spacing, 1); + assert_eq!(pasp_boxes[0].v_spacing, 1); + assert_eq!(btrt_boxes.len(), 1); +} + +#[test] +fn mux_to_path_imports_multisample_h265_inputs_with_stream_timing() { + let h265_input = write_test_h265_annexb_file_with_timing( + "mux-raw-h265-timed-input", + &[b"\x80hevc", b"\x80tail"], + ); + let output_path = write_temp_file("mux-raw-h265-timed-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&h265_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let stsc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stsc_boxes.len(), 1); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 24); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 2); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1); + assert_eq!(stsc_boxes[0].entries.len(), 1); + assert_eq!(stsc_boxes[0].entries[0].first_chunk, 1); + assert_eq!(stsc_boxes[0].entries[0].samples_per_chunk, 2); + assert_eq!(stsz_boxes[0].sample_count, 2); + assert!(stsz_boxes[0].sample_size > 0); + assert!(stsz_boxes[0].entry_size.is_empty()); + + let pasp_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("hvc1"), + fourcc("pasp"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("hvc1"), + fourcc("btrt"), + ]), + ); + let tkhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("tkhd")]), + ); + assert_eq!(pasp_boxes.len(), 1); + assert_eq!(pasp_boxes[0].h_spacing, 855); + assert_eq!(pasp_boxes[0].v_spacing, 857); + assert_eq!(btrt_boxes.len(), 1); + assert!(btrt_boxes[0].buffer_size_db > 0); + assert!(btrt_boxes[0].max_bitrate > 0); + assert!(btrt_boxes[0].avg_bitrate > 0); + assert_eq!(tkhd_boxes.len(), 1); + assert_eq!(tkhd_boxes[0].width >> 16, 1277); + assert_eq!(tkhd_boxes[0].height >> 16, 570); +} + +#[test] +fn mux_to_path_flat_auto_profile_collapses_mixed_direct_video_tracks_into_one_chunk() { + let h265_input = write_test_h265_annexb_file_with_timing( + "mux-flat-mixed-h265-input", + &[b"\x80hevc", b"\x80tail"], + ); + let aac_input = write_test_adts_file("mux-flat-mixed-aac-input", &[b"abc", b"defg"]); + let output_path = write_temp_file("mux-flat-mixed-h265-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&h265_input), + MuxTrackSpec::path(&aac_input), + ]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let stsc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + let stco_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stco"), + ]), + ); + let hdlr_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + assert_eq!(stsc_boxes.len(), 2); + assert_eq!(stco_boxes.len(), 2); + let video_index = hdlr_boxes + .iter() + .position(|hdlr| hdlr.handler_type == fourcc("vide")) + .unwrap(); + assert_eq!( + stsc_boxes[video_index].entries, + vec![StscEntry { + first_chunk: 1, + samples_per_chunk: 2, + sample_description_index: 1, + }] + ); + assert_eq!(stco_boxes[video_index].entry_count, 1); +} + +#[test] +fn mux_to_path_imports_real_h265_bframes_with_edit_list_and_ctts() { + let h265_input = fixture_path("mux/raw_h265_bframes.h265"); + let output_path = write_temp_file("mux-raw-h265-bframes-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&h265_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let tkhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("tkhd")]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let ctts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("ctts"), + ]), + ); + let edts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("edts")]), + ); + let elst_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("edts"), + fourcc("elst"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("hvc1"), + fourcc("btrt"), + ]), + ); + + assert_eq!(tkhd_boxes.len(), 1); + assert_eq!(tkhd_boxes[0].width >> 16, 1277); + assert_eq!(tkhd_boxes[0].height >> 16, 570); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 24); + assert_eq!(mdhd_boxes[0].duration(), 8); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 6); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 1); + assert_eq!(ctts_boxes.len(), 1); + assert_eq!(ctts_boxes[0].entry_count, 5); + assert_eq!(ctts_boxes[0].entries[0].sample_count, 1); + assert_eq!(ctts_boxes[0].sample_offset(0), 2); + assert_eq!(ctts_boxes[0].entries[1].sample_count, 1); + assert_eq!(ctts_boxes[0].sample_offset(1), 6); + assert_eq!(ctts_boxes[0].entries[2].sample_count, 1); + assert_eq!(ctts_boxes[0].sample_offset(2), 3); + assert_eq!(ctts_boxes[0].entries[3].sample_count, 2); + assert_eq!(ctts_boxes[0].sample_offset(3), 0); + assert_eq!(ctts_boxes[0].entries[4].sample_count, 1); + assert_eq!(ctts_boxes[0].sample_offset(4), 1); + assert_eq!(edts_boxes.len(), 1); + assert_eq!(elst_boxes.len(), 1); + assert_eq!(elst_boxes[0].entry_count, 1); + assert_eq!(elst_boxes[0].segment_duration(0), 150); + assert_eq!(elst_boxes[0].media_time(0), 2); + assert_eq!(btrt_boxes.len(), 1); + assert_eq!(btrt_boxes[0].buffer_size_db, 10_985); + assert_eq!(btrt_boxes[0].max_bitrate, 271_536); + assert_eq!(btrt_boxes[0].avg_bitrate, 271_536); +} + +#[test] +fn mux_to_path_imports_real_single_sample_vvc_annex_b_input() { + let vvc_input = fixture_path("mux/raw_vvc_idr.vvc"); + let output_path = write_temp_file("mux-raw-vvc-idr-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&vvc_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let tkhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("tkhd")]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let vvc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("vvc1"), + fourcc("vvcC"), + ]), + ); + let video_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("vvc1"), + ]), + ); + let ctts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("ctts"), + ]), + ); + let elst_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("edts"), + fourcc("elst"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + + assert_eq!(tkhd_boxes.len(), 1); + assert_eq!(tkhd_boxes[0].width >> 16, 1280); + assert_eq!(tkhd_boxes[0].height >> 16, 720); + assert_eq!(video_entries.len(), 1); + assert_eq!(video_entries[0].width, 1280); + assert_eq!(video_entries[0].height, 720); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 25); + assert_eq!(mdhd_boxes[0].duration(), 2); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 1 + }] + ); + assert_eq!(ctts_boxes.len(), 1); + assert_eq!(ctts_boxes[0].entry_count, 1); + assert_eq!(ctts_boxes[0].sample_offset(0), 1); + assert_eq!(elst_boxes.len(), 1); + assert_eq!(elst_boxes[0].entry_count, 1); + assert_eq!(elst_boxes[0].segment_duration(0), 24); + assert_eq!(elst_boxes[0].media_time(0), 1); + assert_eq!(vvc_boxes.len(), 1); + assert_eq!(vvc_boxes[0].version, 0); + assert!(!vvc_boxes[0].decoder_configuration_record.is_empty()); + assert_eq!( + &vvc_boxes[0].decoder_configuration_record[..4], + &[0xFF, 0x00, 0x65, 0x5F] + ); +} + +#[test] +fn mux_to_path_imports_multi_sample_vvc_annex_b_input() { + let vvc_input = write_multi_sample_vvc_annex_b_input("mux-raw-vvc-multi-sample-input"); + let output_path = write_temp_file("mux-raw-vvc-multi-sample-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&vvc_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + let ctts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("ctts"), + ]), + ); + + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 25); + assert_eq!(mdhd_boxes[0].duration(), 3); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 1 + }] + ); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(stsz_boxes[0].sample_count, 2); + assert_eq!(ctts_boxes.len(), 1); + assert_eq!(ctts_boxes[0].entry_count, 1); + assert_eq!(ctts_boxes[0].entries[0].sample_count, 2); + assert_eq!(ctts_boxes[0].sample_offset(0), 1); +} + +#[test] +fn mux_to_path_imports_multi_sample_vvc_annex_b_input_to_fragmented_output() { + let vvc_input = write_multi_sample_vvc_annex_b_input("mux-fragmented-vvc-multi-sample-input"); + let output_path = write_temp_file("mux-fragmented-vvc-multi-sample-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&vvc_input)]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 1.0 }); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let trun_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("trun")]), + ); + let tfdt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfdt")]), + ); + + assert_eq!(trun_boxes.len(), 1); + assert_eq!(trun_boxes[0].sample_count, 2); + assert_eq!(tfdt_boxes.len(), 1); + assert_eq!(tfdt_boxes[0].base_media_decode_time_v0, 0); +} + +#[test] +fn mux_to_path_imports_path_first_ivf_video_inputs() { + for (sample_entry_type, prefix, frame_payloads, writer) in [ + ( + "av01", + "mux-raw-av1", + vec![ + build_test_av1_sequence_header_obu(640, 360), + build_test_av1_sequence_header_obu(640, 360), + ], + write_test_av1_ivf_file as fn(&str, u16, u16, &[u64], &[&[u8]]) -> TestTempPath, + ), + ( + "vp08", + "mux-raw-vp8", + vec![ + build_test_vp8_keyframe(640, 360, 1, b"vp8-a"), + build_test_vp8_keyframe(640, 360, 1, b"vp8-b"), + ], + write_test_vp8_ivf_file as fn(&str, u16, u16, &[u64], &[&[u8]]) -> TestTempPath, + ), + ( + "vp09", + "mux-raw-vp9", + vec![ + build_test_vp9_keyframe(640, 360, 0), + build_test_vp9_keyframe(640, 360, 0), + ], + write_test_vp9_ivf_file as fn(&str, u16, u16, &[u64], &[&[u8]]) -> TestTempPath, + ), + ( + "vp10", + "mux-raw-vp10", + vec![ + build_test_vp10_keyframe(640, 360, 0), + build_test_vp10_keyframe(640, 360, 0), + ], + write_test_vp10_ivf_file as fn(&str, u16, u16, &[u64], &[&[u8]]) -> TestTempPath, + ), + ] { + let frame_refs = frame_payloads.iter().map(Vec::as_slice).collect::>(); + let input = writer(prefix, 640, 360, &[0, 1], &frame_refs); + let output_path = write_temp_file(&format!("{prefix}-output"), &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + frame_payloads.concat(), + "{sample_entry_type}" + ); + + let entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(sample_entry_type), + ]), + ); + assert_eq!(entries.len(), 1, "{sample_entry_type}"); + assert_eq!(entries[0].sample_entry.box_type, fourcc(sample_entry_type)); + assert_eq!(entries[0].width, 640, "{sample_entry_type}"); + assert_eq!(entries[0].height, 360, "{sample_entry_type}"); + if matches!(sample_entry_type, "vp08" | "vp09" | "vp10") { + let visible_len = usize::from(entries[0].compressorname[0]).min(31); + assert_eq!( + &entries[0].compressorname[1..1 + visible_len], + b"VPC Coding", + "{sample_entry_type}" + ); + } + + let sample_sizes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + assert_eq!(sample_sizes.len(), 1, "{sample_entry_type}"); + assert_eq!(sample_sizes[0].sample_count, 2, "{sample_entry_type}"); + if frame_payloads[0].len() == frame_payloads[1].len() { + assert_eq!( + sample_sizes[0].sample_size, + u32::try_from(frame_payloads[0].len()).unwrap(), + "{sample_entry_type}" + ); + assert!(sample_sizes[0].entry_size.is_empty(), "{sample_entry_type}"); + } else { + assert_eq!(sample_sizes[0].sample_size, 0, "{sample_entry_type}"); + assert_eq!( + sample_sizes[0].entry_size, + frame_payloads + .iter() + .map(|payload| u64::try_from(payload.len()).unwrap()) + .collect::>(), + "{sample_entry_type}" + ); + } + + let sample_times = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(sample_times.len(), 1, "{sample_entry_type}"); + assert_eq!( + sample_times[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 1, + }], + "{sample_entry_type}" + ); + + match sample_entry_type { + "av01" => { + let av1c = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("av01"), + fourcc("av1C"), + ]), + ); + assert_eq!(av1c.len(), 1); + assert!(!av1c[0].config_obus.is_empty()); + + let colr = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("av01"), + fourcc("colr"), + ]), + ); + assert_eq!(colr.len(), 1); + assert_eq!(colr[0].colour_type, fourcc("nclx")); + assert_eq!(colr[0].colour_primaries, 2); + assert_eq!(colr[0].transfer_characteristics, 2); + assert_eq!(colr[0].matrix_coefficients, 2); + assert!(!colr[0].full_range_flag); + } + "vp08" | "vp09" | "vp10" => { + let vpcc = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(sample_entry_type), + fourcc("vpcC"), + ]), + ); + assert_eq!(vpcc.len(), 1); + assert_eq!(vpcc[0].version(), 1); + if sample_entry_type == "vp08" { + assert_eq!(vpcc[0].profile, 1); + assert_eq!(vpcc[0].level, 10); + } else if sample_entry_type == "vp09" { + assert_eq!(vpcc[0].profile, 0); + assert_eq!(vpcc[0].level, 0); + assert_eq!(vpcc[0].colour_primaries, 5); + assert_eq!(vpcc[0].transfer_characteristics, 5); + assert_eq!(vpcc[0].matrix_coefficients, 6); + } else { + assert_eq!(vpcc[0].profile, 1); + assert_eq!(vpcc[0].level, 10); + assert_eq!(vpcc[0].bit_depth, 8); + assert_eq!(vpcc[0].colour_primaries, 0); + assert_eq!(vpcc[0].transfer_characteristics, 0); + assert_eq!(vpcc[0].matrix_coefficients, 0); + } + + let stss = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + assert!(stss.is_empty()); + } + _ => unreachable!(), + } + } +} + +#[test] +fn mux_to_path_imports_single_sample_ivf_video_inputs_with_zero_duration() { + for (sample_entry_type, prefix, frame_payloads, writer) in [ + ( + "av01", + "mux-raw-single-av1", + vec![build_test_av1_sequence_header_obu(640, 360)], + write_test_av1_ivf_file as fn(&str, u16, u16, &[u64], &[&[u8]]) -> TestTempPath, + ), + ( + "vp08", + "mux-raw-single-vp8", + vec![build_test_vp8_keyframe(640, 360, 1, b"vp8-a")], + write_test_vp8_ivf_file as fn(&str, u16, u16, &[u64], &[&[u8]]) -> TestTempPath, + ), + ( + "vp09", + "mux-raw-single-vp9", + vec![build_test_vp9_keyframe(640, 360, 0)], + write_test_vp9_ivf_file as fn(&str, u16, u16, &[u64], &[&[u8]]) -> TestTempPath, + ), + ( + "vp10", + "mux-raw-single-vp10", + vec![build_test_vp10_keyframe(640, 360, 0)], + write_test_vp10_ivf_file as fn(&str, u16, u16, &[u64], &[&[u8]]) -> TestTempPath, + ), + ] { + let frame_refs = frame_payloads.iter().map(Vec::as_slice).collect::>(); + let input = writer(prefix, 640, 360, &[0], &frame_refs); + let output_path = write_temp_file(&format!("{prefix}-output"), &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let media_headers = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let sample_times = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(media_headers.len(), 1, "{sample_entry_type}"); + assert_eq!(media_headers[0].duration(), 0, "{sample_entry_type}"); + assert_eq!(sample_times.len(), 1, "{sample_entry_type}"); + assert_eq!( + sample_times[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 0, + }], + "{sample_entry_type}" + ); + } +} + +#[test] +fn mux_to_path_strips_leading_temporal_delimiter_obus_from_direct_av1_samples() { + let mut frame = vec![0x12, 0x00]; + frame.extend_from_slice(&build_test_av1_sequence_header_obu(320, 240)); + let input = write_test_av1_ivf_file("mux-av1-temporal-delimiter", 320, 240, &[0], &[&frame]); + let output_path = write_temp_file("mux-av1-temporal-delimiter-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + build_test_av1_sequence_header_obu(320, 240) + ); +} + +#[test] +fn mux_to_path_imports_path_first_raw_av1_obu_inputs() { + let frame_a = build_test_av1_sequence_header_obu(640, 360); + let frame_b = build_test_av1_sequence_header_obu(640, 360); + let input = write_test_av1_obu_file("mux-raw-av1-obu-input", &[&frame_a, &frame_b]); + let output_path = write_temp_file("mux-raw-av1-obu-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + [frame_a.clone(), frame_b.clone()].concat() + ); + + let mdhd = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let av1c = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("av01"), + fourcc("av1C"), + ]), + ); + let pasp = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("av01"), + fourcc("pasp"), + ]), + ); + let btrt = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("av01"), + fourcc("btrt"), + ]), + ); + assert_eq!(mdhd.len(), 1); + assert_eq!(mdhd[0].timescale, 1_200_000); + assert_eq!(stts.len(), 1); + assert_eq!( + stts[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 48_000, + }] + ); + assert_eq!(av1c.len(), 1); + assert_eq!(pasp.len(), 1); + assert_eq!(pasp[0].h_spacing, 1); + assert_eq!(pasp[0].v_spacing, 1); + assert_eq!(btrt.len(), 1); + assert!(btrt[0].buffer_size_db > 0); + assert!(btrt[0].max_bitrate > 0); + assert!(btrt[0].avg_bitrate > 0); + assert!(!av1c[0].config_obus.is_empty()); +} + +#[test] +fn mux_to_path_imports_path_first_raw_av1_annexb_inputs() { + let frame_a = build_test_av1_sequence_header_obu(640, 360); + let frame_b = build_test_av1_sequence_header_obu(640, 360); + let input = write_test_av1_annex_b_file("mux-raw-av1-annexb-input", &[&frame_a, &frame_b]); + let output_path = write_temp_file("mux-raw-av1-annexb-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + [frame_a.clone(), frame_b.clone()].concat() + ); + + let mdhd = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let pasp = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("av01"), + fourcc("pasp"), + ]), + ); + let btrt = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("av01"), + fourcc("btrt"), + ]), + ); + assert_eq!(mdhd.len(), 1); + assert_eq!(mdhd[0].timescale, 25_000); + assert_eq!(stts.len(), 1); + assert_eq!( + stts[0].entries, + vec![SttsEntry { + sample_count: 2, + sample_delta: 1_000, + }] + ); + assert!(pasp.is_empty()); + assert_eq!(btrt.len(), 1); + assert!(btrt[0].buffer_size_db > 0); + assert!(btrt[0].max_bitrate > 0); + assert!(btrt[0].avg_bitrate > 0); +} + +#[test] +fn mux_to_path_imports_raw_mp3_inputs() { + let mp3_input = write_test_mp3_file("mux-raw-mp3-input", &[b"abc", b"defg"]); + let expected = fs::read(&mp3_input).unwrap(); + let output_path = write_temp_file("mux-raw-mp3-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&mp3_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + expected.as_slice() + ); + let audio_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(".mp3"), + ]), + ); + let btrt_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc(".mp3"), + fourcc("btrt"), + ]), + ); + assert_eq!(audio_entries.len(), 1); + assert_eq!(audio_entries[0].sample_entry.box_type, fourcc(".mp3")); + assert_eq!(btrt_boxes.len(), 1); + assert_eq!(btrt_boxes[0].buffer_size_db, 384); + assert_eq!(btrt_boxes[0].max_bitrate, 128_000); + assert_eq!(btrt_boxes[0].avg_bitrate, 128_000); +} + +#[test] +fn mux_to_path_imports_id3_prefixed_raw_mp3_inputs() { + let mp3_input = write_test_mp3_file_with_leading_id3_tag( + "mux-raw-mp3-id3-input", + b"test-id3", + &[b"abc", b"defg"], + ); + let expected = fs::read(&mp3_input).unwrap(); + let output_path = write_temp_file("mux-raw-mp3-id3-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&mp3_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!(mdat_payload(&output_bytes, root_boxes[2]), &expected[18..]); +} + +#[test] +fn mux_to_path_ignores_trailing_id3v1_metadata_after_raw_mp3_frames() { + let frame_file = write_test_mp3_file("mux-raw-mp3-id3v1-frames", &[b"abc", b"defg"]); + let expected = fs::read(&frame_file).unwrap(); + let mut bytes = expected.clone(); + let mut tag = [0_u8; 128]; + tag[..3].copy_from_slice(b"TAG"); + tag[3..22].copy_from_slice(b"sample for id3 test"); + bytes.extend_from_slice(&tag); + let mp3_input = write_temp_file("mux-raw-mp3-id3v1-input", &bytes); + let output_path = write_temp_file("mux-raw-mp3-id3v1-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&mp3_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + expected.as_slice() + ); +} + +#[test] +fn mux_to_path_imports_raw_ac3_inputs() { + let ac3_input = write_test_ac3_file("mux-raw-ac3-input", &[b"ac3"]); + let expected = fs::read(&ac3_input).unwrap(); + let output_path = write_temp_file("mux-raw-ac3-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ac3_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + expected.as_slice() + ); + + let ac3_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ac-3"), + ]), + ); + assert_eq!(ac3_entries.len(), 1); + assert_eq!(ac3_entries[0].sample_entry.box_type, fourcc("ac-3")); +} + +#[test] +fn mux_to_path_imports_raw_ac3_44100hz_inputs() { + let ac3_input = write_test_ac3_44100_file("mux-raw-ac3-44100-input", &[b"ac3"]); + let expected = fs::read(&ac3_input).unwrap(); + let output_path = write_temp_file("mux-raw-ac3-44100-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ac3_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + expected.as_slice() + ); + + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, 44_100); +} + +#[test] +fn mux_to_path_imports_raw_eac3_inputs() { + let eac3_input = write_test_eac3_file("mux-raw-eac3-input", &[b"ec3"]); + let expected = fs::read(&eac3_input).unwrap(); + let output_path = write_temp_file("mux-raw-eac3-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&eac3_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + expected.as_slice() + ); + + let eac3_entries = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ec-3"), + ]), + ); + assert_eq!(eac3_entries.len(), 1); + assert_eq!(eac3_entries[0].sample_entry.box_type, fourcc("ec-3")); +} + +#[test] +fn mux_to_path_imports_raw_eac3_inputs_with_dependent_substreams() { + let eac3_input = + write_test_eac3_file_with_dependent_substream("mux-raw-eac3-dependent-input", &[b"ec3"]); + let expected = fs::read(&eac3_input).unwrap(); + let output_path = write_temp_file("mux-raw-eac3-dependent-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&eac3_input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + expected.as_slice() + ); + + let dec3_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("ec-3"), + fourcc("dec3"), + ]), + ); + + assert_eq!(dec3_boxes.len(), 1); + assert_eq!(dec3_boxes[0].ec3_substreams.len(), 1); + assert_eq!(dec3_boxes[0].ec3_substreams[0].num_dep_sub, 1); + assert_eq!(dec3_boxes[0].ec3_substreams[0].chan_loc, 2); +} + +#[test] +fn mux_to_path_reimports_hevc_outputs_with_decoder_configuration() { + let h265_input = write_test_h265_annexb_file("mux-hevc-reimport-source", &[b"hevc"]); + let intermediate = write_temp_file("mux-hevc-reimport-intermediate", &[]); + let final_output = write_temp_file("mux-hevc-reimport-output", &[]); + let first_request = MuxRequest::new(vec![MuxTrackSpec::path(&h265_input)]); + let second_request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &intermediate, + MuxMp4TrackSelector::Video, + )]); + + mux_to_path(&first_request, &intermediate).unwrap(); + mux_to_path(&second_request, &final_output).unwrap(); + + let output_bytes = fs::read(final_output).unwrap(); + let root_boxes = read_root_boxes(&output_bytes); + assert_eq!( + mdat_payload(&output_bytes, root_boxes[2]), + &[0, 0, 0, 6, 0x26, 0x01, b'h', b'e', b'v', b'c'] + ); +} + +#[test] +fn mux_to_path_accepts_imported_init_only_tracks_with_empty_sample_tables() { + let input = build_imported_track_input_file( + "mux-empty-av1-init-input", + &MuxFileConfig::new(1_000).with_major_brand(fourcc("dash")), + &MuxTrackConfig::new_video(1, 1_000, 640, 360, video_sample_entry_box_with_type("av01")), + 0, + &[], + ); + let output_path = write_temp_file("mux-empty-av1-init-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4(&input, MuxMp4TrackSelector::Video)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + let stsc_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + let stsz_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + let stco_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stco"), + ]), + ); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entry_count, 0); + assert_eq!(stsc_boxes.len(), 1); + assert_eq!(stsc_boxes[0].entry_count, 0); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(stsz_boxes[0].sample_count, 0); + assert_eq!(stco_boxes.len(), 1); + assert_eq!(stco_boxes[0].entry_count, 0); +} + +#[test] +fn mux_to_path_preserves_authority_movie_timescale_for_pure_imported_tracks() { + let video_input = build_imported_track_input_file( + "mux-promoted-timescale-video-input", + &MuxFileConfig::new(1_000).with_major_brand(fourcc("isom")), + &MuxTrackConfig::new_video( + 1, + 30_000, + 640, + 360, + video_sample_entry_box_with_type("avc1"), + ), + 33, + &[TestMuxSample { + bytes: b"video", + duration: 1_001, + composition_time_offset: 0, + is_sync_sample: true, + }], + ); + let audio_input = build_imported_track_input_file( + "mux-promoted-timescale-audio-input", + &MuxFileConfig::new(1_000).with_major_brand(fourcc("isom")), + &MuxTrackConfig::new_audio(1, 48_000, audio_sample_entry_box_with_type("dtsx")), + 21, + &[TestMuxSample { + bytes: b"dtsx", + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }], + ); + + for ( + input, + selector, + expected_movie_timescale, + expected_media_timescale, + expected_sample_delta, + ) in [ + ( + video_input, + MuxMp4TrackSelector::Video, + 1_000_u32, + 30_000_u32, + 1_001_u32, + ), + ( + audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + 1_000_u32, + 48_000_u32, + 1_024_u32, + ), + ] { + let output_path = write_temp_file( + &format!("mux-authority-timescale-output-{expected_movie_timescale}"), + &[], + ); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4(&input, selector)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(output_path).unwrap(); + let mvhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([fourcc("moov"), fourcc("mvhd")]), + ); + let mdhd_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(mvhd_boxes.len(), 1); + assert_eq!(mvhd_boxes[0].timescale, expected_movie_timescale); + assert_eq!(mdhd_boxes.len(), 1); + assert_eq!(mdhd_boxes[0].timescale, expected_media_timescale); + assert_eq!(stts_boxes.len(), 1); + assert_eq!(stts_boxes[0].entries[0].sample_delta, expected_sample_delta); + } +} + +#[test] +fn write_mp4_mux_builds_a_real_mp4_container() { + let mut sources = [ + Cursor::new(b"AAAAhelloBBBBxy".to_vec()), + Cursor::new(b"zzzzSYNCtail".to_vec()), + ]; + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 2, 10, 4, 13, 2), + MuxStagedMediaItem::new(1, 1, 0, 5, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 2, 0, 4, 4, 5) + .with_composition_time_offset(2) + .with_sync_sample(true), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + let file_config = MuxFileConfig::new(1_000) + .with_major_brand(fourcc("isom")) + .with_compatible_brand(fourcc("mp42")); + let track_configs = vec![ + MuxTrackConfig::new_audio(1, 1_000, audio_sample_entry_box()), + MuxTrackConfig::new_video(2, 1_000, 640, 360, video_sample_entry_box()), + ]; + + let mut output = Cursor::new(Vec::new()); + write_mp4_mux( + &mut sources, + &mut output, + &file_config, + &track_configs, + &plan, + ) + .unwrap(); + + let bytes = output.into_inner(); + let root_boxes = read_root_boxes(&bytes); + assert_eq!( + root_boxes.iter().map(BoxInfo::box_type).collect::>(), + vec![fourcc("ftyp"), fourcc("moov"), fourcc("mdat")] + ); + assert_eq!(mdat_payload(&bytes, root_boxes[2]), b"helloSYNCxy"); + + let tkhds = extract_boxes::( + &bytes, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("tkhd")]), + ); + assert_eq!(tkhds.len(), 2); + assert_eq!(tkhds[0].track_id, 1); + assert_eq!(tkhds[0].duration(), 5); + assert_eq!(tkhds[0].alternate_group, 1); + assert_eq!(tkhds[0].volume, 0x0100); + assert_eq!(tkhds[1].track_id, 2); + assert_eq!(tkhds[1].duration(), 14); + assert_eq!(tkhds[1].alternate_group, 0); + assert_eq!(tkhds[1].width, u32::from(640_u16) << 16); + assert_eq!(tkhds[1].height, u32::from(360_u16) << 16); + + let mdhds = extract_boxes::( + &bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("mdhd"), + ]), + ); + assert_eq!( + mdhds + .iter() + .map(|box_value| box_value.timescale) + .collect::>(), + vec![1_000, 1_000] + ); + assert_eq!( + mdhds.iter().map(Mdhd::duration).collect::>(), + vec![5, 14] + ); + + let stts_boxes = extract_boxes::( + &bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(stts_boxes.len(), 2); + assert_eq!(stts_boxes[0].entry_count, 1); + assert_eq!(stts_boxes[0].entries[0].sample_count, 1); + assert_eq!(stts_boxes[0].entries[0].sample_delta, 5); + assert_eq!(stts_boxes[1].entry_count, 1); + assert_eq!(stts_boxes[1].entries[0].sample_count, 2); + assert_eq!(stts_boxes[1].entries[0].sample_delta, 4); + + let stsc_boxes = extract_boxes::( + &bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + assert_eq!(stsc_boxes.len(), 2); + assert_eq!(stsc_boxes[0].entries[0].first_chunk, 1); + assert_eq!(stsc_boxes[0].entries[0].samples_per_chunk, 1); + assert_eq!(stsc_boxes[0].entries[0].sample_description_index, 1); + assert_eq!(stsc_boxes[1].entries[0].samples_per_chunk, 1); + + let stsz_boxes = extract_boxes::( + &bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + assert_eq!(stsz_boxes.len(), 2); + assert_eq!(stsz_boxes[0].sample_count, 1); + assert_eq!(stsz_boxes[0].sample_size, 4); + assert!(stsz_boxes[0].entry_size.is_empty()); + assert_eq!(stsz_boxes[1].sample_count, 2); + assert_eq!(stsz_boxes[1].entry_size, vec![5, 2]); + + let stco_boxes = extract_boxes::( + &bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stco"), + ]), + ); + let mdat_data_start = root_boxes[2].offset() + root_boxes[2].header_size(); + assert_eq!(stco_boxes.len(), 2); + assert_eq!(stco_boxes[0].chunk_offset, vec![mdat_data_start + 5]); + assert_eq!( + stco_boxes[1].chunk_offset, + vec![mdat_data_start, mdat_data_start + 9] + ); + + let ctts_boxes = extract_boxes::( + &bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("ctts"), + ]), + ); + assert_eq!(ctts_boxes.len(), 1); + assert_eq!(ctts_boxes[0].entry_count, 2); + assert_eq!(ctts_boxes[0].entries[0].sample_count, 1); + assert_eq!(ctts_boxes[0].sample_offset(0), 2); + assert_eq!(ctts_boxes[0].entries[1].sample_count, 1); + assert_eq!(ctts_boxes[0].sample_offset(1), 0); + + let stss_boxes = extract_boxes::( + &bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stss"), + ]), + ); + assert_eq!(stss_boxes.len(), 1); + assert_eq!(stss_boxes[0].sample_number, vec![1]); +} + +#[test] +fn write_mp4_mux_to_path_matches_in_memory_container_output() { + let first_source = write_temp_file("mux-container-source-a", b"AAAAhelloBBBBxy"); + let second_source = write_temp_file("mux-container-source-b", b"zzzzSYNCtail"); + let output_path = write_temp_file("mux-container-output-sync", &[]); + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 2, 10, 4, 13, 2), + MuxStagedMediaItem::new(1, 1, 0, 5, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 2, 0, 4, 4, 5) + .with_composition_time_offset(2) + .with_sync_sample(true), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + let file_config = MuxFileConfig::new(1_000); + let track_configs = vec![ + MuxTrackConfig::new_audio(1, 1_000, audio_sample_entry_box()), + MuxTrackConfig::new_video(2, 1_000, 640, 360, video_sample_entry_box()), + ]; + + let mut in_memory_sources = [ + Cursor::new(b"AAAAhelloBBBBxy".to_vec()), + Cursor::new(b"zzzzSYNCtail".to_vec()), + ]; + let mut expected_output = Cursor::new(Vec::new()); + write_mp4_mux( + &mut in_memory_sources, + &mut expected_output, + &file_config, + &track_configs, + &plan, + ) + .unwrap(); + write_mp4_mux_to_path( + &[&first_source, &second_source], + &output_path, + &file_config, + &track_configs, + &plan, + ) + .unwrap(); + + assert_eq!(fs::read(output_path).unwrap(), expected_output.into_inner()); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn copy_planned_payloads_async_matches_sync_file_output() { + let first_source = write_temp_file("mux-source-async-a", b"HEADvideoTAIL"); + let second_source = write_temp_file("mux-source-async-b", b"PREMaudPOST"); + let sync_output = write_temp_file("mux-output-sync-file", &[]); + let async_output = write_temp_file("mux-output-async-file", &[]); + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 2, 10, 4, 4, 5), + MuxStagedMediaItem::new(1, 1, 0, 4, 4, 3), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + copy_planned_payloads_to_path(&[&first_source, &second_source], &sync_output, &plan).unwrap(); + copy_planned_payloads_to_path_async(&[&first_source, &second_source], &async_output, &plan) + .await + .unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn write_mp4_mux_to_path_async_matches_sync_container_output() { + let first_source = write_temp_file("mux-container-async-source-a", b"AAAAhelloBBBBxy"); + let second_source = write_temp_file("mux-container-async-source-b", b"zzzzSYNCtail"); + let sync_output = write_temp_file("mux-container-sync-output", &[]); + let async_output = write_temp_file("mux-container-async-output", &[]); + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 2, 10, 4, 13, 2), + MuxStagedMediaItem::new(1, 1, 0, 5, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 2, 0, 4, 4, 5) + .with_composition_time_offset(2) + .with_sync_sample(true), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + let file_config = MuxFileConfig::new(1_000); + let track_configs = vec![ + MuxTrackConfig::new_audio(1, 1_000, audio_sample_entry_box()), + MuxTrackConfig::new_video(2, 1_000, 640, 360, video_sample_entry_box()), + ]; + + write_mp4_mux_to_path( + &[&first_source, &second_source], + &sync_output, + &file_config, + &track_configs, + &plan, + ) + .unwrap(); + write_mp4_mux_to_path_async( + &[&first_source, &second_source], + &async_output, + &file_config, + &track_configs, + &plan, + ) + .await + .unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_path_first_track_output() { + let audio_input = write_test_adts_file("mux-async-audio-input", &[b"abc", b"defg"]); + let av1_frame_a = build_test_av1_sequence_header_obu(640, 360); + let av1_frame_b = build_test_av1_sequence_header_obu(640, 360); + let video_input = write_test_av1_ivf_file( + "mux-async-video-input", + 640, + 360, + &[0, 1], + &[av1_frame_a.as_slice(), av1_frame_b.as_slice()], + ); + let sync_output = write_temp_file("mux-async-sync-output", &[]); + let async_output = write_temp_file("mux-async-async-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&audio_input), + MuxTrackSpec::path(&video_input), + ]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_raw_av1_annexb_output() { + let frame_a = build_test_av1_sequence_header_obu(640, 360); + let frame_b = build_test_av1_sequence_header_obu(640, 360); + let input = write_test_av1_annex_b_file( + "mux-async-av1-annexb-input", + &[frame_a.as_slice(), frame_b.as_slice()], + ); + let sync_output = write_temp_file("mux-async-av1-annexb-sync-output", &[]); + let async_output = write_temp_file("mux-async-av1-annexb-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_program_stream_output() { + let ps_input = + write_test_program_stream_mp3_file("mux-async-program-stream-input", &[&[0x55; 96]]); + let sync_output = write_temp_file("mux-async-program-stream-sync-output", &[]); + let async_output = write_temp_file("mux-async-program-stream-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_program_stream_mp2_output() { + let ps_input = + write_test_program_stream_mp2_file("mux-async-program-stream-mp2-input", &[&[0x55; 96]]); + let sync_output = write_temp_file("mux-async-program-stream-mp2-sync-output", &[]); + let async_output = write_temp_file("mux-async-program-stream-mp2-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_program_stream_ac3_output() { + let ps_input = + write_test_program_stream_ac3_file("mux-async-program-stream-ac3-input", &[b"ac3"]); + let sync_output = write_temp_file("mux-async-program-stream-ac3-sync-output", &[]); + let async_output = write_temp_file("mux-async-program-stream-ac3-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_program_stream_lpcm_output() { + let sample_a = [0x00_u8, 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x04]; + let sample_b = [0x00_u8, 0x05, 0x00, 0x06, 0x00, 0x07, 0x00, 0x08]; + let ps_input = write_test_program_stream_lpcm_file( + "mux-async-program-stream-lpcm-input", + &[&sample_a, &sample_b], + ); + let sync_output = write_temp_file("mux-async-program-stream-lpcm-sync-output", &[]); + let async_output = write_temp_file("mux-async-program-stream-lpcm-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_program_stream_h264_open_ended_output() { + let ps_input = write_test_program_stream_h264_open_ended_file( + "mux-async-program-stream-h264-open-ended-input", + &[b"idr-sample", b"p-sample"], + ); + let sync_output = write_temp_file("mux-async-program-stream-h264-open-ended-sync-output", &[]); + let async_output = + write_temp_file("mux-async-program-stream-h264-open-ended-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_program_stream_mpeg2v_output() { + let ps_input = write_test_program_stream_mpeg2v_file( + "mux-async-program-stream-mpeg2v-input", + &[b"ps-mpeg2v-a", b"ps-mpeg2v-b"], + ); + let sync_output = write_temp_file("mux-async-program-stream-mpeg2v-sync-output", &[]); + let async_output = write_temp_file("mux-async-program-stream-mpeg2v-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_program_stream_mpeg2v_pts_dts_output() { + let ps_input = write_test_program_stream_mpeg2v_pts_dts_file( + "mux-async-program-stream-mpeg2v-pts-dts-input", + &[b"ps-mpeg2v-a", b"ps-mpeg2v-b"], + ); + let sync_output = write_temp_file("mux-async-program-stream-mpeg2v-pts-dts-sync-output", &[]); + let async_output = write_temp_file("mux-async-program-stream-mpeg2v-pts-dts-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_transport_stream_output() { + let ts_input = + write_test_transport_stream_mp3_file("mux-async-transport-stream-input", &[&[0x66; 320]]); + let sync_output = write_temp_file("mux-async-transport-stream-sync-output", &[]); + let async_output = write_temp_file("mux-async-transport-stream-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_rejects_transport_stream_pat_sections_with_bad_crc() { + let ts_input = + write_test_transport_stream_mp4v_file("mux-async-transport-stream-bad-pat-source", &[b"a"]); + let bad_ts_input = corrupt_mpeg2ts_section_crc( + &ts_input, + 0x0000, + "mux-async-transport-stream-bad-pat-input", + ); + let sync_output = write_temp_file("mux-async-transport-stream-bad-pat-sync-output", &[]); + let async_output = write_temp_file("mux-async-transport-stream-bad-pat-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&bad_ts_input)]); + + let sync_error = mux_to_path(&request, &sync_output).unwrap_err().to_string(); + let async_error = mux_to_path_async(&request, &async_output) + .await + .unwrap_err() + .to_string(); + + assert_eq!(sync_error, async_error); + assert!(sync_error.contains("PAT section failed CRC32 validation")); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_transport_stream_av1_output() { + let frame_a = build_test_av1_sequence_header_obu(320, 240); + let frame_b = build_test_av1_sequence_header_obu(320, 240); + let ts_input = write_test_transport_stream_av1_file( + "mux-async-transport-stream-av1-input", + &[&frame_a, &frame_b], + ); + let sync_output = write_temp_file("mux-async-transport-stream-av1-sync-output", &[]); + let async_output = write_temp_file("mux-async-transport-stream-av1-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_transport_stream_avs3_output() { + let ts_input = write_test_transport_stream_avs3_file( + "mux-async-transport-stream-avs3-input", + &[b"avs3-a", b"avs3-b"], + ); + let sync_output = write_temp_file("mux-async-transport-stream-avs3-sync-output", &[]); + let async_output = write_temp_file("mux-async-transport-stream-avs3-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_transport_stream_vvc_output() { + let ts_input = + write_test_transport_stream_vvc_file("mux-async-transport-stream-vvc-input", &[]); + let sync_output = write_temp_file("mux-async-transport-stream-vvc-sync-output", &[]); + let async_output = write_temp_file("mux-async-transport-stream-vvc-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_program_stream_vvc_output() { + let ps_input = write_test_program_stream_vvc_file("mux-async-program-stream-vvc-input", &[]); + let sync_output = write_temp_file("mux-async-program-stream-vvc-sync-output", &[]); + let async_output = write_temp_file("mux-async-program-stream-vvc-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_transport_stream_ac3_output() { + let ts_input = + write_test_transport_stream_ac3_file("mux-async-transport-stream-ac3-input", &[b"ac3"]); + let sync_output = write_temp_file("mux-async-transport-stream-ac3-sync-output", &[]); + let async_output = write_temp_file("mux-async-transport-stream-ac3-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_transport_stream_latm_output() { + let ts_input = write_test_transport_stream_latm_file( + "mux-async-transport-stream-latm-input", + &[b"abc", b"defg"], + ); + let sync_output = write_temp_file("mux-async-transport-stream-latm-sync-output", &[]); + let async_output = write_temp_file("mux-async-transport-stream-latm-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_transport_stream_latm_other_data_output() { + let ts_input = write_test_transport_stream_latm_other_data_file( + "mux-async-transport-stream-latm-other-data-input", + &[b"abc", b"defg"], + ); + let sync_output = write_temp_file( + "mux-async-transport-stream-latm-other-data-sync-output", + &[], + ); + let async_output = write_temp_file( + "mux-async-transport-stream-latm-other-data-async-output", + &[], + ); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_transport_stream_mhas_output() { + let ts_input = write_test_transport_stream_mhas_file( + "mux-async-transport-stream-mhas-input", + &[b"frame-one", b"frame-two"], + ); + let sync_output = write_temp_file("mux-async-transport-stream-mhas-sync-output", &[]); + let async_output = write_temp_file("mux-async-transport-stream-mhas-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_transport_stream_eac3_output() { + let ts_input = + write_test_transport_stream_eac3_file("mux-async-transport-stream-eac3-input", &[b"ec3"]); + let sync_output = write_temp_file("mux-async-transport-stream-eac3-sync-output", &[]); + let async_output = write_temp_file("mux-async-transport-stream-eac3-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_transport_stream_ac4_output() { + let ts_input = write_test_transport_stream_ac4_file("mux-async-transport-stream-ac4-input", 2); + let sync_output = write_temp_file("mux-async-transport-stream-ac4-sync-output", &[]); + let async_output = write_temp_file("mux-async-transport-stream-ac4-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_transport_stream_truehd_output() { + let ts_input = write_test_transport_stream_truehd_file( + "mux-async-transport-stream-truehd-input", + &[b"abcdefgh", b"ijklmnop"], + ); + let sync_output = write_temp_file("mux-async-transport-stream-truehd-sync-output", &[]); + let async_output = write_temp_file("mux-async-transport-stream-truehd-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_transport_stream_dts_output() { + let ts_input = write_test_transport_stream_dts_file("mux-async-transport-stream-dts-input", 2); + let sync_output = write_temp_file("mux-async-transport-stream-dts-sync-output", &[]); + let async_output = write_temp_file("mux-async-transport-stream-dts-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_transport_stream_dts_stream_type_output() { + let ts_input = write_test_transport_stream_dts_stream_type_file( + "mux-async-transport-stream-dts-stream-type-input", + 2, + ); + let sync_output = write_temp_file( + "mux-async-transport-stream-dts-stream-type-sync-output", + &[], + ); + let async_output = write_temp_file( + "mux-async-transport-stream-dts-stream-type-async-output", + &[], + ); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_transport_stream_dvb_subtitle_output() { + let ts_input = write_test_transport_stream_dvb_subtitle_file( + "mux-async-transport-stream-dvb-subtitle-input", + &[b"\x20async-sub"], + ); + let sync_output = write_temp_file("mux-async-transport-stream-dvb-subtitle-sync-output", &[]); + let async_output = write_temp_file("mux-async-transport-stream-dvb-subtitle-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_vobsub_sub_output() { + let (_idx_input, sub_input) = + write_test_vobsub_files("mux-async-vobsub-sub-input", &[1_000], &[b"\xDE\xAD"]); + let sync_output = write_temp_file("mux-async-vobsub-sync-output", &[]); + let async_output = write_temp_file("mux-async-vobsub-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&sub_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(&sync_output, &async_output); + let async_bytes = fs::read(&async_output).unwrap(); + + let hdlr_boxes = extract_boxes::( + &async_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + let stsz_boxes = extract_boxes::( + &async_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("subp")); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(stsz_boxes[0].sample_count, 2); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_program_stream_vobsub_output() { + let ps_input = write_test_program_stream_vobsub_file( + "mux-async-program-stream-vobsub-input", + &[1_000], + &[b"\xDE\xAD"], + ); + let sync_output = write_temp_file("mux-async-program-stream-vobsub-sync-output", &[]); + let async_output = write_temp_file("mux-async-program-stream-vobsub-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ps_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(&sync_output, &async_output); + let async_bytes = fs::read(&async_output).unwrap(); + + let hdlr_boxes = extract_boxes::( + &async_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("hdlr"), + ]), + ); + let stsz_boxes = extract_boxes::( + &async_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + assert_eq!(hdlr_boxes.len(), 1); + assert_eq!(hdlr_boxes[0].handler_type, fourcc("subp")); + assert_eq!(stsz_boxes.len(), 1); + assert_eq!(stsz_boxes[0].sample_count, 1); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_transformed_raw_track_output() { + let audio_input = write_test_adts_file("mux-async-adts-input", &[b"abc", b"defg"]); + let video_input = write_test_h265_annexb_file("mux-async-h265-input", &[b"hevc"]); + let sync_output = write_temp_file("mux-async-transformed-sync-output", &[]); + let async_output = write_temp_file("mux-async-transformed-async-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::path(&audio_input), + MuxTrackSpec::path(&video_input), + ]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_raw_eac3_output() { + let eac3_input = write_test_eac3_file("mux-async-eac3-input", &[b"ec3"]); + let sync_output = write_temp_file("mux-async-eac3-sync-output", &[]); + let async_output = write_temp_file("mux-async-eac3-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&eac3_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_raw_dts_output() { + let dts_input = write_test_dts_file("mux-async-dts-input", 2); + let sync_output = write_temp_file("mux-async-dts-sync-output", &[]); + let async_output = write_temp_file("mux-async-dts-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&dts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_wrapped_core_dts_output() { + let dts_input = write_test_wrapped_dts_file("mux-async-dts-wrapped-input", 2); + let sync_output = write_temp_file("mux-async-dts-wrapped-sync-output", &[]); + let async_output = write_temp_file("mux-async-dts-wrapped-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&dts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_wrapped_core_dts_output_with_trailing_family_tail() { + let dts_input = write_test_wrapped_dts_file_with_tail( + "mux-async-dts-wrapped-tail-input", + 2, + b"DTSHDTRAILER", + ); + let sync_output = write_temp_file("mux-async-dts-wrapped-tail-sync-output", &[]); + let async_output = write_temp_file("mux-async-dts-wrapped-tail-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&dts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_14bit_little_endian_raw_dts_output() { + let dts_input = write_test_dts_14bit_little_endian_file("mux-async-dts-14le-input", 2); + let sync_output = write_temp_file("mux-async-dts-14le-sync-output", &[]); + let async_output = write_temp_file("mux-async-dts-14le-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&dts_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_raw_ac4_output() { + let ac4_input = write_test_ac4_file("mux-async-ac4-input", 2); + let sync_output = write_temp_file("mux-async-ac4-sync-output", &[]); + let async_output = write_temp_file("mux-async-ac4-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ac4_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_raw_amr_output() { + let amr_input = write_test_amr_file("mux-async-amr-input", &[b"one", b"two"]); + let sync_output = write_temp_file("mux-async-amr-sync-output", &[]); + let async_output = write_temp_file("mux-async-amr-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&amr_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_raw_amr_wb_output() { + let amr_input = write_test_amr_wb_file("mux-async-amr-wb-input", &[b"wide", b"band"]); + let sync_output = write_temp_file("mux-async-amr-wb-sync-output", &[]); + let async_output = write_temp_file("mux-async-amr-wb-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&amr_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_raw_latm_output() { + let latm_input = write_test_latm_file("mux-async-latm-input", &[b"abc", b"defg"]); + let sync_output = write_temp_file("mux-async-latm-sync-output", &[]); + let async_output = write_temp_file("mux-async-latm-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&latm_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_raw_usac_latm_output() { + let latm_input = + write_test_usac_latm_file("mux-async-usac-latm-input", &[b"\x80abc", b"\x00defg"]); + let sync_output = write_temp_file("mux-async-usac-latm-sync-output", &[]); + let async_output = write_temp_file("mux-async-usac-latm-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&latm_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_raw_truehd_output() { + let truehd_input = + write_test_truehd_file("mux-async-truehd-input", &[b"abcdefgh", b"ijklmnop"]); + let sync_output = write_temp_file("mux-async-truehd-sync-output", &[]); + let async_output = write_temp_file("mux-async-truehd-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&truehd_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_raw_flac_output() { + let flac_input = write_test_flac_file("mux-async-flac-input", b"flac-frame"); + let sync_output = write_temp_file("mux-async-flac-sync-output", &[]); + let async_output = write_temp_file("mux-async-flac-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&flac_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_ogg_flac_output() { + let flac_input = write_test_ogg_flac_file("mux-async-ogg-flac-input", &[b"abc", b"def"]); + let sync_output = write_temp_file("mux-async-ogg-flac-sync-output", &[]); + let async_output = write_temp_file("mux-async-ogg-flac-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&flac_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_ogg_flac_mapping_output() { + let flac_input = + write_test_ogg_flac_mapping_file("mux-async-ogg-flac-mapping-input", &[b"abc", b"def"]); + let sync_output = write_temp_file("mux-async-ogg-flac-mapping-sync-output", &[]); + let async_output = write_temp_file("mux-async-ogg-flac-mapping-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&flac_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_ogg_flac_split_header_output() { + let flac_input = + write_test_ogg_flac_split_header_file("mux-async-ogg-flac-split-input", &[b"abc", b"def"]); + let sync_output = write_temp_file("mux-async-ogg-flac-split-sync-output", &[]); + let async_output = write_temp_file("mux-async-ogg-flac-split-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&flac_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_mhas_output() { + let mhas_input = write_test_mhas_file("mux-async-mhas-input", &[b"frame-one", b"frame-two"]); + let sync_output = write_temp_file("mux-async-mhas-sync-output", &[]); + let async_output = write_temp_file("mux-async-mhas-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&mhas_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_iamf_output() { + let iamf_input = write_test_iamf_file("mux-async-iamf-input", &[b"frame-one", b"frame-two"]); + let sync_output = write_temp_file("mux-async-iamf-sync-output", &[]); + let async_output = write_temp_file("mux-async-iamf-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&iamf_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_ogg_opus_output() { + let opus_input = write_test_ogg_opus_file("mux-async-opus-input", &[b"abc", b"def"]); + let sync_output = write_temp_file("mux-async-opus-sync-output", &[]); + let async_output = write_temp_file("mux-async-opus-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&opus_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_nhml_sidecar_output() { + let opus_input = write_test_ogg_opus_file("mux-async-nhml-sidecar-input", &[b"abc", b"def"]); + let report = inspect_direct_ingest_path(&opus_input).unwrap(); + let mut rendered = Vec::new(); + write_report(&mut rendered, &report, DirectIngestReportFormat::Nhml).unwrap(); + let sidecar_path = write_temp_file_with_extension("mux-async-nhml-sidecar", "nhml", &rendered); + let sync_output = write_temp_file("mux-async-nhml-sidecar-sync-output", &[]); + let async_output = write_temp_file("mux-async-nhml-sidecar-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&sidecar_path)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[test] +fn mux_to_path_imports_local_dash_dtsx_with_file_uri_base_url() { + let source_input = build_dtsx_dash_segment_input_file("mux-dash-dtsx-file-uri-source"); + let manifest_dir = temp_output_dir("mux-dash-dtsx-file-uri-manifest"); + let asset_dir = manifest_dir.join("assets"); + fs::create_dir_all(asset_dir.join("audio")).unwrap(); + fs::copy(&source_input, asset_dir.join("audio/segment.mp4")).unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + let asset_base_uri = format!("{}/", path_to_file_uri_string(&asset_dir)); + fs::write( + &manifest_path, + concat!( + "\n", + "\n", + " " + ) + .to_string() + + &asset_base_uri + + concat!( + "\n", + " \n", + " \n", + " audio/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let output_path = write_temp_file("mux-dash-dtsx-file-uri-output", &[]); + mux_to_path( + &MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]), + &output_path, + ) + .unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let ftyp_boxes = extract_boxes::(&output_bytes, BoxPath::from([fourcc("ftyp")])); + let sample_entry_boxes = extract_box_bytes( + &mut Cursor::new(&output_bytes), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("dtsx"), + ]), + ) + .unwrap(); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(ftyp_boxes.len(), 1); + assert_eq!(ftyp_boxes[0].major_brand, fourcc("isom")); + assert_eq!( + ftyp_boxes[0].compatible_brands, + vec![fourcc("isom"), fourcc("iso8"), fourcc("dtsx")] + ); + assert_eq!(sample_entry_boxes.len(), 1); + assert!( + sample_entry_boxes[0] + .windows(4) + .any(|bytes| bytes == b"udts") + ); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 1_024, + }] + ); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_nhnt_sidecar_output() { + let opus_input = write_test_ogg_opus_file("mux-async-nhnt-sidecar-input", &[b"abc", b"def"]); + let report = inspect_direct_ingest_packets(&opus_input).unwrap(); + let mut rendered = Vec::new(); + write_packet_report(&mut rendered, &report, DirectIngestReportFormat::Nhnt).unwrap(); + let sidecar_path = write_temp_file_with_extension("mux-async-nhnt-sidecar", "nhnt", &rendered); + let sync_output = write_temp_file("mux-async-nhnt-sidecar-sync-output", &[]); + let async_output = write_temp_file("mux-async-nhnt-sidecar-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&sidecar_path)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_local_dash_template_representation_tokens() { + let source_input = build_video_input_file( + "mux-async-dash-template-source", + fourcc("isom"), + &[b"dash-template-frame"], + ); + let manifest_dir = temp_output_dir("mux-async-dash-template-manifest"); + fs::create_dir_all(&manifest_dir).unwrap(); + let segment_path = manifest_dir.join("video_64000_1.mp4"); + fs::copy(&source_input, &segment_path).unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let sync_output = write_temp_file("mux-async-dash-template-sync-output", &[]); + let async_output = write_temp_file("mux-async-dash-template-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(&sync_output, &async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_local_dash_number_templates_with_formatting() { + let source_input = build_video_input_file( + "mux-async-dash-number-template-source", + fourcc("isom"), + &[b"dash-number-frame"], + ); + let manifest_dir = temp_output_dir("mux-async-dash-number-template-manifest"); + fs::create_dir_all(&manifest_dir).unwrap(); + fs::copy( + &source_input, + manifest_dir.join("literal_$video_064000_001.mp4"), + ) + .unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let sync_output = write_temp_file("mux-async-dash-number-template-sync-output", &[]); + let async_output = write_temp_file("mux-async-dash-number-template-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(&sync_output, &async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_local_adaptation_dash_template_tokens() { + let source_input = build_video_input_file( + "mux-async-dash-adaptation-template-source", + fourcc("isom"), + &[b"dash-adaptation-template-frame"], + ); + let manifest_dir = temp_output_dir("mux-async-dash-adaptation-template-manifest"); + fs::create_dir_all(&manifest_dir).unwrap(); + let segment_path = manifest_dir.join("video_64000_1.mp4"); + fs::copy(&source_input, &segment_path).unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let sync_output = write_temp_file("mux-async-dash-adaptation-template-sync-output", &[]); + let async_output = write_temp_file("mux-async-dash-adaptation-template-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(&sync_output, &async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_multi_period_local_dash_segment_lists() { + let first_input = build_video_input_file( + "mux-async-dash-multi-period-source-a", + fourcc("isom"), + &[b"dash-period-one"], + ); + let second_input = build_video_input_file( + "mux-async-dash-multi-period-source-b", + fourcc("isom"), + &[b"dash-period-two"], + ); + let manifest_dir = temp_output_dir("mux-async-dash-multi-period-manifest"); + fs::create_dir_all(manifest_dir.join("root/period-one")).unwrap(); + fs::create_dir_all(manifest_dir.join("root/period-two")).unwrap(); + fs::copy( + &first_input, + manifest_dir.join("root/period-one/segment.mp4"), + ) + .unwrap(); + fs::copy( + &second_input, + manifest_dir.join("root/period-two/segment.mp4"), + ) + .unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "\n", + "\n", + " root/\n", + " \n", + " period-one/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " period-two/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let sync_output = write_temp_file("mux-async-dash-multi-period-sync-output", &[]); + let async_output = write_temp_file("mux-async-dash-multi-period-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(&sync_output, &async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_local_dash_dtsx_file_uri_output() { + let source_input = build_dtsx_dash_segment_input_file("mux-async-dash-dtsx-file-uri-source"); + let manifest_dir = temp_output_dir("mux-async-dash-dtsx-file-uri-manifest"); + let asset_dir = manifest_dir.join("assets"); + fs::create_dir_all(asset_dir.join("audio")).unwrap(); + fs::copy(&source_input, asset_dir.join("audio/segment.mp4")).unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + let asset_base_uri = format!("{}/", path_to_file_uri_string(&asset_dir)); + fs::write( + &manifest_path, + concat!( + "\n", + "\n", + " " + ) + .to_string() + + &asset_base_uri + + concat!( + "\n", + " \n", + " \n", + " audio/\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "\n" + ), + ) + .unwrap(); + + let sync_output = write_temp_file("mux-async-dash-dtsx-file-uri-sync-output", &[]); + let async_output = write_temp_file("mux-async-dash-dtsx-file-uri-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(&sync_output, &async_output); +} + +#[cfg(feature = "async")] +#[tokio::test] +async fn mux_to_path_async_matches_sync_compact_local_dash_segment_lists() { + let source_input = build_video_input_file( + "mux-async-dash-compact-source", + fourcc("isom"), + &[b"dash-async-compact-frame"], + ); + let manifest_dir = temp_output_dir("mux-async-dash-compact-manifest"); + fs::create_dir_all(manifest_dir.join("root/adaptation/video")).unwrap(); + fs::copy( + &source_input, + manifest_dir.join("root/adaptation/video/segment.mp4"), + ) + .unwrap(); + let manifest_path = manifest_dir.join("manifest.mpd"); + fs::write( + &manifest_path, + concat!( + "", + "root/adaptation/", + "video/", + "", + "" + ), + ) + .unwrap(); + + let sync_output = write_temp_file("mux-async-dash-compact-sync-output", &[]); + let async_output = write_temp_file("mux-async-dash-compact-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&manifest_path)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(&sync_output, &async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_raw_bmp_outputs() { + for (label, bytes) in [ + ("bmp24", build_test_bmp24_bytes()), + ("bmp32", build_test_bmp32_bytes()), + ] { + let input = + write_temp_file_with_extension(&format!("mux-async-{label}-input"), "bmp", &bytes); + let sync_output = write_temp_file(&format!("mux-async-{label}-sync-output"), &[]); + let async_output = write_temp_file(&format!("mux-async-{label}-async-output"), &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(&sync_output, &async_output); + } +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_raw_y4m_outputs() { + for (label, bytes) in [ + ( + "y4m420", + build_test_y4m_bytes("C420", &[0x10, 0x20, 0x30, 0x40, 0x80, 0x90]), + ), + ( + "y4m422", + build_test_y4m_bytes("C422", &[0x10, 0x20, 0x30, 0x40, 0x80, 0x90, 0xA0, 0xB0]), + ), + ( + "y4m444alpha", + build_test_y4m_bytes( + "C444alpha", + &[ + 0x10, 0x20, 0x30, 0x40, 0x80, 0x90, 0xA0, 0xB0, 0xC0, 0xD0, 0xE0, 0xF0, 0x01, + 0x02, 0x03, 0x04, + ], + ), + ), + ] { + let input = + write_temp_file_with_extension(&format!("mux-async-{label}-input"), "y4m", &bytes); + let sync_output = write_temp_file(&format!("mux-async-{label}-sync-output"), &[]); + let async_output = write_temp_file(&format!("mux-async-{label}-async-output"), &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(&sync_output, &async_output); + } +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_explicit_rawvideo_outputs() { + for case in raw_video_test_cases() { + let params = + MuxRawVideoParams::new(case.width, case.height, case.pixel_format, 25, 1).unwrap(); + let input = write_temp_file_with_extension( + &format!("mux-async-{}-input", case.label), + "raw", + &build_test_raw_video_input_bytes(case, 2), + ); + let sync_output = write_temp_file(&format!("mux-async-{}-sync-output", case.label), &[]); + let async_output = write_temp_file(&format!("mux-async-{}-async-output", case.label), &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::raw_video(&input, params)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(&sync_output, &async_output); + } +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_raw_jpeg2000_outputs() { + for (label, extension, bytes) in [ + ("jp2", "jp2", build_test_jp2_bytes(8, 8)), + ( + "j2k", + "j2k", + build_test_j2k_codestream_bytes(8, 8, b"codestream"), + ), + ] { + let input = + write_temp_file_with_extension(&format!("mux-async-{label}-input"), extension, &bytes); + let sync_output = write_temp_file(&format!("mux-async-{label}-sync-output"), &[]); + let async_output = write_temp_file(&format!("mux-async-{label}-async-output"), &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(&sync_output, &async_output); + } +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_raw_prores_outputs() { + for (label, extension, bytes) in [ + ( + "prores-422hq", + "apch", + build_test_prores_frame_bytes(64, 32, 2), + ), + ( + "prores-4444", + "ap4h", + build_test_prores_frame_bytes(64, 32, 3), + ), + ] { + let input = + write_temp_file_with_extension(&format!("mux-async-{label}-input"), extension, &bytes); + let sync_output = write_temp_file(&format!("mux-async-{label}-sync-output"), &[]); + let async_output = write_temp_file(&format!("mux-async-{label}-async-output"), &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(&sync_output, &async_output); + } +} + +#[test] +fn mux_to_path_imports_single_frame_raw_prores_with_open_ended_stts() { + let input = write_temp_file_with_extension( + "mux-prores-single-frame-input", + "apch", + &build_test_prores_frame_bytes(64, 32, 2), + ); + let output_path = write_temp_file("mux-prores-single-frame-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&input)]); + + mux_to_path(&request, &output_path).unwrap(); + + let output_bytes = fs::read(&output_path).unwrap(); + let stts_boxes = extract_boxes::( + &output_bytes, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stts"), + ]), + ); + assert_eq!(stts_boxes.len(), 1); + assert_eq!( + stts_boxes[0].entries, + vec![SttsEntry { + sample_count: 1, + sample_delta: 0, + }] + ); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_saf_aac_output() { + let saf_input = write_test_saf_aac_file("mux-async-saf-aac-input", &[b"abc", b"def"]); + let sync_output = write_temp_file("mux-async-saf-aac-sync-output", &[]); + let async_output = write_temp_file("mux-async-saf-aac-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&saf_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_wave_pcm_output() { + let pcm_input = write_test_wave_pcm_file( + "mux-async-wave-pcm-input", + &[[-1_000, 1_000], [2_000, -2_000]], + ); + let sync_output = write_temp_file("mux-async-wave-pcm-sync-output", &[]); + let async_output = write_temp_file("mux-async-wave-pcm-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&pcm_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_aiff_pcm_output() { + let pcm_input = write_test_aiff_pcm_file( + "mux-async-aiff-pcm-input", + &[[-1_000, 1_000], [2_000, -2_000]], + ); + let sync_output = write_temp_file("mux-async-aiff-pcm-sync-output", &[]); + let async_output = write_temp_file("mux-async-aiff-pcm-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&pcm_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_ogg_vorbis_output() { + let vorbis_input = write_test_ogg_vorbis_file("mux-async-vorbis-input", &[b"abc", b"def"]); + let sync_output = write_temp_file("mux-async-vorbis-sync-output", &[]); + let async_output = write_temp_file("mux-async-vorbis-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&vorbis_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_ogg_speex_output() { + let speex_input = write_test_ogg_speex_file("mux-async-speex-input", &[b"abc", b"def"]); + let sync_output = write_temp_file("mux-async-speex-sync-output", &[]); + let async_output = write_temp_file("mux-async-speex-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&speex_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_rejects_ogg_pages_with_bad_crc() { + let speex_input = write_test_ogg_speex_file("mux-async-speex-bad-crc-input", &[b"abc", b"def"]); + let mut input_bytes = fs::read(&speex_input).unwrap(); + let first_payload_offset = 27 + usize::from(input_bytes[26]); + input_bytes[first_payload_offset] ^= 0x01; + fs::write(&speex_input, input_bytes).unwrap(); + let output_path = write_temp_file("mux-async-speex-bad-crc-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&speex_input)]); + + let error = mux_to_path_async(&request, &output_path).await.unwrap_err(); + match error { + MuxError::UnsupportedTrackImport { message, .. } => { + assert!(message.contains("failed CRC validation")); + } + other => panic!("expected unsupported-track error, got {other:?}"), + } +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_ogg_theora_output() { + let theora_input = + write_test_ogg_theora_file("mux-async-theora-input", &[b"frame-a", b"frame-b"]); + let sync_output = write_temp_file("mux-async-theora-sync-output", &[]); + let async_output = write_temp_file("mux-async-theora-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&theora_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_caf_alac_output() { + let alac_input = write_test_caf_alac_file("mux-async-alac-input", &[b"ABCD", b"EFGH"]); + let sync_output = write_temp_file("mux-async-alac-sync-output", &[]); + let async_output = write_temp_file("mux-async-alac-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&alac_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_variable_packet_caf_alac_output() { + let packet_a = vec![b'A'; 1_977]; + let packet_b = vec![b'B'; 254]; + let alac_input = write_test_caf_alac_variable_packet_file( + "mux-async-alac-variable-input", + &[packet_a.as_slice(), packet_b.as_slice()], + ); + let sync_output = write_temp_file("mux-async-alac-variable-sync-output", &[]); + let async_output = write_temp_file("mux-async-alac-variable-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&alac_input)]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_fragmented_output() { + let audio_input = build_audio_input_file( + "mux-async-fragmented-source", + fourcc("isom"), + &[b"one", b"two", b"three"], + ); + let sync_output = write_temp_file("mux-async-fragmented-sync-output", &[]); + let async_output = write_temp_file("mux-async-fragmented-async-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 0.015 }); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_fragmented_to_paths_async_matches_sync_split_output() { + let audio_input = build_audio_input_file( + "mux-async-fragmented-split-source", + fourcc("isom"), + &[b"one", b"two", b"three"], + ); + let sync_dir = temp_output_dir("mux-async-fragmented-split-sync-dir"); + let async_dir = temp_output_dir("mux-async-fragmented-split-async-dir"); + let sync_init = sync_dir.join("init.mp4"); + let sync_media = sync_dir.join("media.mp4"); + let async_init = async_dir.join("init.mp4"); + let async_media = async_dir.join("media.mp4"); + let request = MuxRequest::new(vec![MuxTrackSpec::mp4( + &audio_input, + MuxMp4TrackSelector::Audio { occurrence: 1 }, + )]) + .with_output_layout(MuxOutputLayout::Fragmented) + .with_duration_mode(MuxDurationMode::Fragment { seconds: 0.015 }); + + mux_fragmented_to_paths(&request, &sync_init, &sync_media).unwrap(); + mux_fragmented_to_paths_async(&request, &async_init, &async_media) + .await + .unwrap(); + + let mut sync_bytes = [fs::read(sync_init).unwrap(), fs::read(sync_media).unwrap()].concat(); + let mut async_bytes = [ + fs::read(async_init).unwrap(), + fs::read(async_media).unwrap(), + ] + .concat(); + normalize_mp4_time_fields(&mut sync_bytes); + normalize_mp4_time_fields(&mut async_bytes); + assert_eq!(sync_bytes, async_bytes); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn write_fragmented_mp4_mux_segmented_async_matches_sync_output() { + let first_source = write_temp_file("mux-async-fragmented-segmented-source", b"a1a2a3"); + let mut sync_sources = [std::fs::File::open(&first_source).unwrap()]; + let mut async_sources = [tokio::fs::File::open(&first_source).await.unwrap()]; + let file_config = MuxFileConfig::new(1_000).with_major_brand(fourcc("isom")); + let track_configs = vec![MuxTrackConfig::new_audio( + 1, + 1_000, + audio_sample_entry_box(), + )]; + let plan = plan_staged_media_items_with_chunk_sample_counts( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 10, 0, 2).with_sync_sample(true), + MuxStagedMediaItem::new(0, 1, 10, 10, 2, 2).with_sync_sample(true), + MuxStagedMediaItem::new(0, 1, 20, 10, 4, 2).with_sync_sample(true), + ], + MuxInterleavePolicy::DecodeTime, + [(1, vec![2, 1])], + ) + .unwrap(); + let mut sync_init = Cursor::new(Vec::new()); + let mut sync_media = Cursor::new(Vec::new()); + let async_dir = temp_output_dir("mux-async-fragmented-segmented-dir"); + let async_init_path = async_dir.join("init.mp4"); + let async_media_path = async_dir.join("media.mp4"); + tokio::fs::create_dir_all(&async_dir).await.unwrap(); + let mut async_init = tokio::fs::File::create(&async_init_path).await.unwrap(); + let mut async_media = tokio::fs::File::create(&async_media_path).await.unwrap(); + + write_fragmented_mp4_mux_segmented( + &mut sync_sources, + &mut sync_init, + &mut sync_media, + &file_config, + &track_configs, + &plan, + ) + .unwrap(); + write_fragmented_mp4_mux_segmented_async( + &mut async_sources, + &mut async_init, + &mut async_media, + &file_config, + &track_configs, + &plan, + ) + .await + .unwrap(); + async_init.flush().await.unwrap(); + async_media.flush().await.unwrap(); + + let mut sync_bytes = [sync_init.into_inner(), sync_media.into_inner()].concat(); + let mut async_bytes = [ + fs::read(async_init_path).unwrap(), + fs::read(async_media_path).unwrap(), + ] + .concat(); + normalize_mp4_time_fields(&mut sync_bytes); + normalize_mp4_time_fields(&mut async_bytes); + assert_eq!(sync_bytes, async_bytes); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mux_to_path_async_matches_sync_mixed_subtitle_output() { + let video_input = build_video_input_file_with_metadata( + "mux-async-mixed-video-input", + fourcc("isom"), + "avc1", + *b"und", + "PrimaryVideoHandler", + &[b"video"], + ); + let audio_input = build_audio_input_file_with_metadata( + "mux-async-mixed-audio-input", + fourcc("dash"), + "mp4a", + *b"eng", + "EnglishAudioHandler", + &[b"aud"], + ); + let text_input = build_mixed_text_input_file("mux-async-mixed-text-input", fourcc("mp42")); + let sync_output = write_temp_file("mux-async-mixed-sync-output", &[]); + let async_output = write_temp_file("mux-async-mixed-async-output", &[]); + let request = MuxRequest::new(vec![ + MuxTrackSpec::mp4(&video_input, MuxMp4TrackSelector::Video), + MuxTrackSpec::mp4(&audio_input, MuxMp4TrackSelector::Audio { occurrence: 1 }), + MuxTrackSpec::mp4(&text_input, MuxMp4TrackSelector::Text { occurrence: 1 }), + MuxTrackSpec::mp4(&text_input, MuxMp4TrackSelector::Text { occurrence: 2 }), + ]); + + mux_to_path(&request, &sync_output).unwrap(); + mux_to_path_async(&request, &async_output).await.unwrap(); + + assert_mp4_files_match_ignoring_time_fields(sync_output, async_output); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn copy_planned_payloads_async_supports_seekable_async_readers_and_writers() { + let mut sources = [ + Cursor::new(b"AAAAhelloBBBBxy".to_vec()), + Cursor::new(b"zzzzSYNCtail".to_vec()), + ]; + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 2, 10, 4, 13, 2), + MuxStagedMediaItem::new(1, 1, 0, 5, 4, 4), + MuxStagedMediaItem::new(0, 2, 0, 4, 4, 5), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut output = Cursor::new(Vec::new()); + copy_planned_payloads_async(&mut sources, &mut output, &plan) + .await + .unwrap(); + + assert_eq!(output.into_inner(), b"helloSYNCxy"); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn copy_planned_payloads_async_progressive_supports_non_seekable_readers() { + let (mut first_writer, first_source) = tokio::io::duplex(64); + let (mut second_writer, second_source) = tokio::io::duplex(64); + first_writer.write_all(b"AAAAhelloBBBBxy").await.unwrap(); + first_writer.shutdown().await.unwrap(); + second_writer.write_all(b"zzzzSYNCtail").await.unwrap(); + second_writer.shutdown().await.unwrap(); + + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 4, 4, 5), + MuxStagedMediaItem::new(1, 2, 5, 4, 4, 4), + MuxStagedMediaItem::new(0, 1, 10, 4, 13, 2), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut output = Cursor::new(Vec::new()); + let mut sources = [first_source, second_source]; + copy_planned_payloads_async_progressive(&mut sources, &mut output, &plan) + .await + .unwrap(); + + assert_eq!(output.into_inner(), b"helloSYNCxy"); +} + +fn build_audio_input_file( + prefix: &str, + major_brand: mp4forge::FourCc, + payloads: &[&[u8]], +) -> TestTempPath { + build_audio_input_file_with_type(prefix, major_brand, "mp4a", payloads) +} + +fn build_audio_input_file_with_metadata( + prefix: &str, + major_brand: mp4forge::FourCc, + sample_entry_type: &str, + language: [u8; 3], + handler_name: &str, + payloads: &[&[u8]], +) -> TestTempPath { + let samples = payloads + .iter() + .copied() + .map(|bytes| TestMuxSample { + bytes, + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }) + .collect::>(); + write_single_track_mp4_input( + prefix, + &MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")), + MuxTrackConfig::new_audio( + 1, + 1_000, + audio_sample_entry_box_with_type(sample_entry_type), + ) + .with_language(language) + .with_handler_name(handler_name), + &samples, + ) +} + +fn build_dtsx_dash_segment_input_file(prefix: &str) -> TestTempPath { + let sample_entry_box = audio_sample_entry_box_with_children( + "dtsx", + &encode_supported_box( + &Udts { + decoder_profile_code: 1, + frame_duration_code: 1, + max_payload_code: 1, + num_presentations_code: 5, + channel_mask: 3, + id_tag_present: vec![false; 6], + ..Udts::default() + }, + &[], + ), + ); + let file_config = MuxFileConfig::new(48_000) + .with_major_brand(fourcc("isom")) + .with_minor_version(0); + let track_config = MuxTrackConfig::new_audio(1, 48_000, sample_entry_box); + let samples = [TestMuxSample { + bytes: b"dtsx", + duration: 1_024, + composition_time_offset: 0, + is_sync_sample: true, + }]; + let ftyp_bytes = encode_supported_box( + &Ftyp { + major_brand: fourcc("isom"), + minor_version: 0, + compatible_brands: vec![fourcc("isom"), fourcc("iso8"), fourcc("dtsx")], + }, + &[], + ); + let payload = samples + .iter() + .flat_map(|sample| sample.bytes) + .copied() + .collect::>(); + let provisional_moov = + build_imported_track_moov_bytes(&file_config, &track_config, 1_024, None, &samples, &[]); + let mdat_header = BoxInfo::new(fourcc("mdat"), 8 + payload.len() as u64).encode(); + let moov_bytes = build_imported_track_moov_bytes( + &file_config, + &track_config, + 1_024, + None, + &samples, + &[u64::try_from(ftyp_bytes.len() + provisional_moov.len() + mdat_header.len()).unwrap()], + ); + write_temp_file( + prefix, + &[ftyp_bytes, moov_bytes, mdat_header, payload].concat(), + ) +} + +fn path_to_file_uri_string(path: &Path) -> String { + let absolute = path.canonicalize().unwrap(); + let display = absolute.display().to_string(); + let normalized = if let Some(stripped) = display.strip_prefix(r"\\?\UNC\") { + format!("//{}", stripped.replace('\\', "/")) + } else if let Some(stripped) = display.strip_prefix(r"\\?\") { + stripped.replace('\\', "/") + } else { + display.replace('\\', "/") + }; + if normalized.starts_with("//") { + format!("file:{normalized}") + } else if normalized.starts_with('/') { + format!("file://{normalized}") + } else { + format!("file:///{normalized}") + } +} + +fn build_test_bmp24_bytes() -> Vec { + build_test_bmp_bytes(24) +} + +fn build_test_bmp32_bytes() -> Vec { + build_test_bmp_bytes(32) +} + +fn build_test_bmp_bytes(bits_per_pixel: u16) -> Vec { + let width = 2_u32; + let height = 2_i32; + let row_stride = match bits_per_pixel { + 24 => 8_u32, + 32 => 8_u32, + _ => unreachable!(), + }; + let data_size = row_stride * u32::try_from(height).unwrap(); + let file_size = 54_u32 + data_size; + let mut bytes = Vec::with_capacity(usize::try_from(file_size).unwrap()); + bytes.extend_from_slice(b"BM"); + bytes.extend_from_slice(&file_size.to_le_bytes()); + bytes.extend_from_slice(&0_u16.to_le_bytes()); + bytes.extend_from_slice(&0_u16.to_le_bytes()); + bytes.extend_from_slice(&54_u32.to_le_bytes()); + bytes.extend_from_slice(&40_u32.to_le_bytes()); + bytes.extend_from_slice(&i32::try_from(width).unwrap().to_le_bytes()); + bytes.extend_from_slice(&height.to_le_bytes()); + bytes.extend_from_slice(&1_u16.to_le_bytes()); + bytes.extend_from_slice(&bits_per_pixel.to_le_bytes()); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(&data_size.to_le_bytes()); + bytes.extend_from_slice(&0_i32.to_le_bytes()); + bytes.extend_from_slice(&0_i32.to_le_bytes()); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + match bits_per_pixel { + 24 => { + bytes.extend_from_slice(&[0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0x00, 0x00]); + bytes.extend_from_slice(&[0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00, 0x00]); + } + 32 => { + bytes.extend_from_slice(&[0x00, 0x00, 0xFF, 0x40, 0x00, 0xFF, 0x00, 0x80]); + bytes.extend_from_slice(&[0xFF, 0x00, 0x00, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF]); + } + _ => unreachable!(), + } + bytes +} + +fn build_test_y4m_bytes(chroma: &str, payload: &[u8]) -> Vec { + let mut bytes = format!("YUV4MPEG2 W2 H2 F25:1 {chroma}\nFRAME\n").into_bytes(); + bytes.extend_from_slice(payload); + bytes +} + +#[derive(Clone, Copy)] +struct RawVideoTestCase { + label: &'static str, + spfmt: &'static str, + pixel_format: MuxRawVideoPixelFormat, + width: u32, + height: u32, + expect_pasp: bool, + expect_colr: bool, +} + +fn raw_video_test_cases() -> Vec { + vec![ + RawVideoTestCase { + label: "rawvideo-yuv420", + spfmt: "yuv", + pixel_format: MuxRawVideoPixelFormat::Yuv420p8, + width: 2, + height: 2, + expect_pasp: true, + expect_colr: true, + }, + RawVideoTestCase { + label: "rawvideo-yvu420", + spfmt: "yvu", + pixel_format: MuxRawVideoPixelFormat::Yvu420p8, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-yuv420-10", + spfmt: "yuvl", + pixel_format: MuxRawVideoPixelFormat::Yuv420p10, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-yuv422", + spfmt: "yuv2", + pixel_format: MuxRawVideoPixelFormat::Yuv422p8, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-yuv422-10", + spfmt: "yp2l", + pixel_format: MuxRawVideoPixelFormat::Yuv422p10, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-yuv444", + spfmt: "yuv4", + pixel_format: MuxRawVideoPixelFormat::Yuv444p8, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-yuv444-10", + spfmt: "yp4l", + pixel_format: MuxRawVideoPixelFormat::Yuv444p10, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-yuva420", + spfmt: "yuva", + pixel_format: MuxRawVideoPixelFormat::Yuva420p8, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-yuvd420", + spfmt: "yuvd", + pixel_format: MuxRawVideoPixelFormat::Yuvd420p8, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-yuv444alpha", + spfmt: "yp4a", + pixel_format: MuxRawVideoPixelFormat::Yuva444p8, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-nv12", + spfmt: "nv12", + pixel_format: MuxRawVideoPixelFormat::Nv12p8, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-nv21", + spfmt: "nv21", + pixel_format: MuxRawVideoPixelFormat::Nv21p8, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-nv12-10", + spfmt: "nv1l", + pixel_format: MuxRawVideoPixelFormat::Nv12p10, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-nv21-10", + spfmt: "nv2l", + pixel_format: MuxRawVideoPixelFormat::Nv21p10, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-uyvy", + spfmt: "uyvy", + pixel_format: MuxRawVideoPixelFormat::Uyvy422p8, + width: 2, + height: 2, + expect_pasp: true, + expect_colr: true, + }, + RawVideoTestCase { + label: "rawvideo-vyuy", + spfmt: "vyuy", + pixel_format: MuxRawVideoPixelFormat::Vyuy422p8, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-yuyv", + spfmt: "yuyv", + pixel_format: MuxRawVideoPixelFormat::Yuyv422p8, + width: 2, + height: 2, + expect_pasp: true, + expect_colr: true, + }, + RawVideoTestCase { + label: "rawvideo-yvyu", + spfmt: "yvyu", + pixel_format: MuxRawVideoPixelFormat::Yvyu422p8, + width: 2, + height: 2, + expect_pasp: true, + expect_colr: true, + }, + RawVideoTestCase { + label: "rawvideo-uyvl", + spfmt: "uyvl", + pixel_format: MuxRawVideoPixelFormat::Uyvy422p10, + width: 2, + height: 2, + expect_pasp: true, + expect_colr: true, + }, + RawVideoTestCase { + label: "rawvideo-vyul", + spfmt: "vyul", + pixel_format: MuxRawVideoPixelFormat::Vyuy422p10, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-yuyl", + spfmt: "yuyl", + pixel_format: MuxRawVideoPixelFormat::Yuyv422p10, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-yvyl", + spfmt: "yvyl", + pixel_format: MuxRawVideoPixelFormat::Yvyu422p10, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-yuv444p", + spfmt: "yv4p", + pixel_format: MuxRawVideoPixelFormat::Yuv444Packed8, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-v308", + spfmt: "v308", + pixel_format: MuxRawVideoPixelFormat::Vyu444Packed8, + width: 2, + height: 2, + expect_pasp: true, + expect_colr: true, + }, + RawVideoTestCase { + label: "rawvideo-yuv444ap", + spfmt: "y4ap", + pixel_format: MuxRawVideoPixelFormat::Yuva444Packed8, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-v408", + spfmt: "v408", + pixel_format: MuxRawVideoPixelFormat::Uyva444Packed8, + width: 2, + height: 2, + expect_pasp: true, + expect_colr: true, + }, + RawVideoTestCase { + label: "rawvideo-v410", + spfmt: "v410", + pixel_format: MuxRawVideoPixelFormat::Yuv444Packed10, + width: 2, + height: 2, + expect_pasp: true, + expect_colr: true, + }, + RawVideoTestCase { + label: "rawvideo-v210", + spfmt: "v210", + pixel_format: MuxRawVideoPixelFormat::V210, + width: 48, + height: 2, + expect_pasp: true, + expect_colr: true, + }, + RawVideoTestCase { + label: "rawvideo-grey", + spfmt: "grey", + pixel_format: MuxRawVideoPixelFormat::Grey8, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-algr", + spfmt: "algr", + pixel_format: MuxRawVideoPixelFormat::AlphaGrey8, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-gral", + spfmt: "gral", + pixel_format: MuxRawVideoPixelFormat::GreyAlpha8, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-rgb8", + spfmt: "rgb8", + pixel_format: MuxRawVideoPixelFormat::Rgb332, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-rgb4", + spfmt: "rgb4", + pixel_format: MuxRawVideoPixelFormat::Rgb444, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-rgb5", + spfmt: "rgb5", + pixel_format: MuxRawVideoPixelFormat::Rgb555, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-rgb6", + spfmt: "rgb6", + pixel_format: MuxRawVideoPixelFormat::Rgb565, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-rgb", + spfmt: "rgb", + pixel_format: MuxRawVideoPixelFormat::Rgb24, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-bgr", + spfmt: "bgr", + pixel_format: MuxRawVideoPixelFormat::Bgr24, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-rgbx", + spfmt: "rgbx", + pixel_format: MuxRawVideoPixelFormat::Rgbx32, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-bgrx", + spfmt: "bgrx", + pixel_format: MuxRawVideoPixelFormat::Bgrx32, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-xrgb", + spfmt: "xrgb", + pixel_format: MuxRawVideoPixelFormat::Xrgb32, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-xbgr", + spfmt: "xbgr", + pixel_format: MuxRawVideoPixelFormat::Xbgr32, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-argb", + spfmt: "argb", + pixel_format: MuxRawVideoPixelFormat::Argb32, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-rgba", + spfmt: "rgba", + pixel_format: MuxRawVideoPixelFormat::Rgba32, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-bgra", + spfmt: "bgra", + pixel_format: MuxRawVideoPixelFormat::Bgra32, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-abgr", + spfmt: "abgr", + pixel_format: MuxRawVideoPixelFormat::Abgr32, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-rgbd", + spfmt: "rgbd", + pixel_format: MuxRawVideoPixelFormat::Rgbd32, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + RawVideoTestCase { + label: "rawvideo-rgbds", + spfmt: "rgbds", + pixel_format: MuxRawVideoPixelFormat::Rgbds32, + width: 2, + height: 2, + expect_pasp: false, + expect_colr: false, + }, + ] +} + +fn build_test_raw_video_input_bytes(case: RawVideoTestCase, frame_count: usize) -> Vec { + let frame_payload = + build_test_raw_video_frame_payload(case.pixel_format, case.width, case.height); + build_test_raw_video_bytes(&frame_payload, frame_count) +} + +fn build_test_raw_video_frame_payload( + pixel_format: MuxRawVideoPixelFormat, + width: u32, + height: u32, +) -> Vec { + let size = usize::try_from(test_raw_video_frame_size(pixel_format, width, height)).unwrap(); + (0..size) + .map(|index| u8::try_from((index % 251) + 1).unwrap()) + .collect() +} + +fn test_raw_video_frame_size(pixel_format: MuxRawVideoPixelFormat, width: u32, height: u32) -> u64 { + let width = u64::from(width); + let height = u64::from(height); + let luma = width * height; + match pixel_format { + MuxRawVideoPixelFormat::Grey8 | MuxRawVideoPixelFormat::Rgb332 => luma, + MuxRawVideoPixelFormat::AlphaGrey8 + | MuxRawVideoPixelFormat::GreyAlpha8 + | MuxRawVideoPixelFormat::Rgb444 + | MuxRawVideoPixelFormat::Rgb555 + | MuxRawVideoPixelFormat::Rgb565 + | MuxRawVideoPixelFormat::Uyvy422p8 + | MuxRawVideoPixelFormat::Vyuy422p8 + | MuxRawVideoPixelFormat::Yuyv422p8 + | MuxRawVideoPixelFormat::Yvyu422p8 => luma * 2, + MuxRawVideoPixelFormat::Rgb24 + | MuxRawVideoPixelFormat::Bgr24 + | MuxRawVideoPixelFormat::Yuv444p8 + | MuxRawVideoPixelFormat::Yuv444Packed8 + | MuxRawVideoPixelFormat::Vyu444Packed8 => luma * 3, + MuxRawVideoPixelFormat::Rgbx32 + | MuxRawVideoPixelFormat::Bgrx32 + | MuxRawVideoPixelFormat::Xrgb32 + | MuxRawVideoPixelFormat::Xbgr32 + | MuxRawVideoPixelFormat::Argb32 + | MuxRawVideoPixelFormat::Rgba32 + | MuxRawVideoPixelFormat::Bgra32 + | MuxRawVideoPixelFormat::Abgr32 + | MuxRawVideoPixelFormat::Rgbd32 + | MuxRawVideoPixelFormat::Rgbds32 + | MuxRawVideoPixelFormat::Yuva444p8 + | MuxRawVideoPixelFormat::Yuva444Packed8 + | MuxRawVideoPixelFormat::Uyva444Packed8 + | MuxRawVideoPixelFormat::Yuv444Packed10 + | MuxRawVideoPixelFormat::Uyvy422p10 + | MuxRawVideoPixelFormat::Vyuy422p10 + | MuxRawVideoPixelFormat::Yuyv422p10 + | MuxRawVideoPixelFormat::Yvyu422p10 => luma * 4, + MuxRawVideoPixelFormat::Yuv420p8 | MuxRawVideoPixelFormat::Yvu420p8 => { + let uv_height = height.div_ceil(2); + let stride_uv = width.div_ceil(2); + luma + stride_uv * uv_height * 2 + } + MuxRawVideoPixelFormat::Yuva420p8 | MuxRawVideoPixelFormat::Yuvd420p8 => { + let uv_height = height.div_ceil(2); + let stride_uv = width.div_ceil(2); + (2 * luma) + stride_uv * uv_height * 2 + } + MuxRawVideoPixelFormat::Yuv420p10 => { + let stride = width * 2; + let uv_height = height.div_ceil(2); + let stride_uv = stride.div_ceil(2); + stride * height + stride_uv * uv_height * 2 + } + MuxRawVideoPixelFormat::Yuv422p8 => { + let stride_uv = width.div_ceil(2); + luma + stride_uv * height * 2 + } + MuxRawVideoPixelFormat::Yuv422p10 => { + let stride = width * 2; + let stride_uv = stride.div_ceil(2); + stride * height + stride_uv * height * 2 + } + MuxRawVideoPixelFormat::Yuv444p10 => (width * 2) * height * 3, + MuxRawVideoPixelFormat::Nv12p8 | MuxRawVideoPixelFormat::Nv21p8 => (3 * width * height) / 2, + MuxRawVideoPixelFormat::Nv12p10 | MuxRawVideoPixelFormat::Nv21p10 => { + (3 * (width * 2) * height) / 2 + } + MuxRawVideoPixelFormat::V210 => { + let mut padded_width = width; + while !padded_width.is_multiple_of(48) { + padded_width += 1; + } + (padded_width * 16 / 6) * height + } + } +} + +fn build_test_raw_video_bytes(frame_payload: &[u8], frame_count: usize) -> Vec { + let mut bytes = Vec::with_capacity(frame_payload.len() * frame_count); + for _ in 0..frame_count { + bytes.extend_from_slice(frame_payload); + } + bytes +} + +fn build_test_j2k_codestream_bytes(width: u32, height: u32, tail: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(16 + tail.len()); + bytes.extend_from_slice(&0xFF4F_FF51_u32.to_be_bytes()); + bytes.extend_from_slice(&0_u32.to_be_bytes()); + bytes.extend_from_slice(&width.to_be_bytes()); + bytes.extend_from_slice(&height.to_be_bytes()); + bytes.extend_from_slice(tail); + bytes +} + +fn build_test_jp2_bytes(width: u32, height: u32) -> Vec { + let mut ihdr_payload = Vec::with_capacity(14); + ihdr_payload.extend_from_slice(&height.to_be_bytes()); + ihdr_payload.extend_from_slice(&width.to_be_bytes()); + ihdr_payload.extend_from_slice(&1_u16.to_be_bytes()); + ihdr_payload.push(7); + ihdr_payload.push(7); + ihdr_payload.push(0); + ihdr_payload.push(0); + + let mut ihdr_box = Vec::with_capacity(8 + ihdr_payload.len()); + ihdr_box.extend_from_slice(&(u32::try_from(8 + ihdr_payload.len()).unwrap()).to_be_bytes()); + ihdr_box.extend_from_slice(b"ihdr"); + ihdr_box.extend_from_slice(&ihdr_payload); + + let mut jp2h_box = Vec::with_capacity(8 + ihdr_box.len()); + jp2h_box.extend_from_slice(&(u32::try_from(8 + ihdr_box.len()).unwrap()).to_be_bytes()); + jp2h_box.extend_from_slice(b"jp2h"); + jp2h_box.extend_from_slice(&ihdr_box); + + let codestream = build_test_j2k_codestream_bytes(width, height, b"jp2"); + let mut jp2c_box = Vec::with_capacity(8 + codestream.len()); + jp2c_box.extend_from_slice(&(u32::try_from(8 + codestream.len()).unwrap()).to_be_bytes()); + jp2c_box.extend_from_slice(b"jp2c"); + jp2c_box.extend_from_slice(&codestream); + + let mut bytes = Vec::with_capacity(12 + jp2h_box.len() + jp2c_box.len()); + bytes.extend_from_slice(&12_u32.to_be_bytes()); + bytes.extend_from_slice(b"jP "); + bytes.extend_from_slice(&0x0D0A_870A_u32.to_be_bytes()); + bytes.extend_from_slice(&jp2h_box); + bytes.extend_from_slice(&jp2c_box); + bytes +} + +fn build_test_prores_frame_bytes(width: u16, height: u16, chroma_format: u8) -> Vec { + let mut bytes = vec![0_u8; 28]; + bytes[0..4].copy_from_slice(&28_u32.to_be_bytes()); + bytes[4..8].copy_from_slice(b"icpf"); + bytes[8..10].copy_from_slice(&20_u16.to_be_bytes()); + bytes[16..18].copy_from_slice(&width.to_be_bytes()); + bytes[18..20].copy_from_slice(&height.to_be_bytes()); + bytes[20] = chroma_format << 6; + bytes[22] = 1; + bytes[23] = 1; + bytes[24] = 1; + bytes +} + +fn build_imported_track_input_file( + prefix: &str, + file_config: &MuxFileConfig, + track_config: &MuxTrackConfig, + movie_duration: u32, + samples: &[TestMuxSample<'_>], +) -> TestTempPath { + build_imported_track_input_file_with_edit_media_time( + prefix, + file_config, + track_config, + movie_duration, + 0, + samples, + ) +} + +fn build_imported_track_input_file_with_edit_media_time( + prefix: &str, + file_config: &MuxFileConfig, + track_config: &MuxTrackConfig, + movie_duration: u32, + edit_media_time: u32, + samples: &[TestMuxSample<'_>], +) -> TestTempPath { + let edit_entries = (edit_media_time != 0).then(|| { + vec![ElstEntry { + segment_duration_v0: 0, + media_time_v0: i32::try_from(edit_media_time).unwrap(), + media_rate_integer: 1, + ..ElstEntry::default() + }] + }); + build_imported_track_input_file_with_edit_entries( + prefix, + file_config, + track_config, + movie_duration, + edit_entries.as_deref(), + samples, + ) +} + +fn build_imported_track_input_file_with_edit_entries( + prefix: &str, + file_config: &MuxFileConfig, + track_config: &MuxTrackConfig, + movie_duration: u32, + edit_entries: Option<&[ElstEntry]>, + samples: &[TestMuxSample<'_>], +) -> TestTempPath { + let ftyp = Ftyp { + major_brand: file_config.major_brand(), + minor_version: file_config.minor_version(), + compatible_brands: file_config.compatible_brands().to_vec(), + }; + let ftyp_bytes = encode_supported_box(&ftyp, &[]); + + let payload = samples + .iter() + .flat_map(|sample| sample.bytes) + .copied() + .collect::>(); + let provisional_moov = build_imported_track_moov_bytes( + file_config, + track_config, + movie_duration, + edit_entries, + samples, + &[], + ); + let mdat_header = BoxInfo::new(fourcc("mdat"), 8 + payload.len() as u64).encode(); + let chunk_offsets = if samples.is_empty() { + Vec::new() + } else { + vec![u64::try_from(ftyp_bytes.len() + provisional_moov.len() + mdat_header.len()).unwrap()] + }; + let moov_bytes = build_imported_track_moov_bytes( + file_config, + track_config, + movie_duration, + edit_entries, + samples, + &chunk_offsets, + ); + + let bytes = [ftyp_bytes, moov_bytes, mdat_header, payload].concat(); + write_temp_file(prefix, &bytes) +} + +fn build_multi_description_audio_input_file(prefix: &str) -> TestTempPath { + let file_config = MuxFileConfig::new(1_000) + .with_major_brand(fourcc("isom")) + .with_compatible_brand(fourcc("mp42")); + let track_config = MuxTrackConfig::new_audio(1, 1_000, audio_sample_entry_box()); + let samples = [ + TestMuxSample { + bytes: b"a1", + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }, + TestMuxSample { + bytes: b"a2", + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }, + ]; + let ftyp = Ftyp { + major_brand: file_config.major_brand(), + minor_version: file_config.minor_version(), + compatible_brands: file_config.compatible_brands().to_vec(), + }; + let ftyp_bytes = encode_supported_box(&ftyp, &[]); + let payload = samples + .iter() + .flat_map(|sample| sample.bytes) + .copied() + .collect::>(); + let provisional_moov = + build_multi_description_audio_moov_bytes(&file_config, &track_config, &samples, &[]); + let mdat_header = BoxInfo::new(fourcc("mdat"), 8 + payload.len() as u64).encode(); + let chunk_offsets = + vec![u64::try_from(ftyp_bytes.len() + provisional_moov.len() + mdat_header.len()).unwrap()]; + let moov_bytes = build_multi_description_audio_moov_bytes( + &file_config, + &track_config, + &samples, + &chunk_offsets, + ); + + write_temp_file( + prefix, + &[ftyp_bytes, moov_bytes, mdat_header, payload].concat(), + ) +} + +fn build_multi_description_audio_moov_bytes( + file_config: &MuxFileConfig, + track_config: &MuxTrackConfig, + samples: &[TestMuxSample<'_>], + chunk_offsets: &[u64], +) -> Vec { + let media_duration = samples + .iter() + .map(|sample| sample.duration) + .fold(0_u32, u32::saturating_add); + + let mut mvhd = Mvhd::default(); + mvhd.timescale = file_config.movie_timescale(); + mvhd.duration_v0 = media_duration; + mvhd.rate = 1 << 16; + mvhd.volume = 1 << 8; + mvhd.next_track_id = 2; + let mvhd_bytes = encode_supported_box(&mvhd, &[]); + + let mut tkhd = Tkhd::default(); + tkhd.track_id = track_config.track_id(); + tkhd.duration_v0 = media_duration; + tkhd.volume = track_config.volume(); + let tkhd_bytes = encode_supported_box(&tkhd, &[]); + + let mut mdhd = Mdhd::default(); + mdhd.timescale = track_config.timescale(); + mdhd.duration_v0 = media_duration; + mdhd.language = encode_mdhd_language(track_config.language()); + let mdhd_bytes = encode_supported_box(&mdhd, &[]); + + let mut hdlr = Hdlr::default(); + hdlr.handler_type = fourcc("soun"); + hdlr.name = track_config.handler_name().to_string(); + let hdlr_bytes = encode_supported_box(&hdlr, &[]); + + let mut url = Url::default(); + url.set_flags(1); + let mut dref = Dref::default(); + dref.entry_count = 1; + let dref_bytes = encode_supported_box(&dref, &encode_supported_box(&url, &[])); + let dinf_bytes = encode_supported_box(&Dinf, &dref_bytes); + + let mut stsd = Stsd::default(); + stsd.entry_count = 2; + let stsd_bytes = encode_supported_box( + &stsd, + &[ + track_config.sample_entry_box().to_vec(), + track_config.sample_entry_box().to_vec(), + ] + .concat(), + ); + + let mut stts = Stts::default(); + stts.entry_count = 1; + stts.entries = vec![SttsEntry { + sample_count: u32::try_from(samples.len()).unwrap(), + sample_delta: 10, + }]; + let stts_bytes = encode_supported_box(&stts, &[]); + + let mut stsc = Stsc::default(); + stsc.entry_count = 1; + stsc.entries = vec![StscEntry { + first_chunk: 1, + samples_per_chunk: u32::try_from(samples.len()).unwrap(), + sample_description_index: 2, + }]; + let stsc_bytes = encode_supported_box(&stsc, &[]); + + let mut stsz = Stsz::default(); + stsz.sample_count = u32::try_from(samples.len()).unwrap(); + stsz.entry_size = samples + .iter() + .map(|sample| u64::try_from(sample.bytes.len()).unwrap()) + .collect(); + let stsz_bytes = encode_supported_box(&stsz, &[]); + + let mut co64 = Co64::default(); + co64.entry_count = u32::try_from(chunk_offsets.len()).unwrap(); + co64.chunk_offset = chunk_offsets.to_vec(); + let co64_bytes = encode_supported_box(&co64, &[]); + + let stbl_bytes = encode_supported_box( + &Stbl, + &[stsd_bytes, stts_bytes, stsc_bytes, stsz_bytes, co64_bytes].concat(), + ); + let minf_bytes = encode_supported_box( + &Minf, + &[ + encode_supported_box(&Smhd::default(), &[]), + dinf_bytes, + stbl_bytes, + ] + .concat(), + ); + let mdia_bytes = encode_supported_box(&Mdia, &[mdhd_bytes, hdlr_bytes, minf_bytes].concat()); + let trak_bytes = encode_supported_box(&Trak, &[tkhd_bytes, mdia_bytes].concat()); + encode_supported_box(&Moov, &[mvhd_bytes, trak_bytes].concat()) +} + +fn build_audio_input_file_with_type( + prefix: &str, + major_brand: mp4forge::FourCc, + sample_entry_type: &str, + payloads: &[&[u8]], +) -> TestTempPath { + let samples = payloads + .iter() + .copied() + .map(|bytes| TestMuxSample { + bytes, + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }) + .collect::>(); + write_single_track_mp4_input( + prefix, + &MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")), + MuxTrackConfig::new_audio( + 1, + 1_000, + audio_sample_entry_box_with_type(sample_entry_type), + ), + &samples, + ) +} + +fn build_video_input_file( + prefix: &str, + major_brand: mp4forge::FourCc, + payloads: &[&[u8]], +) -> TestTempPath { + build_video_input_file_with_type(prefix, major_brand, "avc1", payloads) +} + +fn build_video_input_file_with_metadata( + prefix: &str, + major_brand: mp4forge::FourCc, + sample_entry_type: &str, + language: [u8; 3], + handler_name: &str, + payloads: &[&[u8]], +) -> TestTempPath { + let samples = payloads + .iter() + .copied() + .map(|bytes| TestMuxSample { + bytes, + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }) + .collect::>(); + write_single_track_mp4_input( + prefix, + &MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")), + MuxTrackConfig::new_video( + 1, + 1_000, + 640, + 360, + video_sample_entry_box_with_type(sample_entry_type), + ) + .with_language(language) + .with_handler_name(handler_name), + &samples, + ) +} + +fn build_video_input_file_with_type( + prefix: &str, + major_brand: mp4forge::FourCc, + sample_entry_type: &str, + payloads: &[&[u8]], +) -> TestTempPath { + let samples = payloads + .iter() + .copied() + .map(|bytes| TestMuxSample { + bytes, + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }) + .collect::>(); + write_single_track_mp4_input( + prefix, + &MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")), + MuxTrackConfig::new_video( + 1, + 1_000, + 640, + 360, + video_sample_entry_box_with_type(sample_entry_type), + ), + &samples, + ) +} + +fn build_imported_track_moov_bytes( + file_config: &MuxFileConfig, + track_config: &MuxTrackConfig, + movie_duration: u32, + edit_entries: Option<&[ElstEntry]>, + samples: &[TestMuxSample<'_>], + chunk_offsets: &[u64], +) -> Vec { + let mut mvhd = Mvhd::default(); + mvhd.timescale = file_config.movie_timescale(); + mvhd.duration_v0 = movie_duration; + mvhd.rate = 1 << 16; + mvhd.volume = 1 << 8; + mvhd.next_track_id = track_config.track_id() + 1; + let mvhd_bytes = encode_supported_box(&mvhd, &[]); + + let media_duration = samples + .iter() + .map(|sample| sample.duration) + .fold(0_u32, u32::saturating_add); + + let mut tkhd = Tkhd::default(); + tkhd.track_id = track_config.track_id(); + tkhd.duration_v0 = movie_duration; + tkhd.volume = track_config.volume(); + tkhd.width = u32::from(track_config.track_width()) << 16; + tkhd.height = u32::from(track_config.track_height()) << 16; + let tkhd_bytes = encode_supported_box(&tkhd, &[]); + + let mut mdhd = Mdhd::default(); + mdhd.timescale = track_config.timescale(); + mdhd.duration_v0 = media_duration; + mdhd.language = encode_mdhd_language(track_config.language()); + let mdhd_bytes = encode_supported_box(&mdhd, &[]); + + let mut hdlr = Hdlr::default(); + hdlr.handler_type = match track_config.kind() { + MuxTrackKind::Audio => fourcc("soun"), + MuxTrackKind::Video => fourcc("vide"), + MuxTrackKind::Text => fourcc("text"), + MuxTrackKind::Subtitle => fourcc("subt"), + }; + hdlr.name = track_config.handler_name().to_string(); + let hdlr_bytes = encode_supported_box(&hdlr, &[]); + + let media_header = match track_config.kind() { + MuxTrackKind::Audio => encode_supported_box(&Smhd::default(), &[]), + MuxTrackKind::Video => { + let mut vmhd = Vmhd::default(); + vmhd.set_flags(1); + encode_supported_box(&vmhd, &[]) + } + MuxTrackKind::Text => encode_supported_box(&Nmhd::default(), &[]), + MuxTrackKind::Subtitle => encode_supported_box(&Sthd::default(), &[]), + }; + + let mut url = Url::default(); + url.set_flags(1); + let mut dref = Dref::default(); + dref.entry_count = 1; + let dref_bytes = encode_supported_box(&dref, &encode_supported_box(&url, &[])); + let dinf_bytes = encode_supported_box(&Dinf, &dref_bytes); + + let mut stsd = Stsd::default(); + stsd.entry_count = 1; + let stsd_bytes = encode_supported_box(&stsd, track_config.sample_entry_box()); + + let mut stts = Stts::default(); + let mut stts_entries = Vec::::new(); + for sample in samples { + if let Some(last) = stts_entries.last_mut() + && last.sample_delta == sample.duration + { + last.sample_count += 1; + } else { + stts_entries.push(SttsEntry { + sample_count: 1, + sample_delta: sample.duration, + }); + } + } + stts.entry_count = u32::try_from(stts_entries.len()).unwrap(); + stts.entries = stts_entries; + let stts_bytes = encode_supported_box(&stts, &[]); + + let ctts_bytes = if samples + .iter() + .any(|sample| sample.composition_time_offset != 0) + { + let mut ctts = Ctts::default(); + let mut ctts_entries = Vec::::new(); + for sample in samples { + let sample_offset = u32::try_from(sample.composition_time_offset).unwrap(); + if let Some(last) = ctts_entries.last_mut() + && last.sample_offset_v0 == sample_offset + { + last.sample_count += 1; + } else { + ctts_entries.push(mp4forge::boxes::iso14496_12::CttsEntry { + sample_count: 1, + sample_offset_v0: sample_offset, + ..mp4forge::boxes::iso14496_12::CttsEntry::default() + }); + } + } + ctts.entry_count = u32::try_from(ctts_entries.len()).unwrap(); + ctts.entries = ctts_entries; + Some(encode_supported_box(&ctts, &[])) + } else { + None + }; + + let mut stsc = Stsc::default(); + if !samples.is_empty() { + stsc.entry_count = 1; + stsc.entries = vec![StscEntry { + first_chunk: 1, + samples_per_chunk: u32::try_from(samples.len()).unwrap(), + sample_description_index: 1, + }]; + } + let stsc_bytes = encode_supported_box(&stsc, &[]); + + let mut stsz = Stsz::default(); + stsz.sample_count = u32::try_from(samples.len()).unwrap(); + stsz.entry_size = samples + .iter() + .map(|sample| u64::try_from(sample.bytes.len()).unwrap()) + .collect(); + let stsz_bytes = encode_supported_box(&stsz, &[]); + + let mut co64 = Co64::default(); + co64.entry_count = u32::try_from(chunk_offsets.len()).unwrap(); + co64.chunk_offset = chunk_offsets.to_vec(); + let co64_bytes = encode_supported_box(&co64, &[]); + + let mut stbl_children = vec![stsd_bytes, stts_bytes]; + if let Some(ctts_bytes) = ctts_bytes { + stbl_children.push(ctts_bytes); + } + if samples.iter().any(|sample| !sample.is_sync_sample) { + let mut stss = Stss::default(); + stss.sample_number = samples + .iter() + .enumerate() + .filter(|(_, sample)| sample.is_sync_sample) + .map(|(index, _)| u64::try_from(index + 1).unwrap()) + .collect(); + stss.entry_count = u32::try_from(stss.sample_number.len()).unwrap(); + stbl_children.push(encode_supported_box(&stss, &[])); + } + stbl_children.extend([stsc_bytes, stsz_bytes, co64_bytes]); + + let stbl_bytes = encode_supported_box(&Stbl, &stbl_children.concat()); + let minf_bytes = encode_supported_box(&Minf, &[media_header, dinf_bytes, stbl_bytes].concat()); + let mdia_bytes = encode_supported_box(&Mdia, &[mdhd_bytes, hdlr_bytes, minf_bytes].concat()); + let edts_bytes = edit_entries.map(|entries| { + let mut elst = Elst::default(); + elst.entry_count = u32::try_from(entries.len()).unwrap(); + elst.entries = entries.to_vec(); + encode_supported_box(&Edts, &encode_supported_box(&elst, &[])) + }); + let mut trak_children = vec![tkhd_bytes]; + if let Some(edts_bytes) = edts_bytes { + trak_children.push(edts_bytes); + } + trak_children.push(mdia_bytes); + let trak_bytes = encode_supported_box(&Trak, &trak_children.concat()); + encode_supported_box(&Moov, &[mvhd_bytes, trak_bytes].concat()) +} + +fn build_external_reference_audio_input_file( + prefix: &str, + referenced_media: &[u8], + sample_sizes: &[u32], +) -> (TestTempPath, TestTempPath) { + let reference_path = + write_temp_file_with_extension(&format!("{prefix}-media"), "bin", referenced_media); + let reference_name = reference_path + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(); + let file_config = MuxFileConfig::new(1_000) + .with_major_brand(fourcc("isom")) + .with_compatible_brand(fourcc("mp42")); + let mut mvhd = Mvhd::default(); + mvhd.timescale = file_config.movie_timescale(); + mvhd.duration_v0 = sample_sizes.len() as u32 * 10; + mvhd.rate = 1 << 16; + mvhd.volume = 1 << 8; + mvhd.next_track_id = 2; + let mvhd_bytes = encode_supported_box(&mvhd, &[]); + + let mut tkhd = Tkhd::default(); + tkhd.track_id = 1; + tkhd.duration_v0 = mvhd.duration_v0; + tkhd.volume = 1 << 8; + let tkhd_bytes = encode_supported_box(&tkhd, &[]); + + let mut mdhd = Mdhd::default(); + mdhd.timescale = 1_000; + mdhd.duration_v0 = mvhd.duration_v0; + mdhd.language = encode_mdhd_language(*b"und"); + let mdhd_bytes = encode_supported_box(&mdhd, &[]); + + let mut hdlr = Hdlr::default(); + hdlr.handler_type = fourcc("soun"); + hdlr.name = "SoundHandler".to_string(); + let hdlr_bytes = encode_supported_box(&hdlr, &[]); + + let mut url = Url::default(); + url.location = reference_name; + let mut dref = Dref::default(); + dref.entry_count = 1; + let dref_bytes = encode_supported_box(&dref, &encode_supported_box(&url, &[])); + let dinf_bytes = encode_supported_box(&Dinf, &dref_bytes); + + let mut stsd = Stsd::default(); + stsd.entry_count = 1; + let stsd_bytes = encode_supported_box(&stsd, &audio_sample_entry_box()); + + let mut stts = Stts::default(); + stts.entry_count = 1; + stts.entries = vec![SttsEntry { + sample_count: u32::try_from(sample_sizes.len()).unwrap(), + sample_delta: 10, + }]; + let stts_bytes = encode_supported_box(&stts, &[]); + + let mut stsc = Stsc::default(); + stsc.entry_count = 1; + stsc.entries = vec![StscEntry { + first_chunk: 1, + samples_per_chunk: u32::try_from(sample_sizes.len()).unwrap(), + sample_description_index: 1, + }]; + let stsc_bytes = encode_supported_box(&stsc, &[]); + + let mut stsz = Stsz::default(); + stsz.sample_count = u32::try_from(sample_sizes.len()).unwrap(); + stsz.entry_size = sample_sizes.iter().copied().map(u64::from).collect(); + let stsz_bytes = encode_supported_box(&stsz, &[]); + + let mut co64 = Co64::default(); + co64.entry_count = 1; + co64.chunk_offset = vec![2]; + let co64_bytes = encode_supported_box(&co64, &[]); + + let stbl_bytes = encode_supported_box( + &Stbl, + &[stsd_bytes, stts_bytes, stsc_bytes, stsz_bytes, co64_bytes].concat(), + ); + let minf_bytes = encode_supported_box( + &Minf, + &[ + encode_supported_box(&Smhd::default(), &[]), + dinf_bytes, + stbl_bytes, + ] + .concat(), + ); + let mdia_bytes = encode_supported_box(&Mdia, &[mdhd_bytes, hdlr_bytes, minf_bytes].concat()); + let trak_bytes = encode_supported_box(&Trak, &[tkhd_bytes, mdia_bytes].concat()); + let moov_bytes = encode_supported_box(&Moov, &[mvhd_bytes, trak_bytes].concat()); + let ftyp_bytes = encode_supported_box( + &Ftyp { + major_brand: fourcc("isom"), + minor_version: 0, + compatible_brands: vec![fourcc("isom"), fourcc("mp42")], + }, + &[], + ); + let input_path = write_temp_file(prefix, &[ftyp_bytes, moov_bytes].concat()); + (input_path, reference_path) +} + +fn audio_sample_entry_box() -> Vec { + audio_sample_entry_box_with_type("mp4a") +} + +fn audio_sample_entry_box_with_type(box_type: &str) -> Vec { + audio_sample_entry_box_with_children(box_type, &[]) +} + +fn audio_sample_entry_box_with_children(box_type: &str, children: &[u8]) -> Vec { + encode_supported_box( + &AudioSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc(box_type), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000_u32 << 16, + ..AudioSampleEntry::default() + }, + children, + ) +} + +fn video_sample_entry_box() -> Vec { + video_sample_entry_box_with_type("avc1") +} + +fn video_sample_entry_box_with_type(box_type: &str) -> Vec { + video_sample_entry_box_with_children(box_type, &[]) +} + +fn video_sample_entry_box_with_children(box_type: &str, children: &[u8]) -> Vec { + encode_supported_box( + &VisualSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc(box_type), + data_reference_index: 1, + }, + width: 640, + height: 360, + horizresolution: 72_u32 << 16, + vertresolution: 72_u32 << 16, + frame_count: 1, + depth: 0x0018, + pre_defined3: -1, + ..VisualSampleEntry::default() + }, + children, + ) +} + +fn build_wvtt_input_file( + prefix: &str, + major_brand: mp4forge::FourCc, + payloads: &[&[u8]], +) -> TestTempPath { + let samples = payloads + .iter() + .copied() + .map(|bytes| TestMuxSample { + bytes, + duration: 10, + composition_time_offset: 0, + is_sync_sample: true, + }) + .collect::>(); + write_single_track_mp4_input( + prefix, + &MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")), + MuxTrackConfig::new_text(1, 1_000, 0, 0, wvtt_sample_entry_box()), + &samples, + ) +} + +fn build_mixed_text_input_file(prefix: &str, major_brand: mp4forge::FourCc) -> TestTempPath { + let first_source = write_temp_file(&format!("{prefix}-source-text"), b"wvtt"); + let second_source = write_temp_file(&format!("{prefix}-source-subtitle"), b"stpp"); + let output_path = write_temp_file(prefix, &[]); + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 10, 0, 4).with_sync_sample(true), + MuxStagedMediaItem::new(1, 2, 0, 10, 0, 4).with_sync_sample(true), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + let file_config = MuxFileConfig::new(1_000) + .with_major_brand(major_brand) + .with_compatible_brand(fourcc("mp42")); + let track_configs = vec![ + MuxTrackConfig::new_text(1, 1_000, 0, 0, wvtt_sample_entry_box()) + .with_language(*b"eng") + .with_handler_name("EnglishCaptionHandler"), + MuxTrackConfig::new_subtitle(2, 1_000, 0, 0, stpp_sample_entry_box()) + .with_language(*b"fra") + .with_handler_name("FrenchSubtitleHandler"), + ]; + + write_mp4_mux_to_path( + &[&first_source, &second_source], + &output_path, + &file_config, + &track_configs, + &plan, + ) + .unwrap(); + output_path +} + +fn decode_mdhd_language(encoded: [u8; 3]) -> [u8; 3] { + [encoded[0] + b'`', encoded[1] + b'`', encoded[2] + b'`'] +} + +fn encode_mdhd_language(language: [u8; 3]) -> [u8; 3] { + [language[0] - b'`', language[1] - b'`', language[2] - b'`'] +} + +fn wvtt_sample_entry_box() -> Vec { + let children = [ + encode_supported_box( + &WebVTTConfigurationBox { + config: "WEBVTT".to_string(), + }, + &[], + ), + encode_supported_box( + &WebVTTSourceLabelBox { + source_label: "source_label".to_string(), + }, + &[], + ), + ] + .concat(); + encode_supported_box( + &WVTTSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("wvtt"), + data_reference_index: 1, + }, + }, + &children, + ) +} + +fn stpp_sample_entry_box() -> Vec { + encode_supported_box( + &XMLSubtitleSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("stpp"), + data_reference_index: 1, + }, + namespace: "http://www.w3.org/ns/ttml".to_string(), + schema_location: String::new(), + auxiliary_mime_types: String::new(), + }, + &[], + ) +} + +fn read_root_boxes(bytes: &[u8]) -> Vec { + let mut reader = Cursor::new(bytes); + let mut root_boxes = Vec::new(); + while usize::try_from(reader.position()) + .ok() + .is_some_and(|offset| offset < bytes.len()) + { + let info = BoxInfo::read(&mut reader).unwrap(); + info.seek_to_end(&mut reader).unwrap(); + root_boxes.push(info); + } + root_boxes +} + +fn find_first_box_at_path(bytes: &[u8], path: &[mp4forge::FourCc]) -> Option<(usize, usize)> { + find_first_box_at_path_in(bytes, 0, bytes.len(), path) +} + +fn find_first_box_at_path_in( + bytes: &[u8], + start: usize, + end: usize, + path: &[mp4forge::FourCc], +) -> Option<(usize, usize)> { + let target = path.first().copied()?; + let mut offset = start; + while offset.checked_add(8)? <= end { + let size = u32::from_be_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) as usize; + if size < 8 { + return None; + } + let box_end = offset.checked_add(size)?; + if box_end > end { + return None; + } + let box_type = mp4forge::FourCc::from_bytes([ + bytes[offset + 4], + bytes[offset + 5], + bytes[offset + 6], + bytes[offset + 7], + ]); + if box_type == target { + if path.len() == 1 { + return Some((offset, size)); + } + return find_first_box_at_path_in(bytes, offset + 8, box_end, &path[1..]); + } + offset = box_end; + } + None +} + +fn replace_same_size_box_at_path( + bytes: &[u8], + path: &[mp4forge::FourCc], + replacement: &[u8], +) -> Vec { + let (offset, size) = find_first_box_at_path(bytes, path).unwrap(); + assert_eq!(size, replacement.len()); + let mut patched = bytes.to_vec(); + patched[offset..offset + size].copy_from_slice(replacement); + patched +} + +fn patch_first_stsc_sample_description_index( + bytes: &[u8], + sample_description_index: u32, +) -> Vec { + let (offset, size) = find_first_box_at_path( + bytes, + &[ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ], + ) + .unwrap(); + assert!(size >= 28); + let mut patched = bytes.to_vec(); + patched[offset + 24..offset + 28].copy_from_slice(&sample_description_index.to_be_bytes()); + patched +} + +fn patch_first_tfhd_sample_description_index( + bytes: &[u8], + sample_description_index: u32, +) -> Vec { + let (offset, size) = + find_first_box_at_path(bytes, &[fourcc("moof"), fourcc("traf"), fourcc("tfhd")]).unwrap(); + assert!(size >= 20); + let mut patched = bytes.to_vec(); + patched[offset + 16..offset + 20].copy_from_slice(&sample_description_index.to_be_bytes()); + patched +} + +fn encode_compact_sample_size_box(field_size: u8, sample_sizes: &[u32]) -> Vec { + let mut payload = vec![0, 0, 0, 0, 0, 0, 0, field_size]; + payload.extend_from_slice(&u32::try_from(sample_sizes.len()).unwrap().to_be_bytes()); + match field_size { + 4 => { + for pair in sample_sizes.chunks(2) { + let high = u8::try_from(pair[0]).unwrap(); + assert!(high <= 0x0F); + let low = pair + .get(1) + .map(|size| u8::try_from(*size).unwrap()) + .unwrap_or(0); + assert!(low <= 0x0F); + payload.push((high << 4) | low); + } + } + 8 => { + for size in sample_sizes { + payload.push(u8::try_from(*size).unwrap()); + } + } + 16 => { + for size in sample_sizes { + payload.extend_from_slice(&u16::try_from(*size).unwrap().to_be_bytes()); + } + } + _ => panic!("unsupported compact sample size field width"), + } + encode_raw_box(fourcc("stz2"), &payload) +} + +fn encode_free_padding_box(size: usize) -> Vec { + assert!(size >= 8); + encode_raw_box(fourcc("free"), &vec![0; size - 8]) +} + +fn mdat_payload(bytes: &[u8], mdat: BoxInfo) -> &[u8] { + let start = usize::try_from(mdat.offset() + mdat.header_size()).unwrap(); + let end = usize::try_from(mdat.offset() + mdat.size()).unwrap(); + &bytes[start..end] +} + +fn extract_boxes(bytes: &[u8], path: BoxPath) -> Vec +where + T: mp4forge::codec::CodecBox + Clone + 'static, +{ + let mut reader = Cursor::new(bytes); + extract_box_as::<_, T>(&mut reader, None, path).unwrap() +} diff --git a/tests/mux_diagnostics.rs b/tests/mux_diagnostics.rs new file mode 100644 index 0000000..71bff8d --- /dev/null +++ b/tests/mux_diagnostics.rs @@ -0,0 +1,160 @@ +#![cfg(feature = "mux")] + +mod support; + +use mp4forge::mux::{MuxError, MuxRequest, MuxTrackSpec, mux_to_path}; + +use support::{ + write_temp_file, write_temp_file_with_extension, write_test_avi_audio_tag_file, + write_test_saf_remote_url_file, +}; + +#[test] +fn mux_to_path_rejects_non_core_dts_family_with_actionable_message() { + let dts_input = write_temp_file("mux-diagnostics-dtshd-input", b"DTSHDHDRdemo"); + let output_path = write_temp_file("mux-diagnostics-dtshd-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&dts_input)]); + + let error = mux_to_path(&request, &output_path).unwrap_err(); + let message = error.to_string(); + + assert!(message.contains("non-core DTS-family audio"), "{message}"); + assert!( + message.contains("expose one contiguous core substream"), + "{message}" + ); + assert!( + message.contains("little-endian core DTS sync frames"), + "{message}" + ); + assert!( + message.contains("transformed 14-bit core DTS sync frames"), + "{message}" + ); + assert!( + message + .contains("import this family from an MP4 source with `#audio` or `#track:ID` instead"), + "{message}" + ); +} + +#[test] +fn mux_to_path_rejects_unknown_avi_audio_tags_with_context() { + let avi_input = write_test_avi_audio_tag_file( + "mux-diagnostics-avi-tag-input", + 0x7777, + 8_000, + 1, + 4, + &[b"\x12\x34\x56\x78"], + ); + let output_path = write_temp_file("mux-diagnostics-avi-tag-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&avi_input)]); + + let error = mux_to_path(&request, &output_path).unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains("unsupported WAVE format tag 0x7777"), + "{message}" + ); + assert!(message.contains("channels=1"), "{message}"); + assert!(message.contains("sample_rate=8000"), "{message}"); + assert!(message.contains("bits_per_sample=4"), "{message}"); + assert!(message.contains("currently accepts"), "{message}"); + assert!(message.contains("IBM CVSD"), "{message}"); + assert!(message.contains("OKI ADPCM"), "{message}"); + assert!(message.contains("DIGISTD"), "{message}"); + assert!(message.contains("Yamaha ADPCM"), "{message}"); + assert!(message.contains("DSP TrueSpeech"), "{message}"); + assert!(message.contains("GSM 610"), "{message}"); + assert!(message.contains("IBM ADPCM"), "{message}"); + assert!(message.contains("AAC ADTS"), "{message}"); +} + +#[test] +fn mux_to_path_rejects_saf_remote_url_declarations_with_actionable_message() { + let saf_input = write_test_saf_remote_url_file("mux-diagnostics-saf-remote-input"); + let output_path = write_temp_file("mux-diagnostics-saf-remote-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&saf_input)]); + + let error = mux_to_path(&request, &output_path).unwrap_err(); + let message = error.to_string(); + + assert!( + message + .contains("remote URL declarations are outside the current path-only import contract"), + "{message}" + ); +} + +#[test] +fn mux_to_path_rejects_gsf_serialized_transport_sources_with_actionable_message() { + let gsf_input = + write_temp_file_with_extension("mux-diagnostics-gsf-input", "gsf", b"GS5F\x01demo"); + let output_path = write_temp_file("mux-diagnostics-gsf-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&gsf_input)]); + + let error = mux_to_path(&request, &output_path).unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains("GSF is a serialized multi-PID transport surface"), + "{message}" + ); + assert!( + message.contains("import the authored files or authored MP4 tracks directly instead"), + "{message}" + ); +} + +#[test] +fn mux_to_path_rejects_ghi_segment_index_sources_with_actionable_message() { + let ghi_input = write_temp_file_with_extension("mux-diagnostics-ghi-input", "ghi", b"GHIDdemo"); + let output_path = write_temp_file("mux-diagnostics-ghi-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path(&ghi_input)]); + + let error = mux_to_path(&request, &output_path).unwrap_err(); + let message = error.to_string(); + + assert!( + message.contains("GHI is a segment-index or manifest transport surface"), + "{message}" + ); + assert!( + message.contains("import the authored media files or local MPD inputs directly instead"), + "{message}" + ); +} + +#[test] +fn mux_to_path_reports_missing_input_path_with_context() { + let output_path = write_temp_file("mux-diagnostics-missing-input-output", &[]); + let request = MuxRequest::new(vec![MuxTrackSpec::path("this-file-does-not-exist.bin")]); + + let error = mux_to_path(&request, &output_path).unwrap_err(); + let message = error.to_string(); + + assert!(message.contains("failed to open mux input"), "{message}"); + assert!( + message.contains("this-file-does-not-exist.bin"), + "{message}" + ); +} + +#[test] +fn mux_errors_report_stable_category_and_stage_metadata() { + let request_error = MuxError::InvalidOutputLayout { + layout: "fragmented", + message: "demo".to_string(), + }; + assert_eq!(request_error.category(), "input"); + assert_eq!(request_error.stage(), "request"); + + let unsupported = MuxError::UnsupportedTrackImport { + spec: "demo".to_string(), + message: "not supported".to_string(), + }; + assert_eq!(unsupported.category(), "unsupported"); + assert_eq!(unsupported.stage(), "import"); +} diff --git a/tests/mux_rewrite.rs b/tests/mux_rewrite.rs new file mode 100644 index 0000000..6cfc2e5 --- /dev/null +++ b/tests/mux_rewrite.rs @@ -0,0 +1,282 @@ +#![cfg(feature = "mux")] + +mod support; + +use mp4forge::bitio::BitWriter; +use mp4forge::boxes::iso14496_12::{AVCDecoderConfiguration, HEVCDecoderConfiguration}; +use mp4forge::boxes::iso14496_15::VVCDecoderConfiguration; +use mp4forge::mux::rewrite::{ + AdtsRewriteError, AnnexBRewriteError, Av1AnnexBRewriteError, MhasRewriteError, + rewrite_aac_sample_to_adts, rewrite_av1_sample_to_annex_b, rewrite_avc_sample_to_annex_b, + rewrite_hevc_sample_to_annex_b, rewrite_mhas_samples_to_stream, rewrite_vvc_sample_to_annex_b, +}; +use std::fs; + +use support::{ + build_test_av1_sequence_header_obu, write_test_adts_file, write_test_av1_annex_b_file, + write_test_mhas_file, +}; + +#[test] +fn rewrite_avc_sample_to_annex_b_rewrites_multiple_nalus() { + let avcc = AVCDecoderConfiguration { + length_size_minus_one: 3, + ..Default::default() + }; + let sample = [ + 0x00, 0x00, 0x00, 0x02, 0x65, 0x88, 0x00, 0x00, 0x00, 0x01, 0x06, + ]; + + let rewritten = rewrite_avc_sample_to_annex_b(&sample, &avcc).unwrap(); + + assert_eq!( + rewritten, + vec![ + 0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x00, 0x00, 0x00, 0x01, 0x06 + ] + ); +} + +#[test] +fn rewrite_hevc_sample_to_annex_b_rewrites_multiple_nalus() { + let hvcc = HEVCDecoderConfiguration { + length_size_minus_one: 1, + ..Default::default() + }; + let sample = [0x00, 0x03, 0x26, 0x01, 0xAA, 0x00, 0x02, 0x02, 0x01]; + + let rewritten = rewrite_hevc_sample_to_annex_b(&sample, &hvcc).unwrap(); + + assert_eq!( + rewritten, + vec![ + 0x00, 0x00, 0x00, 0x01, 0x26, 0x01, 0xAA, 0x00, 0x00, 0x00, 0x01, 0x02, 0x01 + ] + ); +} + +#[test] +fn rewrite_vvc_sample_to_annex_b_rewrites_multiple_nalus() { + let vvcc = VVCDecoderConfiguration { + decoder_configuration_record: vec![0xFE], + ..Default::default() + }; + let sample = [ + 0x00, 0x00, 0x00, 0x03, 0x8A, 0x00, 0x55, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, + ]; + + let rewritten = rewrite_vvc_sample_to_annex_b(&sample, &vvcc).unwrap(); + + assert_eq!( + rewritten, + vec![ + 0x00, 0x00, 0x00, 0x01, 0x8A, 0x00, 0x55, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01 + ] + ); +} + +#[test] +fn rewrite_rejects_truncated_nal_payloads() { + let avcc = AVCDecoderConfiguration { + length_size_minus_one: 3, + ..Default::default() + }; + let sample = [0x00, 0x00, 0x00, 0x04, 0x65, 0x88]; + + let error = rewrite_avc_sample_to_annex_b(&sample, &avcc).unwrap_err(); + + assert_eq!( + error, + AnnexBRewriteError::TruncatedNalUnit { + codec: "AVC", + offset: 4, + declared_size: 4, + remaining_size: 2, + } + ); +} + +#[test] +fn rewrite_rejects_invalid_length_field_widths() { + let avcc = AVCDecoderConfiguration { + length_size_minus_one: 4, + ..Default::default() + }; + + let error = rewrite_avc_sample_to_annex_b(&[0x00, 0x01, 0x09], &avcc).unwrap_err(); + + assert_eq!( + error, + AnnexBRewriteError::InvalidLengthFieldWidth { + codec: "AVC", + width: 5, + } + ); +} + +#[test] +fn rewrite_av1_sample_to_annex_b_matches_expected_temporal_unit_shape() { + let mut sample = build_test_av1_sequence_header_obu(640, 480); + sample.extend_from_slice(&[0x32, 0x02, 0x80, 0xAA]); + + let expected_path = + write_test_av1_annex_b_file("rewrite-av1-annex-b-expected", &[sample.as_slice()]); + let expected = fs::read(expected_path).unwrap(); + + let rewritten = rewrite_av1_sample_to_annex_b(&sample).unwrap(); + + assert_eq!(rewritten, expected); +} + +#[test] +fn rewrite_av1_rejects_missing_internal_obu_size_fields() { + let error = rewrite_av1_sample_to_annex_b(&[0x08, 0xAA]).unwrap_err(); + + assert_eq!( + error, + Av1AnnexBRewriteError::MissingObuSizeField { offset: 0 } + ); +} + +#[test] +fn rewrite_aac_sample_to_adts_matches_fixture_header_shape() { + let payload = b"abc"; + let expected_path = write_test_adts_file("rewrite-aac-adts-expected", &[payload.as_slice()]); + let expected = fs::read(expected_path).unwrap(); + + let rewritten = rewrite_aac_sample_to_adts(payload, &[0x12, 0x10]).unwrap(); + + assert_eq!(rewritten, expected); +} + +#[test] +fn rewrite_aac_rejects_unsupported_object_types_for_adts() { + let error = rewrite_aac_sample_to_adts(b"abc", &[0x2A, 0x10]).unwrap_err(); + + assert_eq!( + error, + AdtsRewriteError::UnsupportedAudioObjectType { + audio_object_type: 5, + } + ); +} + +#[test] +fn rewrite_mhas_samples_to_stream_round_trips_valid_packetized_samples() { + let expected_path = write_test_mhas_file("rewrite-mhas-stream-expected", &[b"abc", b"def"]); + let expected = fs::read(expected_path).unwrap(); + + let first_frame = build_test_mhas_frame_packet(b"abc"); + let second_frame = build_test_mhas_frame_packet(b"def"); + let first_sample = [ + build_test_mhas_packet(6, &[0xA5]), + build_test_mhas_packet(1, &build_test_mhas_config_payload()), + first_frame, + ] + .concat(); + let second_sample = second_frame; + + let rewritten = + rewrite_mhas_samples_to_stream(&[first_sample.as_slice(), second_sample.as_slice()]) + .unwrap(); + + assert_eq!(rewritten, expected); +} + +#[test] +fn rewrite_mhas_rejects_samples_without_the_required_leading_sync_packet() { + let sample = build_test_mhas_packet(1, &build_test_mhas_config_payload()); + + let error = rewrite_mhas_samples_to_stream(&[sample.as_slice()]).unwrap_err(); + + assert_eq!(error, MhasRewriteError::MissingLeadingSyncPacket); +} + +#[test] +fn rewrite_mhas_rejects_empty_leading_sync_payload() { + let sample = build_test_mhas_packet(6, &[]); + + let error = rewrite_mhas_samples_to_stream(&[sample.as_slice()]).unwrap_err(); + + assert_eq!( + error, + MhasRewriteError::TruncatedPacketPayload { + sample_index: 0, + offset: 2, + declared_size: 1, + remaining_size: 0, + } + ); +} + +fn build_test_mhas_frame_packet(payload: &[u8]) -> Vec { + let mut frame_payload = Vec::with_capacity(payload.len() + 1); + frame_payload.push(0x80); + frame_payload.extend_from_slice(payload); + build_test_mhas_packet(2, &frame_payload) +} + +fn build_test_mhas_config_payload() -> Vec { + let mut writer = BitWriter::new(Vec::new()); + write_test_bits_u64(&mut writer, 12, 8); + write_test_bits_u64(&mut writer, 3, 5); + write_test_bits_u64(&mut writer, 1, 3); + writer.write_bit(false).unwrap(); + writer.write_bit(false).unwrap(); + write_test_bits_u64(&mut writer, 1, 2); + write_test_mhas_escaped_value(&mut writer, 1, 5, 8, 16); + align_test_bit_writer(&mut writer); + writer.into_inner().unwrap() +} + +fn build_test_mhas_packet(packet_type: u64, payload: &[u8]) -> Vec { + let mut writer = BitWriter::new(Vec::new()); + write_test_mhas_escaped_value(&mut writer, packet_type, 3, 8, 8); + write_test_mhas_escaped_value(&mut writer, 0, 2, 8, 32); + write_test_mhas_escaped_value( + &mut writer, + u64::try_from(payload.len()).unwrap(), + 11, + 24, + 24, + ); + align_test_bit_writer(&mut writer); + let mut packet = writer.into_inner().unwrap(); + packet.extend_from_slice(payload); + packet +} + +fn write_test_mhas_escaped_value( + writer: &mut BitWriter>, + value: u64, + first_width: usize, + second_width: usize, + third_width: usize, +) { + let first_max = (1_u64 << first_width) - 1; + if value < first_max { + write_test_bits_u64(writer, value, first_width); + return; + } + write_test_bits_u64(writer, first_max, first_width); + let remainder = value - first_max; + let second_max = (1_u64 << second_width) - 1; + if remainder < second_max { + write_test_bits_u64(writer, remainder, second_width); + return; + } + write_test_bits_u64(writer, second_max, second_width); + write_test_bits_u64(writer, remainder - second_max, third_width); +} + +fn write_test_bits_u64(writer: &mut BitWriter>, value: u64, width: usize) { + for shift in (0..width).rev() { + writer.write_bit(((value >> shift) & 1) != 0).unwrap(); + } +} + +fn align_test_bit_writer(writer: &mut BitWriter>) { + while !writer.is_aligned() { + writer.write_bit(false).unwrap(); + } +} diff --git a/tests/parity_harness.rs b/tests/parity_harness.rs index f74b3af..9445542 100644 --- a/tests/parity_harness.rs +++ b/tests/parity_harness.rs @@ -614,9 +614,6 @@ fn fragmented_and_encrypted_cli_surfaces_match_shared_fixture_expectations() { .all(|segment| segment.base_media_decode_time == 123_456) ); assert!(mfra.is_empty()); - - let _ = fs::remove_file(&edit_output); - let _ = fs::remove_dir_all(÷_output_dir); } #[test] diff --git a/tests/probe.rs b/tests/probe.rs index 8160641..fc58f16 100644 --- a/tests/probe.rs +++ b/tests/probe.rs @@ -4,6 +4,7 @@ use std::fs; use std::io::Cursor; +use miniz_oxide::deflate::compress_to_vec_zlib; use mp4forge::boxes::AnyTypeBox; use mp4forge::boxes::av1::AV1CodecConfiguration; use mp4forge::boxes::avs3::Av3c; @@ -12,16 +13,17 @@ use mp4forge::boxes::etsi_ts_103_190::Dac4; use mp4forge::boxes::flac::{DfLa, FlacMetadataBlock}; use mp4forge::boxes::iso14496_12::{ AVCDecoderConfiguration, AlbumLoudnessInfo, AudioSampleEntry, Btrt, Clap, CoLL, Colr, Ctts, - CttsEntry, Edts, Elng, Elst, ElstEntry, Fiel, Frma, Ftyp, HEVCDecoderConfiguration, Hdlr, - LoudnessEntry, LoudnessMeasurement, Ludt, Mdhd, Mdia, Meta, Minf, Moof, Moov, Mvhd, Nmhd, Pasp, - Prft, SampleEntry, Schm, Sinf, SmDm, SphericalVideoV1Metadata, Stbl, Stco, Sthd, Stsc, - StscEntry, Stsd, Stsz, Stts, SttsEntry, TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, - TFHD_DEFAULT_SAMPLE_SIZE_PRESENT, TRUN_SAMPLE_COMPOSITION_TIME_OFFSET_PRESENT, - TRUN_SAMPLE_DURATION_PRESENT, TRUN_SAMPLE_SIZE_PRESENT, TextSubtitleSampleEntry, Tfdt, Tfhd, - Tkhd, TrackLoudnessInfo, Traf, Trak, Trun, TrunEntry, UUID_FRAGMENT_ABSOLUTE_TIMING, - UUID_FRAGMENT_RUN_TABLE, UUID_SAMPLE_ENCRYPTION, UUID_SPHERICAL_VIDEO_V1, Udta, Uuid, - UuidFragmentAbsoluteTiming, UuidFragmentRunEntry, UuidFragmentRunTable, UuidPayload, - VisualSampleEntry, XMLSubtitleSampleEntry, + CttsEntry, DvsC, Edts, Elng, Elst, ElstEntry, Fiel, Frma, Ftyp, GenericMediaSampleEntry, + HEVCDecoderConfiguration, Hdlr, LoudnessEntry, LoudnessMeasurement, Ludt, Mdhd, Mdia, Meta, + Minf, Moof, Moov, Mvhd, Nmhd, Pasp, Prft, SampleEntry, Schm, Sinf, SmDm, + SphericalVideoV1Metadata, Stbl, Stco, Sthd, Stsc, StscEntry, Stsd, Stsz, Stts, SttsEntry, + TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, TFHD_DEFAULT_SAMPLE_SIZE_PRESENT, + TRUN_SAMPLE_COMPOSITION_TIME_OFFSET_PRESENT, TRUN_SAMPLE_DURATION_PRESENT, + TRUN_SAMPLE_SIZE_PRESENT, TextSubtitleSampleEntry, Tfdt, Tfhd, Tkhd, TrackLoudnessInfo, Traf, + Trak, Trun, TrunEntry, UUID_FRAGMENT_ABSOLUTE_TIMING, UUID_FRAGMENT_RUN_TABLE, + UUID_SAMPLE_ENCRYPTION, UUID_SPHERICAL_VIDEO_V1, Udta, Uuid, UuidFragmentAbsoluteTiming, + UuidFragmentRunEntry, UuidFragmentRunTable, UuidPayload, VisualSampleEntry, + XMLSubtitleSampleEntry, }; use mp4forge::boxes::iso14496_14::{ DECODER_CONFIG_DESCRIPTOR_TAG, DECODER_SPECIFIC_INFO_TAG, DecoderConfigDescriptor, Descriptor, @@ -37,10 +39,10 @@ use mp4forge::boxes::opus::DOps; use mp4forge::boxes::vp::VpCodecConfiguration; use mp4forge::codec::{CodecBox, MutableBox, marshal}; use mp4forge::probe::{ - AacProfileInfo, EditListEntry, ProbeOptions, TrackCodec, TrackCodecDetails, TrackCodecFamily, - average_sample_bitrate, average_segment_bitrate, detect_aac_profile, find_idr_frames, - max_sample_bitrate, max_segment_bitrate, normalized_codec_family_name, probe, probe_bytes, - probe_bytes_with_options, probe_codec_detailed, probe_codec_detailed_bytes, + AacProfileInfo, EditListEntry, ProbeError, ProbeOptions, TrackCodec, TrackCodecDetails, + TrackCodecFamily, average_sample_bitrate, average_segment_bitrate, detect_aac_profile, + find_idr_frames, max_sample_bitrate, max_segment_bitrate, normalized_codec_family_name, probe, + probe_bytes, probe_bytes_with_options, probe_codec_detailed, probe_codec_detailed_bytes, probe_codec_detailed_bytes_with_options, probe_codec_detailed_with_options, probe_detailed, probe_detailed_bytes, probe_detailed_bytes_with_options, probe_detailed_with_options, probe_extended_media_characteristics, probe_extended_media_characteristics_bytes, probe_fra, @@ -168,6 +170,33 @@ fn probe_summarizes_movie_tracks_samples_and_codecs() { assert_eq!(idr_frames, vec![0]); } +#[test] +fn probe_rejects_declared_root_box_past_input_without_large_allocation() { + let mut file = include_bytes!("fixtures/av1_opus.mp4").to_vec(); + file[0] = file[10]; + + let error = + probe_codec_detailed_with_options(&mut Cursor::new(file), ProbeOptions::lightweight()) + .unwrap_err(); + + assert_unexpected_eof_probe_error(error); +} + +#[test] +fn probe_detects_tracks_in_compressed_movie_metadata() { + let file = compress_movie_metadata(&build_wvtt_movie_file()); + let mut reader = Cursor::new(file); + + let info = probe_codec_detailed(&mut reader).unwrap(); + + assert_eq!(info.timescale, 1_000); + assert_eq!(info.tracks.len(), 1); + assert_eq!( + info.tracks[0].summary.codec_family, + TrackCodecFamily::WebVtt + ); +} + #[test] fn probe_bytes_matches_cursor_based_probe() { let file = build_movie_file(); @@ -763,52 +792,227 @@ fn probe_detailed_recognizes_av01_track_family() { } #[test] -fn probe_detailed_surfaces_new_sample_entry_types_without_new_family_variants() { +fn probe_detailed_surfaces_additive_family_names_for_new_sample_entry_types() { + for ( + file, + sample_entry_type, + expected_family_name, + expected_channel_count, + expected_sample_rate, + ) in [ + ( + build_ec3_movie_file(), + "ec-3", + "eac3", + Some(6), + Some(48_000), + ), + (build_ac4_movie_file(), "ac-4", "ac4", Some(2), Some(48_000)), + ( + build_simple_audio_movie_file("alac", 2, 48_000, 1_024, 4, Vec::new(), vec![0x2d; 4]), + "alac", + "alac", + Some(2), + Some(48_000), + ), + ( + build_simple_audio_movie_file("dtsc", 2, 48_000, 1_024, 4, Vec::new(), vec![0x2e; 4]), + "dtsc", + "dts", + Some(2), + Some(48_000), + ), + ( + build_simple_audio_movie_file("dtse", 2, 48_000, 1_024, 4, Vec::new(), vec![0x2f; 4]), + "dtse", + "dts", + Some(2), + Some(48_000), + ), + ( + build_simple_audio_movie_file("dtsh", 2, 48_000, 1_024, 4, Vec::new(), vec![0x30; 4]), + "dtsh", + "dts", + Some(2), + Some(48_000), + ), + ( + build_simple_audio_movie_file("dtsl", 2, 48_000, 1_024, 4, Vec::new(), vec![0x31; 4]), + "dtsl", + "dts", + Some(2), + Some(48_000), + ), + ( + build_simple_audio_movie_file("dtsm", 2, 48_000, 1_024, 4, Vec::new(), vec![0x32; 4]), + "dtsm", + "dts", + Some(2), + Some(48_000), + ), + ( + build_simple_audio_movie_file("dtsx", 2, 48_000, 1_024, 4, Vec::new(), vec![0x33; 4]), + "dtsx", + "dts", + Some(2), + Some(48_000), + ), + ( + build_simple_audio_movie_file("dtsy", 2, 48_000, 1_024, 4, Vec::new(), vec![0x34; 4]), + "dtsy", + "dts", + Some(2), + Some(48_000), + ), + ( + build_flac_movie_file(), + "fLaC", + "flac", + Some(2), + Some(48_000), + ), + ( + build_simple_audio_movie_file(".mp3", 2, 44_100, 1_152, 4, Vec::new(), vec![0x46; 4]), + ".mp3", + "mp3", + Some(2), + Some(44_100), + ), + ( + build_simple_audio_movie_file("spex", 1, 16_000, 320, 4, Vec::new(), vec![0x47; 4]), + "spex", + "speex", + Some(1), + Some(16_000), + ), + ( + build_simple_audio_movie_file("samr", 1, 8_000, 160, 4, Vec::new(), vec![0x4B; 4]), + "samr", + "amr", + Some(1), + Some(8_000), + ), + ( + build_simple_audio_movie_file("sawb", 1, 16_000, 320, 4, Vec::new(), vec![0x4C; 4]), + "sawb", + "amr_wb", + Some(1), + Some(16_000), + ), + ( + build_simple_audio_movie_file("sqcp", 1, 8_000, 160, 4, Vec::new(), vec![0x48; 4]), + "sqcp", + "qcelp", + Some(1), + Some(8_000), + ), + ( + build_simple_audio_movie_file("sevc", 1, 8_000, 160, 4, Vec::new(), vec![0x49; 4]), + "sevc", + "evrc", + Some(1), + Some(8_000), + ), + ( + build_simple_audio_movie_file("ssmv", 1, 8_000, 160, 4, Vec::new(), vec![0x4A; 4]), + "ssmv", + "smv", + Some(1), + Some(8_000), + ), + ( + build_simple_audio_movie_file("mlpa", 2, 48_000, 40, 4, Vec::new(), vec![0x4D; 4]), + "mlpa", + "truehd", + Some(2), + Some(48_000), + ), + ( + build_simple_audio_movie_file("iamf", 2, 48_000, 1_024, 4, Vec::new(), vec![0x35; 4]), + "iamf", + "iamf", + Some(2), + Some(48_000), + ), + (build_dvbs_movie_file(), "dvbs", "dvb_subtitle", None, None), + (build_dvbt_movie_file(), "dvbt", "dvb_teletext", None, None), + ( + build_mha1_movie_file(), + "mha1", + "mpeg_h", + Some(2), + Some(48_000), + ), + ( + build_mpeg_h_audio_movie_file("mhm1", vec![0x36, 0x37, 0x38, 0x39]), + "mhm1", + "mpeg_h", + Some(2), + Some(48_000), + ), + ] { + let info = probe_detailed(&mut Cursor::new(file)).unwrap(); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec, TrackCodec::Unknown); + assert_eq!(track.codec_family, TrackCodecFamily::Unknown); + assert_eq!(track.sample_entry_type, Some(fourcc(sample_entry_type))); + assert_eq!(track.channel_count, expected_channel_count); + assert_eq!(track.sample_rate, expected_sample_rate); + assert_eq!( + normalized_codec_family_name( + track.codec_family, + track.sample_entry_type, + track.original_format, + ), + expected_family_name + ); + } + { - let mut reader = Cursor::new(build_ec3_movie_file()); + let mut reader = Cursor::new(build_simple_video_movie_file("mp4v", vec![0x3a; 4])); let info = probe_detailed(&mut reader).unwrap(); let track = &info.tracks[0]; assert_eq!(track.summary.codec, TrackCodec::Unknown); assert_eq!(track.codec_family, TrackCodecFamily::Unknown); - assert_eq!(track.sample_entry_type, Some(fourcc("ec-3"))); - assert_eq!(track.channel_count, Some(6)); - assert_eq!(track.sample_rate, Some(48_000)); + assert_eq!(track.sample_entry_type, Some(fourcc("mp4v"))); + assert_eq!(track.display_width, Some(640)); + assert_eq!(track.display_height, Some(360)); assert_eq!( normalized_codec_family_name( track.codec_family, track.sample_entry_type, track.original_format, ), - "unknown" + "mpeg4_visual" ); } { - let mut reader = Cursor::new(build_ac4_movie_file()); + let mut reader = Cursor::new(build_simple_video_movie_file("s263", vec![0x3b; 4])); let info = probe_detailed(&mut reader).unwrap(); let track = &info.tracks[0]; assert_eq!(track.summary.codec, TrackCodec::Unknown); assert_eq!(track.codec_family, TrackCodecFamily::Unknown); - assert_eq!(track.sample_entry_type, Some(fourcc("ac-4"))); - assert_eq!(track.channel_count, Some(2)); - assert_eq!(track.sample_rate, Some(48_000)); + assert_eq!(track.sample_entry_type, Some(fourcc("s263"))); + assert_eq!(track.display_width, Some(640)); + assert_eq!(track.display_height, Some(360)); assert_eq!( normalized_codec_family_name( track.codec_family, track.sample_entry_type, track.original_format, ), - "unknown" + "h263" ); } { - let mut reader = Cursor::new(build_vvc_movie_file()); + let mut reader = Cursor::new(build_simple_video_movie_file("H263", vec![0x3b; 4])); let info = probe_detailed(&mut reader).unwrap(); let track = &info.tracks[0]; assert_eq!(track.summary.codec, TrackCodec::Unknown); assert_eq!(track.codec_family, TrackCodecFamily::Unknown); - assert_eq!(track.sample_entry_type, Some(fourcc("vvc1"))); + assert_eq!(track.sample_entry_type, Some(fourcc("H263"))); assert_eq!(track.display_width, Some(640)); assert_eq!(track.display_height, Some(360)); assert_eq!( @@ -817,17 +1021,17 @@ fn probe_detailed_surfaces_new_sample_entry_types_without_new_family_variants() track.sample_entry_type, track.original_format, ), - "unknown" + "h263" ); } { - let mut reader = Cursor::new(build_avs3_movie_file()); + let mut reader = Cursor::new(build_simple_video_movie_file("jpeg", vec![0x3d; 4])); let info = probe_detailed(&mut reader).unwrap(); let track = &info.tracks[0]; assert_eq!(track.summary.codec, TrackCodec::Unknown); assert_eq!(track.codec_family, TrackCodecFamily::Unknown); - assert_eq!(track.sample_entry_type, Some(fourcc("avs3"))); + assert_eq!(track.sample_entry_type, Some(fourcc("jpeg"))); assert_eq!(track.display_width, Some(640)); assert_eq!(track.display_height, Some(360)); assert_eq!( @@ -836,55 +1040,145 @@ fn probe_detailed_surfaces_new_sample_entry_types_without_new_family_variants() track.sample_entry_type, track.original_format, ), - "avs3" + "jpeg" + ); + } + + { + let mut reader = Cursor::new(build_simple_video_movie_file("MJPG", vec![0x3d; 4])); + let info = probe_detailed(&mut reader).unwrap(); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec, TrackCodec::Unknown); + assert_eq!(track.codec_family, TrackCodecFamily::Unknown); + assert_eq!(track.sample_entry_type, Some(fourcc("MJPG"))); + assert_eq!(track.display_width, Some(640)); + assert_eq!(track.display_height, Some(360)); + assert_eq!( + normalized_codec_family_name( + track.codec_family, + track.sample_entry_type, + track.original_format, + ), + "jpeg" + ); + } + + { + let mut reader = Cursor::new(build_simple_video_movie_file("png ", vec![0x3c; 4])); + let info = probe_detailed(&mut reader).unwrap(); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec, TrackCodec::Unknown); + assert_eq!(track.codec_family, TrackCodecFamily::Unknown); + assert_eq!(track.sample_entry_type, Some(fourcc("png "))); + assert_eq!(track.display_width, Some(640)); + assert_eq!(track.display_height, Some(360)); + assert_eq!( + normalized_codec_family_name( + track.codec_family, + track.sample_entry_type, + track.original_format, + ), + "png" + ); + } + + { + let mut reader = Cursor::new(build_simple_video_movie_file("PNG ", vec![0x3c; 4])); + let info = probe_detailed(&mut reader).unwrap(); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec, TrackCodec::Unknown); + assert_eq!(track.codec_family, TrackCodecFamily::Unknown); + assert_eq!(track.sample_entry_type, Some(fourcc("PNG "))); + assert_eq!(track.display_width, Some(640)); + assert_eq!(track.display_height, Some(360)); + assert_eq!( + normalized_codec_family_name( + track.codec_family, + track.sample_entry_type, + track.original_format, + ), + "png" + ); + } + + for sample_entry_type in ["DIV3", "DIV4", "BGR3"] { + let mut reader = Cursor::new(build_simple_video_movie_file( + sample_entry_type, + vec![0x52; 4], + )); + let info = probe_detailed(&mut reader).unwrap(); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec, TrackCodec::Unknown); + assert_eq!(track.codec_family, TrackCodecFamily::Unknown); + assert_eq!(track.sample_entry_type, Some(fourcc(sample_entry_type))); + assert_eq!(track.display_width, Some(640)); + assert_eq!(track.display_height, Some(360)); + assert_eq!( + normalized_codec_family_name( + track.codec_family, + track.sample_entry_type, + track.original_format, + ), + "unknown" ); } { - let mut reader = Cursor::new(build_flac_movie_file()); + let mut reader = Cursor::new(build_vvc_movie_file()); let info = probe_detailed(&mut reader).unwrap(); let track = &info.tracks[0]; assert_eq!(track.summary.codec, TrackCodec::Unknown); assert_eq!(track.codec_family, TrackCodecFamily::Unknown); - assert_eq!(track.sample_entry_type, Some(fourcc("fLaC"))); - assert_eq!(track.channel_count, Some(2)); - assert_eq!(track.sample_rate, Some(48_000)); + assert_eq!(track.sample_entry_type, Some(fourcc("vvc1"))); + assert_eq!(track.display_width, Some(640)); + assert_eq!(track.display_height, Some(360)); assert_eq!( normalized_codec_family_name( track.codec_family, track.sample_entry_type, track.original_format, ), - "flac" + "vvc" ); } { - let mut reader = Cursor::new(build_mha1_movie_file()); + let mut reader = Cursor::new(build_avs3_movie_file()); let info = probe_detailed(&mut reader).unwrap(); let track = &info.tracks[0]; assert_eq!(track.summary.codec, TrackCodec::Unknown); assert_eq!(track.codec_family, TrackCodecFamily::Unknown); - assert_eq!(track.sample_entry_type, Some(fourcc("mha1"))); - assert_eq!(track.channel_count, Some(2)); - assert_eq!(track.sample_rate, Some(48_000)); + assert_eq!(track.sample_entry_type, Some(fourcc("avs3"))); + assert_eq!(track.display_width, Some(640)); + assert_eq!(track.display_height, Some(360)); assert_eq!( normalized_codec_family_name( track.codec_family, track.sample_entry_type, track.original_format, ), - "mpeg_h" + "avs3" ); } } #[test] -fn probe_codec_detailed_keeps_unknown_codec_details_for_new_family_strings() { +fn probe_codec_detailed_keeps_unknown_codec_details_for_additive_family_strings() { for file in [ - build_avs3_movie_file(), + build_ec3_movie_file(), + build_ac4_movie_file(), + build_simple_audio_movie_file("alac", 2, 48_000, 1_024, 4, Vec::new(), vec![0x40; 4]), + build_simple_audio_movie_file("dtsc", 2, 48_000, 1_024, 4, Vec::new(), vec![0x41; 4]), + build_simple_audio_movie_file("dtsy", 2, 48_000, 1_024, 4, Vec::new(), vec![0x42; 4]), + build_simple_audio_movie_file("spex", 1, 16_000, 320, 4, Vec::new(), vec![0x43; 4]), build_flac_movie_file(), + build_simple_audio_movie_file("iamf", 2, 48_000, 1_024, 4, Vec::new(), vec![0x44; 4]), + build_dvbs_movie_file(), + build_dvbt_movie_file(), build_mha1_movie_file(), + build_mpeg_h_audio_movie_file("mhm1", vec![0x45, 0x46, 0x47, 0x48]), + build_simple_video_movie_file("mp4v", vec![0x49; 4]), + build_avs3_movie_file(), ] { let info = probe_codec_detailed(&mut Cursor::new(file)).unwrap(); let track = &info.tracks[0]; @@ -1208,6 +1502,36 @@ fn probe_media_characteristics_exposes_sample_entry_side_metadata() { ); } +#[test] +fn probe_detailed_maps_companded_avi_audio_sample_entries_to_pcm_family() { + for sample_entry_type in ["alaw", "ulaw"] { + let info = probe_detailed(&mut Cursor::new(build_simple_audio_movie_file( + sample_entry_type, + 1, + 8_000, + 160, + 4, + Vec::new(), + vec![0x55; 4], + ))) + .unwrap(); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec, TrackCodec::Unknown); + assert_eq!(track.codec_family, TrackCodecFamily::Pcm); + assert_eq!(track.sample_entry_type, Some(fourcc(sample_entry_type))); + assert_eq!(track.channel_count, Some(1)); + assert_eq!(track.sample_rate, Some(8_000)); + assert_eq!( + normalized_codec_family_name( + track.codec_family, + track.sample_entry_type, + track.original_format, + ), + "pcm" + ); + } +} + #[test] fn probe_extended_media_characteristics_exposes_visual_sample_entry_side_metadata() { let file = build_media_characteristics_movie_file(); @@ -1330,6 +1654,18 @@ fn detect_aac_profile_matches_expected_cases() { audio_object_type: 29, }), ), + ( + aac_profile_esds( + 0x40, + &[ + 0x13, 0x10, 0x56, 0xe5, 0x98, 0x06, 0x80, 0x80, 0x80, 0x01, 0x02, + ], + ), + Some(AacProfileInfo { + object_type_indication: 0x40, + audio_object_type: 5, + }), + ), ( aac_profile_esds(0x6b, &[0x10, 0x00]), Some(AacProfileInfo { @@ -2171,11 +2507,14 @@ fn build_encrypted_video_trak(chunk_offsets: &[u64; 1]) -> Vec { encode_supported_box(&Trak, &[tkhd, mdia].concat()) } -fn build_single_track_movie_file( +fn build_single_track_movie_file( compatible_brands: Vec, - track_builder: fn(&[u64; 1]) -> Vec, + track_builder: F, mdat_payload: Vec, -) -> Vec { +) -> Vec +where + F: Fn(&[u64; 1]) -> Vec, +{ let ftyp = encode_supported_box( &Ftyp { major_brand: fourcc("isom"), @@ -2203,6 +2542,77 @@ fn build_single_track_moov(track: Vec) -> Vec { encode_supported_box(&Moov, &[mvhd, track].concat()) } +fn build_simple_audio_movie_file( + sample_entry_type: &str, + channel_count: u16, + sample_rate: u16, + sample_duration: u32, + sample_size: u32, + sample_entry_children: Vec, + mdat_payload: Vec, +) -> Vec { + let sample_entry_type = sample_entry_type.to_string(); + let compatible_brands = vec![fourcc("isom"), fourcc("iso8"), fourcc(&sample_entry_type)]; + build_single_track_movie_file( + compatible_brands, + move |chunk_offsets| { + let sample_entry = encode_supported_box( + &audio_sample_entry_with_type( + &sample_entry_type, + channel_count, + u32::from(sample_rate), + ), + &sample_entry_children, + ); + build_single_sample_audio_trak( + 1, + u32::from(sample_rate), + sample_duration, + sample_entry, + chunk_offsets, + sample_size, + ) + }, + mdat_payload, + ) +} + +fn build_simple_video_movie_file(sample_entry_type: &str, mdat_payload: Vec) -> Vec { + let sample_entry_type = sample_entry_type.to_string(); + let compatible_brands = vec![fourcc("isom"), fourcc("iso8"), fourcc(&sample_entry_type)]; + build_single_track_movie_file( + compatible_brands, + move |chunk_offsets| { + let sample_entry = encode_supported_box( + &video_sample_entry_with_type(&sample_entry_type, 640, 360), + &[], + ); + build_single_sample_video_trak( + 1, + 1_000, + 1_000, + (640, 360), + sample_entry, + chunk_offsets, + 4, + ) + }, + mdat_payload, + ) +} + +fn build_mpeg_h_audio_movie_file(sample_entry_type: &str, mdat_payload: Vec) -> Vec { + build_simple_audio_movie_file( + sample_entry_type, + 2, + 48_000, + 1_024, + 4, + encode_supported_box(&mha_config(), &[]), + mdat_payload, + ) +} + fn build_hevc_movie_file() -> Vec { build_single_track_movie_file( vec![fourcc("isom"), fourcc("iso8"), fourcc("hvc1")], @@ -2415,6 +2825,71 @@ fn build_wvtt_trak(chunk_offsets: &[u64; 1]) -> Vec { ) } +fn build_dvbs_movie_file() -> Vec { + build_single_track_movie_file( + vec![fourcc("isom"), fourcc("iso8"), fourcc("dvbs")], + build_dvbs_trak, + vec![0x91, 0x92, 0x93, 0x94], + ) +} + +fn build_dvbs_trak(chunk_offsets: &[u64; 1]) -> Vec { + let sample_entry = encode_supported_box( + &GenericMediaSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("dvbs"), + data_reference_index: 1, + }, + }, + &encode_supported_box( + &DvsC { + composition_page_id: 0x0123, + ancillary_page_id: 0x0456, + subtitle_type: 0x10, + }, + &[], + ), + ); + build_single_sample_subtitle_trak( + 1, + 1_000, + 1_000, + subtitle_media_header_box(), + sample_entry, + chunk_offsets, + 4, + ) +} + +fn build_dvbt_movie_file() -> Vec { + build_single_track_movie_file( + vec![fourcc("isom"), fourcc("iso8"), fourcc("dvbt")], + build_dvbt_trak, + vec![0x95, 0x96, 0x97, 0x98], + ) +} + +fn build_dvbt_trak(chunk_offsets: &[u64; 1]) -> Vec { + let sample_entry = encode_supported_box( + &GenericMediaSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("dvbt"), + data_reference_index: 1, + }, + }, + &[], + ); + build_single_sample_subtitle_trak( + 1, + 1_000, + 1_000, + subtitle_media_header_box(), + sample_entry, + chunk_offsets, + 4, + ) +} + fn build_encrypted_hevc_movie_file() -> Vec { build_single_track_movie_file( vec![fourcc("iso6"), fourcc("dash"), fourcc("cenc")], @@ -3072,6 +3547,13 @@ fn segment_info(track_id: u32, size: u32, duration: u32) -> mp4forge::probe::Seg } } +fn assert_unexpected_eof_probe_error(error: ProbeError) { + match error { + ProbeError::Io(error) => assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof), + other => panic!("expected unexpected EOF, got {other:?}"), + } +} + fn fourcc(value: &str) -> FourCc { FourCc::try_from(value).unwrap() } @@ -3092,3 +3574,40 @@ fn encode_raw_box(box_type: FourCc, payload: &[u8]) -> Vec { bytes.extend_from_slice(payload); bytes } + +fn compress_movie_metadata(file: &[u8]) -> Vec { + let mut cursor = Cursor::new(file); + let mut ftyp_box = None::>; + let mut moov_box = None::>; + let mut trailing_boxes = Vec::::new(); + + while usize::try_from(cursor.position()).unwrap() < file.len() { + let info = BoxInfo::read(&mut cursor).unwrap(); + let start = usize::try_from(info.offset()).unwrap(); + let end = usize::try_from(info.offset() + info.size()).unwrap(); + match info.box_type() { + value if value == fourcc("ftyp") => ftyp_box = Some(file[start..end].to_vec()), + value if value == fourcc("moov") => moov_box = Some(file[start..end].to_vec()), + _ => trailing_boxes.extend_from_slice(&file[start..end]), + } + info.seek_to_end(&mut cursor).unwrap(); + } + + let moov_box = moov_box.unwrap(); + let compressed_moov = compress_to_vec_zlib(&moov_box, 6); + let dcom = encode_raw_box(fourcc("dcom"), b"zlib"); + let mut cmvd_payload = Vec::with_capacity(4 + compressed_moov.len()); + cmvd_payload.extend_from_slice(&(moov_box.len() as u32).to_be_bytes()); + cmvd_payload.extend_from_slice(&compressed_moov); + let cmvd = encode_raw_box(fourcc("cmvd"), &cmvd_payload); + let cmov = encode_raw_box(fourcc("cmov"), &[dcom, cmvd].concat()); + let moov = encode_raw_box(fourcc("moov"), &cmov); + + let mut output = Vec::new(); + if let Some(ftyp_box) = ftyp_box { + output.extend_from_slice(&ftyp_box); + } + output.extend_from_slice(&moov); + output.extend_from_slice(&trailing_boxes); + output +} diff --git a/tests/sample_reader.rs b/tests/sample_reader.rs new file mode 100644 index 0000000..74ee6f9 --- /dev/null +++ b/tests/sample_reader.rs @@ -0,0 +1,497 @@ +#![cfg(feature = "mux")] + +use std::io::Cursor; + +use mp4forge::mux::sample_reader::{ + AsyncPlannedSampleReader, AsyncProgressiveSampleReader, PlannedSampleReader, + ProgressiveSampleReader, SampleReaderError, +}; +use mp4forge::mux::{ + MuxInterleavePolicy, MuxStagedMediaItem, MuxTrackConfig, MuxTrackKind, plan_staged_media_items, +}; + +#[cfg(feature = "async")] +use tokio::io::AsyncWriteExt; + +#[test] +fn planned_sample_reader_reads_seekable_samples_in_output_order() { + let mut sources = [ + Cursor::new(b"AAAAhelloBBBBxy".to_vec()), + Cursor::new(b"zzzzSYNCtail".to_vec()), + ]; + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 2, 10, 4, 13, 2), + MuxStagedMediaItem::new(1, 1, 0, 5, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 2, 0, 4, 4, 5).with_composition_time_offset(2), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut reader = PlannedSampleReader::new(&mut sources, &plan); + + let first = reader.next_sample().unwrap().unwrap(); + assert_eq!(first.bytes(), b"hello"); + assert_eq!(first.metadata().track_id(), 2); + assert_eq!(first.metadata().output_offset(), 0); + assert_eq!(first.metadata().output_end_offset(), 5); + assert_eq!(first.metadata().decode_end_time(), 4); + assert_eq!(first.metadata().composition_time_offset(), 2); + assert!(!first.metadata().is_sync_sample()); + + let second = reader.next_sample().unwrap().unwrap(); + assert_eq!(second.bytes(), b"SYNC"); + assert_eq!(second.metadata().track_id(), 1); + assert_eq!(second.metadata().output_offset(), 5); + assert_eq!(second.metadata().output_end_offset(), 9); + assert_eq!(second.metadata().decode_end_time(), 5); + assert!(second.metadata().is_sync_sample()); + + let third = reader.next_sample().unwrap().unwrap(); + assert_eq!(third.bytes(), b"xy"); + assert_eq!(third.metadata().track_id(), 2); + assert_eq!(third.metadata().output_offset(), 9); + assert_eq!(third.metadata().output_end_offset(), 11); + assert_eq!(third.metadata().decode_end_time(), 14); + + assert!(reader.next_sample().unwrap().is_none()); +} + +#[test] +fn planned_sample_reader_reads_into_reused_buffer() { + let mut sources = [ + Cursor::new(b"AAAAhelloBBBBxy".to_vec()), + Cursor::new(b"zzzzSYNCtail".to_vec()), + ]; + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 2, 10, 4, 13, 2), + MuxStagedMediaItem::new(1, 1, 0, 5, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 2, 0, 4, 4, 5).with_composition_time_offset(2), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut reader = PlannedSampleReader::new(&mut sources, &plan); + let mut sample_bytes = Vec::with_capacity(16); + let original_capacity = sample_bytes.capacity(); + + let first = reader.next_sample_into(&mut sample_bytes).unwrap().unwrap(); + assert_eq!(sample_bytes, b"hello"); + assert_eq!(sample_bytes.capacity(), original_capacity); + assert_eq!(first.track_id(), 2); + + let second = reader.next_sample_into(&mut sample_bytes).unwrap().unwrap(); + assert_eq!(sample_bytes, b"SYNC"); + assert_eq!(sample_bytes.capacity(), original_capacity); + assert_eq!(second.track_id(), 1); + + let third = reader.next_sample_into(&mut sample_bytes).unwrap().unwrap(); + assert_eq!(sample_bytes, b"xy"); + assert_eq!(sample_bytes.capacity(), original_capacity); + assert_eq!(third.track_id(), 2); + + assert!( + reader + .next_sample_into(&mut sample_bytes) + .unwrap() + .is_none() + ); +} + +#[test] +fn progressive_sample_reader_reads_non_seekable_samples_in_output_order() { + let mut first_source: &[u8] = b"AAAAhelloBBBBxy"; + let mut second_source: &[u8] = b"zzzzSYNCtail"; + let mut sources = [&mut first_source, &mut second_source]; + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 4, 4, 5), + MuxStagedMediaItem::new(1, 2, 5, 4, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 1, 10, 4, 13, 2), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut reader = ProgressiveSampleReader::new(&mut sources, &plan); + + let first = reader.next_sample().unwrap().unwrap(); + assert_eq!(first.bytes(), b"hello"); + assert_eq!(first.metadata().source_index(), 0); + + let second = reader.next_sample().unwrap().unwrap(); + assert_eq!(second.bytes(), b"SYNC"); + assert_eq!(second.metadata().source_index(), 1); + assert!(second.metadata().is_sync_sample()); + + let third = reader.next_sample().unwrap().unwrap(); + assert_eq!(third.bytes(), b"xy"); + assert_eq!(third.metadata().source_index(), 0); + + assert!(reader.next_sample().unwrap().is_none()); +} + +#[test] +fn progressive_sample_reader_reads_into_reused_buffer() { + let mut first_source: &[u8] = b"AAAAhelloBBBBxy"; + let mut second_source: &[u8] = b"zzzzSYNCtail"; + let mut sources = [&mut first_source, &mut second_source]; + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 4, 4, 5), + MuxStagedMediaItem::new(1, 2, 5, 4, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 1, 10, 4, 13, 2), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut reader = ProgressiveSampleReader::new(&mut sources, &plan); + let mut sample_bytes = Vec::with_capacity(16); + let original_capacity = sample_bytes.capacity(); + + let first = reader.next_sample_into(&mut sample_bytes).unwrap().unwrap(); + assert_eq!(sample_bytes, b"hello"); + assert_eq!(sample_bytes.capacity(), original_capacity); + assert_eq!(first.source_index(), 0); + + let second = reader.next_sample_into(&mut sample_bytes).unwrap().unwrap(); + assert_eq!(sample_bytes, b"SYNC"); + assert_eq!(sample_bytes.capacity(), original_capacity); + assert_eq!(second.source_index(), 1); + + let third = reader.next_sample_into(&mut sample_bytes).unwrap().unwrap(); + assert_eq!(sample_bytes, b"xy"); + assert_eq!(sample_bytes.capacity(), original_capacity); + assert_eq!(third.source_index(), 0); + + assert!( + reader + .next_sample_into(&mut sample_bytes) + .unwrap() + .is_none() + ); +} + +#[test] +fn progressive_sample_reader_rejects_backward_offsets() { + let mut source: &[u8] = b"AAAAhelloBBBBxy"; + let mut sources = [&mut source]; + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 4, 13, 2), + MuxStagedMediaItem::new(0, 1, 10, 4, 4, 5), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut reader = ProgressiveSampleReader::new(&mut sources, &plan); + + let first = reader.next_sample().unwrap().unwrap(); + assert_eq!(first.bytes(), b"xy"); + + let error = reader.next_sample().unwrap_err(); + assert_eq!( + error.to_string(), + "source index 0 would need to move backward from offset 15 to 4" + ); + assert!(matches!( + error, + SampleReaderError::NonMonotonicSourceOffset { + source_index: 0, + previous_offset: 15, + next_offset: 4, + } + )); +} + +#[test] +fn planned_sample_reader_exposes_text_track_identity_when_track_configs_are_supplied() { + let mut sources = [ + Cursor::new(b"AAAAwvttBBBBstpp".to_vec()), + Cursor::new(b"zzzzcaptiontail".to_vec()), + ]; + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 4, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 2, 10, 4, 12, 4).with_sync_sample(true), + MuxStagedMediaItem::new(1, 3, 20, 4, 4, 7), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + let track_configs = [ + MuxTrackConfig::new_text(1, 1_000, 0, 0, Vec::new()).with_language(*b"eng"), + MuxTrackConfig::new_subtitle(2, 1_000, 0, 0, Vec::new()).with_language(*b"fra"), + ]; + + let mut reader = + PlannedSampleReader::new_with_track_configs(&mut sources, &plan, &track_configs); + + let first = reader.next_sample().unwrap().unwrap(); + assert_eq!(first.bytes(), b"wvtt"); + assert_eq!( + first.metadata().track().map(|track| track.kind()), + Some(MuxTrackKind::Text) + ); + assert_eq!( + first.metadata().track().map(|track| track.language()), + Some(*b"eng") + ); + + let second = reader.next_sample().unwrap().unwrap(); + assert_eq!(second.bytes(), b"stpp"); + assert_eq!( + second.metadata().track().map(|track| track.kind()), + Some(MuxTrackKind::Subtitle) + ); + assert_eq!( + second.metadata().track().map(|track| track.language()), + Some(*b"fra") + ); + + let third = reader.next_sample().unwrap().unwrap(); + assert_eq!(third.bytes(), b"caption"); + assert_eq!(third.metadata().track(), None); + assert!(reader.next_sample().unwrap().is_none()); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_planned_sample_reader_exposes_text_track_identity_when_track_configs_are_supplied() { + let mut sources = [ + Cursor::new(b"AAAAwvttBBBBstpp".to_vec()), + Cursor::new(b"zzzzcaptiontail".to_vec()), + ]; + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 4, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 2, 10, 4, 12, 4).with_sync_sample(true), + MuxStagedMediaItem::new(1, 3, 20, 4, 4, 7), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + let track_configs = [ + MuxTrackConfig::new_text(1, 1_000, 0, 0, Vec::new()).with_language(*b"eng"), + MuxTrackConfig::new_subtitle(2, 1_000, 0, 0, Vec::new()).with_language(*b"fra"), + ]; + + let mut reader = + AsyncPlannedSampleReader::new_with_track_configs(&mut sources, &plan, &track_configs); + + let first = reader.next_sample().await.unwrap().unwrap(); + assert_eq!(first.bytes(), b"wvtt"); + assert_eq!( + first.metadata().track().map(|track| track.kind()), + Some(MuxTrackKind::Text) + ); + assert_eq!( + first.metadata().track().map(|track| track.language()), + Some(*b"eng") + ); + + let second = reader.next_sample().await.unwrap().unwrap(); + assert_eq!(second.bytes(), b"stpp"); + assert_eq!( + second.metadata().track().map(|track| track.kind()), + Some(MuxTrackKind::Subtitle) + ); + assert_eq!( + second.metadata().track().map(|track| track.language()), + Some(*b"fra") + ); + + let third = reader.next_sample().await.unwrap().unwrap(); + assert_eq!(third.bytes(), b"caption"); + assert_eq!(third.metadata().track(), None); + assert!(reader.next_sample().await.unwrap().is_none()); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_planned_sample_reader_reads_seekable_samples_in_output_order() { + let mut sources = [ + Cursor::new(b"AAAAhelloBBBBxy".to_vec()), + Cursor::new(b"zzzzSYNCtail".to_vec()), + ]; + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 2, 10, 4, 13, 2), + MuxStagedMediaItem::new(1, 1, 0, 5, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 2, 0, 4, 4, 5).with_composition_time_offset(2), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut reader = AsyncPlannedSampleReader::new(&mut sources, &plan); + + assert_eq!( + reader.next_sample().await.unwrap().unwrap().bytes(), + b"hello" + ); + assert_eq!( + reader.next_sample().await.unwrap().unwrap().bytes(), + b"SYNC" + ); + assert_eq!(reader.next_sample().await.unwrap().unwrap().bytes(), b"xy"); + assert!(reader.next_sample().await.unwrap().is_none()); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_planned_sample_reader_reads_into_reused_buffer() { + let mut sources = [ + Cursor::new(b"AAAAhelloBBBBxy".to_vec()), + Cursor::new(b"zzzzSYNCtail".to_vec()), + ]; + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 2, 10, 4, 13, 2), + MuxStagedMediaItem::new(1, 1, 0, 5, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 2, 0, 4, 4, 5).with_composition_time_offset(2), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut reader = AsyncPlannedSampleReader::new(&mut sources, &plan); + let mut sample_bytes = Vec::with_capacity(16); + let original_capacity = sample_bytes.capacity(); + + let first = reader + .next_sample_into(&mut sample_bytes) + .await + .unwrap() + .unwrap(); + assert_eq!(sample_bytes, b"hello"); + assert_eq!(sample_bytes.capacity(), original_capacity); + assert_eq!(first.track_id(), 2); + + let second = reader + .next_sample_into(&mut sample_bytes) + .await + .unwrap() + .unwrap(); + assert_eq!(sample_bytes, b"SYNC"); + assert_eq!(sample_bytes.capacity(), original_capacity); + assert_eq!(second.track_id(), 1); + + let third = reader + .next_sample_into(&mut sample_bytes) + .await + .unwrap() + .unwrap(); + assert_eq!(sample_bytes, b"xy"); + assert_eq!(sample_bytes.capacity(), original_capacity); + assert_eq!(third.track_id(), 2); + + assert!( + reader + .next_sample_into(&mut sample_bytes) + .await + .unwrap() + .is_none() + ); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_progressive_sample_reader_reads_non_seekable_samples_in_output_order() { + let (mut first_writer, first_source) = tokio::io::duplex(64); + let (mut second_writer, second_source) = tokio::io::duplex(64); + first_writer.write_all(b"AAAAhelloBBBBxy").await.unwrap(); + first_writer.shutdown().await.unwrap(); + second_writer.write_all(b"zzzzSYNCtail").await.unwrap(); + second_writer.shutdown().await.unwrap(); + + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 4, 4, 5), + MuxStagedMediaItem::new(1, 2, 5, 4, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 1, 10, 4, 13, 2), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut sources = [first_source, second_source]; + let mut reader = AsyncProgressiveSampleReader::new(&mut sources, &plan); + + assert_eq!( + reader.next_sample().await.unwrap().unwrap().bytes(), + b"hello" + ); + assert_eq!( + reader.next_sample().await.unwrap().unwrap().bytes(), + b"SYNC" + ); + assert_eq!(reader.next_sample().await.unwrap().unwrap().bytes(), b"xy"); + assert!(reader.next_sample().await.unwrap().is_none()); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_progressive_sample_reader_reads_into_reused_buffer() { + let (mut first_writer, first_source) = tokio::io::duplex(64); + let (mut second_writer, second_source) = tokio::io::duplex(64); + first_writer.write_all(b"AAAAhelloBBBBxy").await.unwrap(); + first_writer.shutdown().await.unwrap(); + second_writer.write_all(b"zzzzSYNCtail").await.unwrap(); + second_writer.shutdown().await.unwrap(); + + let plan = plan_staged_media_items( + vec![ + MuxStagedMediaItem::new(0, 1, 0, 4, 4, 5), + MuxStagedMediaItem::new(1, 2, 5, 4, 4, 4).with_sync_sample(true), + MuxStagedMediaItem::new(0, 1, 10, 4, 13, 2), + ], + MuxInterleavePolicy::DecodeTime, + ) + .unwrap(); + + let mut sources = [first_source, second_source]; + let mut reader = AsyncProgressiveSampleReader::new(&mut sources, &plan); + let mut sample_bytes = Vec::with_capacity(16); + let original_capacity = sample_bytes.capacity(); + + let first = reader + .next_sample_into(&mut sample_bytes) + .await + .unwrap() + .unwrap(); + assert_eq!(sample_bytes, b"hello"); + assert_eq!(sample_bytes.capacity(), original_capacity); + assert_eq!(first.source_index(), 0); + + let second = reader + .next_sample_into(&mut sample_bytes) + .await + .unwrap() + .unwrap(); + assert_eq!(sample_bytes, b"SYNC"); + assert_eq!(sample_bytes.capacity(), original_capacity); + assert_eq!(second.source_index(), 1); + + let third = reader + .next_sample_into(&mut sample_bytes) + .await + .unwrap() + .unwrap(); + assert_eq!(sample_bytes, b"xy"); + assert_eq!(sample_bytes.capacity(), original_capacity); + assert_eq!(third.source_index(), 0); + + assert!( + reader + .next_sample_into(&mut sample_bytes) + .await + .unwrap() + .is_none() + ); +} diff --git a/tests/sidx.rs b/tests/sidx.rs index 93c2d5c..127a3dc 100644 --- a/tests/sidx.rs +++ b/tests/sidx.rs @@ -13,6 +13,8 @@ use mp4forge::boxes::iso14496_12::{ }; use mp4forge::codec::{ImmutableBox, MutableBox}; use mp4forge::extract::{extract_box, extract_box_as}; +#[cfg(feature = "async")] +use mp4forge::sidx::analyze_top_level_sidx_update_async; use mp4forge::sidx::{ SidxAnalysisError, SidxPlanError, SidxRewriteError, TopLevelSidxPlanAction, TopLevelSidxPlanOptions, analyze_top_level_sidx_update, analyze_top_level_sidx_update_bytes, @@ -299,6 +301,19 @@ fn plan_top_level_sidx_update_builds_insert_plan_with_default_values() { assert!(plan.encoded_box_size >= 44); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_analyze_top_level_sidx_update_matches_sync_bytes_for_interleaved_fixture() { + let input = fs::read(fixture_path("sample_fragmented.mp4")).unwrap(); + + let async_analysis = analyze_top_level_sidx_update_async(&mut Cursor::new(&input)) + .await + .unwrap(); + let sync_analysis = analyze_top_level_sidx_update_bytes(&input).unwrap(); + + assert_eq!(async_analysis, sync_analysis); +} + #[cfg(feature = "async")] #[tokio::test] async fn async_plan_top_level_sidx_update_builds_insert_plan_with_default_values() { diff --git a/tests/structure_walk.rs b/tests/structure_walk.rs index fb461a3..a03d403 100644 --- a/tests/structure_walk.rs +++ b/tests/structure_walk.rs @@ -1,6 +1,7 @@ use std::io::Cursor; -use mp4forge::boxes::iso14496_12::{Meta, Moov, Trak, Udta}; +use mp4forge::boxes::etsi_ts_102_366::Dac3; +use mp4forge::boxes::iso14496_12::{AudioSampleEntry, Btrt, Meta, Moov, SampleEntry, Trak, Udta}; use mp4forge::codec::{CodecBox, marshal}; use mp4forge::header::HeaderError; #[cfg(feature = "async")] @@ -120,6 +121,43 @@ impl AsyncWalkVisitor>> for AsyncDescendMoovVisitor<'_> { } } +#[cfg(feature = "async")] +struct AsyncAudioSampleEntryTailVisitor<'a> { + visited: &'a mut Vec, +} + +#[cfg(feature = "async")] +impl AsyncWalkVisitor>> for AsyncAudioSampleEntryTailVisitor<'_> { + type Future<'a> + = AsyncWalkFuture<'a> + where + Self: 'a; + + fn visit<'a, 'r>(&'a mut self, handle: &'a mut AsyncCursorWalkHandle<'r>) -> Self::Future<'a> + where + 'r: 'a, + { + Box::pin(async move { + self.visited.push(handle.path().clone()); + match handle.info().box_type() { + box_type if box_type == fourcc("ac-3") => { + let (payload, read) = handle.read_payload_async().await?; + assert_eq!(read, 28); + assert!(payload.as_ref().as_any().is::()); + Ok(WalkControl::Descend) + } + box_type if box_type == fourcc("dac3") => { + let (payload, read) = handle.read_payload_async().await?; + assert_eq!(read, 3); + assert!(payload.as_ref().as_any().is::()); + Ok(WalkControl::Continue) + } + other => panic!("unexpected box {other}"), + } + }) + } +} + #[test] fn walk_structure_tracks_paths_and_supports_raw_payload_reads() { let unknown = encode_raw_box(fourcc("zzzz"), &[0xde, 0xad, 0xbe, 0xef]); @@ -229,6 +267,221 @@ fn walk_structure_reports_invalid_zero_sized_boxes() { assert!(matches!(error, WalkError::Header(HeaderError::InvalidSize))); } +#[test] +fn walk_structure_rejects_truncated_root_payload_read_without_large_allocation() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&u32::MAX.to_be_bytes()); + bytes.extend_from_slice(b"moov"); + bytes.extend_from_slice(&[0, 0, 0, 0]); + + let mut visited = false; + let error = walk_structure(&mut Cursor::new(bytes), |handle| { + visited = true; + handle.read_payload()?; + Ok(WalkControl::Continue) + }) + .unwrap_err(); + + assert!(visited); + assert!(matches!(error, WalkError::UnexpectedEof)); +} + +#[test] +fn walk_structure_rejects_root_box_end_overflow_without_looping() { + let mut bytes = encode_raw_box(fourcc("free"), &[]); + bytes.extend_from_slice(&1_u32.to_be_bytes()); + bytes.extend_from_slice(b"mdat"); + bytes.extend_from_slice(&u64::MAX.to_be_bytes()); + + let error = walk_structure(&mut Cursor::new(bytes), |_| Ok(WalkControl::Continue)).unwrap_err(); + + assert!(matches!(error, WalkError::UnexpectedEof)); +} + +#[test] +fn walk_structure_handles_truncated_supported_root_payload_without_looping() { + let bytes = vec![ + 93, 93, 115, 98, 115, 105, 108, 98, 101, 118, 99, 115, 116, 116, 0, 4, 117, + ]; + + walk_structure(&mut Cursor::new(bytes), |handle| { + if !handle.is_supported_type() { + return Ok(WalkControl::Continue); + } + + if handle.read_payload().is_ok() { + Ok(WalkControl::Descend) + } else { + Ok(WalkControl::Continue) + } + }) + .unwrap(); +} + +#[test] +fn walk_structure_handles_truncated_supported_root_payload_from_slice_without_looping() { + let bytes = [ + 93, 93, 115, 98, 115, 105, 108, 98, 101, 118, 99, 115, 116, 116, 0, 4, 117, + ]; + + walk_structure(&mut Cursor::new(bytes.as_slice()), |handle| { + if !handle.is_supported_type() { + return Ok(WalkControl::Continue); + } + + if handle.read_payload().is_ok() { + Ok(WalkControl::Descend) + } else { + Ok(WalkControl::Continue) + } + }) + .unwrap(); +} + +#[test] +fn walk_structure_ignores_truncated_trailing_root_box_after_valid_boxes() { + let moov = encode_supported_box(&Moov, &[]); + let mut truncated_mdat = Vec::new(); + truncated_mdat.extend_from_slice(&32_u32.to_be_bytes()); + truncated_mdat.extend_from_slice(b"mdat"); + truncated_mdat.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]); + let file = [moov, truncated_mdat].concat(); + + let mut visited = Vec::new(); + walk_structure(&mut Cursor::new(file), |handle| { + visited.push(handle.path().clone()); + Ok(WalkControl::Continue) + }) + .unwrap(); + + assert_eq!( + visited, + vec![ + BoxPath::from([fourcc("moov")]), + BoxPath::from([fourcc("mdat")]), + ] + ); +} + +#[test] +fn walk_structure_stops_audio_sample_entry_children_before_zero_tail() { + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("ac-3"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..AudioSampleEntry::default() + }; + let dac3 = Dac3 { + fscod: 0, + bsid: 8, + bsmod: 0, + acmod: 7, + lfe_on: 1, + bit_rate_code: 15, + }; + + let mut payload = Vec::new(); + marshal(&mut payload, &sample_entry, None).unwrap(); + payload.extend_from_slice(&encode_supported_box(&dac3, &[])); + payload.extend_from_slice(&[0; 8]); + let file = encode_raw_box(fourcc("ac-3"), &payload); + + let mut visited = Vec::new(); + walk_structure(&mut Cursor::new(file), |handle| { + visited.push(handle.path().clone()); + match handle.info().box_type() { + box_type if box_type == fourcc("ac-3") => { + let (payload, read) = handle.read_payload()?; + assert_eq!(read, 28); + assert!(payload.as_ref().as_any().is::()); + Ok(WalkControl::Descend) + } + box_type if box_type == fourcc("dac3") => { + let (payload, read) = handle.read_payload()?; + assert_eq!(read, 3); + assert!(payload.as_ref().as_any().is::()); + Ok(WalkControl::Continue) + } + other => panic!("unexpected box {other}"), + } + }) + .unwrap(); + + assert_eq!( + visited, + vec![ + BoxPath::from([fourcc("ac-3")]), + BoxPath::from([fourcc("ac-3"), fourcc("dac3")]), + ] + ); +} + +#[test] +fn walk_structure_accepts_zero_typed_audio_sample_entry_child_boxes() { + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("ac-3"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..AudioSampleEntry::default() + }; + let dac3 = Dac3 { + fscod: 0, + bsid: 8, + bsmod: 0, + acmod: 7, + lfe_on: 1, + bit_rate_code: 15, + }; + let btrt = Btrt { + buffer_size_db: 1_792, + max_bitrate: 473_088, + avg_bitrate: 448_120, + }; + + let mut payload = Vec::new(); + marshal(&mut payload, &sample_entry, None).unwrap(); + payload.extend_from_slice(&encode_supported_box(&dac3, &[])); + payload.extend_from_slice(&encode_raw_box(FourCc::from_u32(0), &[])); + payload.extend_from_slice(&encode_supported_box(&btrt, &[])); + let file = encode_raw_box(fourcc("ac-3"), &payload); + + let mut visited = Vec::new(); + walk_structure(&mut Cursor::new(file), |handle| { + visited.push(handle.path().clone()); + match handle.info().box_type() { + box_type if box_type == fourcc("ac-3") => { + let (payload, read) = handle.read_payload()?; + assert_eq!(read, 28); + assert!(payload.as_ref().as_any().is::()); + Ok(WalkControl::Descend) + } + box_type if box_type == fourcc("dac3") => Ok(WalkControl::Continue), + box_type if box_type == FourCc::from_u32(0) => Ok(WalkControl::Continue), + box_type if box_type == fourcc("btrt") => Ok(WalkControl::Continue), + other => panic!("unexpected box {other}"), + } + }) + .unwrap(); + + assert_eq!( + visited, + vec![ + BoxPath::from([fourcc("ac-3")]), + BoxPath::from([fourcc("ac-3"), fourcc("dac3")]), + BoxPath::from([fourcc("ac-3"), FourCc::from_u32(0)]), + BoxPath::from([fourcc("ac-3"), fourcc("btrt")]), + ] + ); +} + #[cfg(feature = "async")] #[tokio::test] async fn async_walk_structure_tracks_paths_and_supports_raw_payload_reads() { @@ -259,6 +512,51 @@ async fn async_walk_structure_tracks_paths_and_supports_raw_payload_reads() { ); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_walk_structure_stops_audio_sample_entry_children_before_zero_tail() { + let sample_entry = AudioSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("ac-3"), + data_reference_index: 1, + }, + channel_count: 2, + sample_size: 16, + sample_rate: 48_000 << 16, + ..AudioSampleEntry::default() + }; + let dac3 = Dac3 { + fscod: 0, + bsid: 8, + bsmod: 0, + acmod: 7, + lfe_on: 1, + bit_rate_code: 15, + }; + + let mut payload = Vec::new(); + marshal(&mut payload, &sample_entry, None).unwrap(); + payload.extend_from_slice(&encode_supported_box(&dac3, &[])); + payload.extend_from_slice(&[0; 8]); + let file = encode_raw_box(fourcc("ac-3"), &payload); + + let mut visited = Vec::new(); + let visitor = AsyncAudioSampleEntryTailVisitor { + visited: &mut visited, + }; + walk_structure_async(&mut Cursor::new(file), visitor) + .await + .unwrap(); + + assert_eq!( + visited, + vec![ + BoxPath::from([fourcc("ac-3")]), + BoxPath::from([fourcc("ac-3"), fourcc("dac3")]), + ] + ); +} + #[cfg(feature = "async")] #[tokio::test] async fn async_walk_structure_from_box_reuses_parent_metadata_and_paths() { @@ -293,6 +591,58 @@ async fn async_walk_structure_from_box_reuses_parent_metadata_and_paths() { ); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_walk_structure_ignores_truncated_trailing_root_box_after_valid_boxes() { + let moov = encode_supported_box(&Moov, &[]); + let mut truncated_mdat = Vec::new(); + truncated_mdat.extend_from_slice(&32_u32.to_be_bytes()); + truncated_mdat.extend_from_slice(b"mdat"); + truncated_mdat.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]); + let file = [moov, truncated_mdat].concat(); + + let mut visited = Vec::new(); + struct AsyncCollectVisitor<'a> { + visited: &'a mut Vec, + } + + impl AsyncWalkVisitor>> for AsyncCollectVisitor<'_> { + type Future<'a> + = AsyncWalkFuture<'a> + where + Self: 'a; + + fn visit<'a, 'r>( + &'a mut self, + handle: &'a mut AsyncCursorWalkHandle<'r>, + ) -> Self::Future<'a> + where + 'r: 'a, + { + Box::pin(async move { + self.visited.push(handle.path().clone()); + Ok(WalkControl::Continue) + }) + } + } + + let visitor = AsyncCollectVisitor { + visited: &mut visited, + }; + + walk_structure_async(&mut Cursor::new(file), visitor) + .await + .unwrap(); + + assert_eq!( + visited, + vec![ + BoxPath::from([fourcc("moov")]), + BoxPath::from([fourcc("mdat")]), + ] + ); +} + fn fourcc(value: &str) -> FourCc { FourCc::try_from(value).unwrap() } diff --git a/tests/support/mod.rs b/tests/support/mod.rs index b9c6f8b..3654812 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -4,15 +4,19 @@ #[cfg(feature = "decrypt")] use std::collections::BTreeMap; use std::fs; +use std::io::Write; #[cfg(feature = "decrypt")] use std::io::{Cursor, Seek}; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::ops::Deref; +use std::path::{Path, PathBuf as StdPathBuf}; +use std::sync::Arc; #[cfg(feature = "decrypt")] use aes::Aes128; #[cfg(feature = "decrypt")] use aes::cipher::{Block, BlockEncrypt, KeyInit}; +#[cfg(feature = "mux")] +use mp4forge::bitio::BitWriter; use mp4forge::boxes::AnyTypeBox; #[cfg(feature = "decrypt")] use mp4forge::boxes::isma_cryp::{Isfm, Islt}; @@ -46,54 +50,4503 @@ use mp4forge::decrypt::{DecryptionKey, NativeCommonEncryptionScheme}; use mp4forge::encryption::{ResolvedSampleEncryptionSample, ResolvedSampleEncryptionSource}; #[cfg(feature = "decrypt")] use mp4forge::extract::{extract_box, extract_box_as}; +#[cfg(feature = "mux")] +use mp4forge::mux::{ + MuxFileConfig, MuxInterleavePolicy, MuxStagedMediaItem, MuxTrackConfig, + plan_staged_media_items, write_mp4_mux_to_path, +}; #[cfg(feature = "decrypt")] use mp4forge::walk::BoxPath; use mp4forge::{BoxInfo, FourCc}; +use tempfile::{Builder, TempDir, TempPath}; + +#[cfg(feature = "mux")] +const TS_PACKET_SIZE: usize = 188; + +pub fn encode_supported_box(box_value: &B, children: &[u8]) -> Vec +where + B: CodecBox, +{ + let mut payload = Vec::new(); + marshal(&mut payload, box_value, None).unwrap(); + payload.extend_from_slice(children); + encode_raw_box(box_value.box_type(), &payload) +} + +pub fn encode_raw_box(box_type: FourCc, payload: &[u8]) -> Vec { + let info = BoxInfo::new(box_type, 8 + payload.len() as u64); + let mut bytes = info.encode(); + bytes.extend_from_slice(payload); + bytes +} + +pub fn fourcc(value: &str) -> FourCc { + FourCc::try_from(value).unwrap() +} + +#[allow(dead_code)] +enum TestTempPathOwner { + File(TempPath), + Dir(TempDir), +} + +#[derive(Clone)] +pub struct TestTempPath { + path: StdPathBuf, + _owner: Arc, +} + +impl TestTempPath { + fn from_temp_path(path: TempPath) -> Self { + Self { + path: path.to_path_buf(), + _owner: Arc::new(TestTempPathOwner::File(path)), + } + } + + fn from_temp_dir_entry(path: StdPathBuf, dir: Arc) -> Self { + Self { path, _owner: dir } + } + + pub fn as_path(&self) -> &Path { + self.path.as_path() + } +} + +impl AsRef for TestTempPath { + fn as_ref(&self) -> &Path { + self.as_path() + } +} + +impl Deref for TestTempPath { + type Target = Path; + + fn deref(&self) -> &Self::Target { + self.as_path() + } +} + +impl From<&TestTempPath> for StdPathBuf { + fn from(path: &TestTempPath) -> Self { + path.as_path().to_path_buf() + } +} + +pub type PathBuf = TestTempPath; + +#[derive(Clone)] +pub struct TestTempDir { + path: StdPathBuf, + _dir: Arc, +} + +impl TestTempDir { + pub fn as_path(&self) -> &Path { + self.path.as_path() + } +} + +impl AsRef for TestTempDir { + fn as_ref(&self) -> &Path { + self.as_path() + } +} + +impl Deref for TestTempDir { + type Target = Path; + + fn deref(&self) -> &Self::Target { + self.as_path() + } +} + +impl From<&TestTempDir> for StdPathBuf { + fn from(path: &TestTempDir) -> Self { + path.as_path().to_path_buf() + } +} + +pub fn write_temp_file(prefix: &str, data: &[u8]) -> TestTempPath { + write_temp_file_with_extension(prefix, "mp4", data) +} + +pub fn write_temp_file_with_extension(prefix: &str, extension: &str, data: &[u8]) -> TestTempPath { + let mut file = Builder::new() + .prefix(&format!("mp4forge-{prefix}-")) + .suffix(&format!(".{extension}")) + .tempfile() + .unwrap(); + file.write_all(data).unwrap(); + TestTempPath::from_temp_path(file.into_temp_path()) +} + +#[cfg(feature = "mux")] +#[derive(Clone, Copy)] +pub struct TestMuxSample<'a> { + pub bytes: &'a [u8], + pub duration: u32, + pub composition_time_offset: i32, + pub is_sync_sample: bool, +} + +#[cfg(feature = "mux")] +pub fn write_single_track_mp4_input( + prefix: &str, + file_config: &MuxFileConfig, + track_config: MuxTrackConfig, + samples: &[TestMuxSample<'_>], +) -> PathBuf { + let source_bytes = samples + .iter() + .flat_map(|sample| sample.bytes) + .copied() + .collect::>(); + let source_path = write_temp_file(&format!("{prefix}-source"), &source_bytes); + let output_path = write_temp_file(&format!("{prefix}-output"), &[]); + + let mut source_offset = 0_u64; + let mut decode_time = 0_u64; + let staged_items = samples + .iter() + .map(|sample| { + let item = MuxStagedMediaItem::new( + 0, + track_config.track_id(), + decode_time, + sample.duration, + source_offset, + u32::try_from(sample.bytes.len()).unwrap(), + ) + .with_composition_time_offset(sample.composition_time_offset) + .with_sync_sample(sample.is_sync_sample); + source_offset += u64::try_from(sample.bytes.len()).unwrap(); + decode_time += u64::from(sample.duration); + item + }) + .collect::>(); + let plan = plan_staged_media_items(staged_items, MuxInterleavePolicy::DecodeTime).unwrap(); + + write_mp4_mux_to_path( + &[&source_path], + &output_path, + file_config, + &[track_config], + &plan, + ) + .unwrap(); + output_path +} + +#[cfg(feature = "mux")] +pub fn write_test_adts_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + for payload in payloads { + bytes.extend_from_slice(&build_adts_frame(payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +/// Writes one deterministic AAC-LC LATM file for direct-ingest mux tests. +pub fn write_test_latm_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + for (index, payload) in payloads.iter().enumerate() { + bytes.extend_from_slice(&build_test_latm_frame(index != 0, payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +/// Writes one deterministic USAC LATM file for direct-ingest mux tests. +pub fn write_test_usac_latm_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + for (index, payload) in payloads.iter().enumerate() { + bytes.extend_from_slice(&build_test_usac_latm_frame(index != 0, payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_truehd_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let bytes = build_test_truehd_stream_bytes(payloads); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn build_test_truehd_stream_bytes(payloads: &[&[u8]]) -> Vec { + let mut bytes = Vec::new(); + for payload in payloads { + bytes.extend_from_slice(&build_test_truehd_frame(payload)); + } + bytes +} + +#[cfg(feature = "mux")] +pub fn write_test_mp3_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + for payload in payloads { + bytes.extend_from_slice(&build_mp3_frame(payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_mp3_44100_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + for payload in payloads { + bytes.extend_from_slice(&build_mp3_frame_44100(payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_mp3_file_with_leading_id3_tag( + prefix: &str, + tag_payload: &[u8], + frame_payloads: &[&[u8]], +) -> PathBuf { + let mut bytes = build_id3v2_tag(tag_payload); + for payload in frame_payloads { + bytes.extend_from_slice(&build_mp3_frame(payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_ac3_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + for payload in payloads { + bytes.extend_from_slice(&build_ac3_frame(payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_ac3_44100_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + for payload in payloads { + bytes.extend_from_slice(&build_ac3_44100_frame(payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_eac3_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + for payload in payloads { + bytes.extend_from_slice(&build_eac3_frame(payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_eac3_file_with_dependent_substream(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + for payload in payloads { + bytes.extend_from_slice(&build_eac3_frame(payload)); + bytes.extend_from_slice(&build_eac3_dependent_substream_frame(payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_ac4_file(prefix: &str, frame_count: usize) -> PathBuf { + let bytes = build_test_ac4_stream_bytes(frame_count); + write_temp_file_with_extension(prefix, "ac4", &bytes) +} + +#[cfg(feature = "mux")] +fn build_test_ac4_stream_bytes(frame_count: usize) -> Vec { + let mut bytes = Vec::new(); + let frame = decode_test_hex_bytes(TEST_AC4_FRAME_HEX); + for _ in 0..frame_count { + bytes.extend_from_slice(&frame); + bytes.extend_from_slice(&[0, 0]); + } + bytes +} + +#[cfg(feature = "mux")] +pub fn build_test_ac4_sample_payload_bytes(frame_count: usize) -> Vec { + let frame = decode_test_hex_bytes(TEST_AC4_FRAME_HEX); + let payload = &frame[7..]; + let mut bytes = Vec::with_capacity(payload.len() * frame_count); + for _ in 0..frame_count { + bytes.extend_from_slice(payload); + } + bytes +} + +#[cfg(feature = "mux")] +pub fn write_test_amr_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"#!AMR\n"); + for payload in payloads { + bytes.extend_from_slice(&build_test_amr_frame(payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_amr_wb_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"#!AMR-WB\n"); + for payload in payloads { + bytes.extend_from_slice(&build_test_amr_wb_frame(payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TestQcpCodecKind { + Qcelp, + Evrc, + Smv, +} + +#[cfg(feature = "mux")] +pub fn write_test_qcp_constant_file( + prefix: &str, + codec: TestQcpCodecKind, + payloads: &[&[u8]], +) -> PathBuf { + assert!(!payloads.is_empty()); + let packet_size = u16::try_from(payloads[0].len()).unwrap(); + assert!(packet_size > 0); + for payload in payloads.iter().skip(1) { + assert_eq!(payload.len(), usize::from(packet_size)); + } + let packets = payloads + .iter() + .map(|payload| payload.to_vec()) + .collect::>(); + write_temp_file( + prefix, + &build_test_qcp_file_bytes( + TestQcpFileSpec { + codec, + decoder_version: 0, + packet_size, + block_size: 160, + sample_rate: 8_000, + rate_entries: &[], + rate_flag: 0, + }, + &packets, + ), + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_qcp_variable_file( + prefix: &str, + codec: TestQcpCodecKind, + packets: &[(u8, &[u8])], +) -> PathBuf { + assert!(!packets.is_empty()); + let mut rate_entries = Vec::new(); + for (rate_index, payload) in packets { + assert!(!payload.is_empty()); + let packet_size = u8::try_from(payload.len()).unwrap(); + if let Some(existing) = rate_entries + .iter() + .find(|(existing_index, _)| *existing_index == *rate_index) + { + assert_eq!(existing.1, packet_size); + } else { + rate_entries.push((*rate_index, packet_size)); + } + } + assert!(rate_entries.len() <= 8); + let packet_bytes = packets + .iter() + .map(|(rate_index, payload)| { + let mut packet = Vec::with_capacity(payload.len() + 1); + packet.push(*rate_index); + packet.extend_from_slice(payload); + packet + }) + .collect::>(); + write_temp_file( + prefix, + &build_test_qcp_file_bytes( + TestQcpFileSpec { + codec, + decoder_version: 0, + packet_size: 0, + block_size: 160, + sample_rate: 8_000, + rate_entries: &rate_entries, + rate_flag: 1, + }, + &packet_bytes, + ), + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_dts_file(prefix: &str, frame_count: usize) -> PathBuf { + let mut bytes = Vec::new(); + for index in 0..frame_count { + bytes.extend_from_slice(&build_dts_frame(index)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_dts_little_endian_file(prefix: &str, frame_count: usize) -> PathBuf { + let mut bytes = Vec::new(); + for index in 0..frame_count { + bytes.extend_from_slice(&swap_test_dts_16bit_words(&build_dts_frame(index))); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_wrapped_dts_file(prefix: &str, frame_count: usize) -> PathBuf { + let mut bytes = b"DTSHDHDR".to_vec(); + for index in 0..frame_count { + bytes.extend_from_slice(&build_dts_frame(index)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_wrapped_dts_file_with_tail( + prefix: &str, + frame_count: usize, + tail: &[u8], +) -> PathBuf { + let mut bytes = b"DTSHDHDR".to_vec(); + for index in 0..frame_count { + bytes.extend_from_slice(&build_dts_frame(index)); + } + bytes.extend_from_slice(tail); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_dts_14bit_big_endian_file(prefix: &str, frame_count: usize) -> PathBuf { + let mut bytes = Vec::new(); + for index in 0..frame_count { + bytes.extend_from_slice(&pack_test_dts_14bit_words(&build_dts_frame(index), false)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_dts_14bit_little_endian_file(prefix: &str, frame_count: usize) -> PathBuf { + let mut bytes = Vec::new(); + for index in 0..frame_count { + bytes.extend_from_slice(&pack_test_dts_14bit_words(&build_dts_frame(index), true)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_flac_file(prefix: &str, frame_payload: &[u8]) -> PathBuf { + write_test_flac_file_with_frames(prefix, &[frame_payload]) +} + +#[cfg(feature = "mux")] +pub fn write_test_flac_file_with_frames(prefix: &str, frame_payloads: &[&[u8]]) -> PathBuf { + write_test_flac_file_with_frames_and_block_size(prefix, 48_000, 1_024, frame_payloads) +} + +#[cfg(feature = "mux")] +/// Writes a deterministic native FLAC file whose authored frame headers expose `block_size` and +/// `sample_rate` directly, so mux tests can model longer retained audio frame timing shapes. +pub fn write_test_flac_file_with_frames_and_block_size( + prefix: &str, + sample_rate: u32, + block_size: u32, + frame_payloads: &[&[u8]], +) -> PathBuf { + assert!(!frame_payloads.is_empty()); + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"fLaC"); + bytes.push(0x80); + bytes.extend_from_slice(&34_u32.to_be_bytes()[1..]); + bytes.extend_from_slice(&build_flac_streaminfo_block( + sample_rate, + 2, + 16, + u64::try_from(frame_payloads.len()).unwrap() * u64::from(block_size), + )); + for payload in frame_payloads { + bytes.extend_from_slice(&build_test_flac_frame_with_block_size(payload, block_size)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_ogg_flac_file(prefix: &str, frame_payloads: &[&[u8]]) -> PathBuf { + let serial = 0x464C_4143_u32; + let mut bytes = Vec::new(); + let mut header_packet = Vec::new(); + header_packet.extend_from_slice(b"fLaC"); + header_packet.push(0x80); + header_packet.extend_from_slice(&34_u32.to_be_bytes()[1..]); + header_packet.extend_from_slice(&build_flac_streaminfo_block( + 48_000, + 2, + 16, + u64::try_from(frame_payloads.len()).unwrap() * 1_024, + )); + bytes.extend_from_slice(&build_ogg_page(serial, 0, 0x02, 0, &[header_packet])); + let mut granule_position = 0_u64; + for (index, payload) in frame_payloads.iter().enumerate() { + let frame = build_test_flac_frame(payload); + granule_position += 1_024; + let header_type = if index + 1 == frame_payloads.len() { + 0x04 + } else { + 0 + }; + bytes.extend_from_slice(&build_ogg_page( + serial, + u32::try_from(index + 2).unwrap(), + header_type, + granule_position, + &[frame], + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_ogg_flac_split_header_file(prefix: &str, frame_payloads: &[&[u8]]) -> PathBuf { + let serial = 0x464C_4144_u32; + let mut bytes = Vec::new(); + bytes.extend_from_slice(&build_ogg_page(serial, 0, 0x02, 0, &[b"fLaC".to_vec()])); + let mut streaminfo_packet = Vec::new(); + streaminfo_packet.push(0x80); + streaminfo_packet.extend_from_slice(&34_u32.to_be_bytes()[1..]); + streaminfo_packet.extend_from_slice(&build_flac_streaminfo_block( + 48_000, + 2, + 16, + u64::try_from(frame_payloads.len()).unwrap() * 1_024, + )); + bytes.extend_from_slice(&build_ogg_page(serial, 1, 0, 0, &[streaminfo_packet])); + let mut granule_position = 0_u64; + for (index, payload) in frame_payloads.iter().enumerate() { + let frame = build_test_flac_frame(payload); + granule_position += 1_024; + let header_type = if index + 1 == frame_payloads.len() { + 0x04 + } else { + 0 + }; + bytes.extend_from_slice(&build_ogg_page( + serial, + u32::try_from(index + 2).unwrap(), + header_type, + granule_position, + &[frame], + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_ogg_flac_mapping_file(prefix: &str, frame_payloads: &[&[u8]]) -> PathBuf { + let serial = 0x4F47_464C_u32; + let mut bytes = Vec::new(); + let total_samples = u64::try_from(frame_payloads.len()).unwrap() * 1_024; + let mut header_packet = Vec::new(); + header_packet.push(0x7F); + header_packet.extend_from_slice(b"FLAC"); + header_packet.push(1); + header_packet.push(0); + header_packet.extend_from_slice(&1_u16.to_be_bytes()); + header_packet.extend_from_slice(b"fLaC"); + header_packet.push(0x00); + header_packet.extend_from_slice(&34_u32.to_be_bytes()[1..]); + header_packet.extend_from_slice(&build_flac_streaminfo_block(48_000, 2, 16, total_samples)); + bytes.extend_from_slice(&build_ogg_page(serial, 0, 0x02, 0, &[header_packet])); + bytes.extend_from_slice(&build_ogg_page( + serial, + 1, + 0, + 0, + &[build_flac_vorbis_comment_block()], + )); + let mut granule_position = 0_u64; + for (index, payload) in frame_payloads.iter().enumerate() { + let frame = build_test_flac_frame(payload); + granule_position += 1_024; + let header_type = if index + 1 == frame_payloads.len() { + 0x04 + } else { + 0 + }; + bytes.extend_from_slice(&build_ogg_page( + serial, + u32::try_from(index + 1).unwrap(), + header_type, + granule_position, + &[frame], + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_ogg_opus_file(prefix: &str, audio_payloads: &[&[u8]]) -> PathBuf { + let serial = 0x4F50_5553_u32; + let mut bytes = Vec::new(); + bytes.extend_from_slice(&build_ogg_page( + serial, + 0, + 0x02, + 0, + &[build_opus_head_packet(2)], + )); + bytes.extend_from_slice(&build_ogg_page(serial, 1, 0, 0, &[b"OpusTags".to_vec()])); + let mut granule_position = 0_u64; + for (index, payload) in audio_payloads.iter().enumerate() { + let mut packet = vec![0x00]; + packet.extend_from_slice(payload); + granule_position += 480; + let header_type = if index + 1 == audio_payloads.len() { + 0x04 + } else { + 0 + }; + bytes.extend_from_slice(&build_ogg_page( + serial, + u32::try_from(index + 2).unwrap(), + header_type, + granule_position, + &[packet], + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_wave_pcm_file(prefix: &str, frames: &[[i16; 2]]) -> PathBuf { + let channel_count = 2_u16; + let sample_rate = 48_000_u32; + let bits_per_sample = 16_u16; + let block_align = channel_count * (bits_per_sample / 8); + let byte_rate = sample_rate * u32::from(block_align); + + let mut data = Vec::with_capacity(frames.len() * usize::from(block_align)); + for frame in frames { + for sample in frame { + data.extend_from_slice(&sample.to_le_bytes()); + } + } + + let fmt_chunk_size = 16_u32; + let data_chunk_size = u32::try_from(data.len()).unwrap(); + let riff_size = 4_u32 + .checked_add(8 + fmt_chunk_size) + .and_then(|value| value.checked_add(8 + data_chunk_size)) + .unwrap(); + + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&riff_size.to_le_bytes()); + bytes.extend_from_slice(b"WAVE"); + bytes.extend_from_slice(b"fmt "); + bytes.extend_from_slice(&fmt_chunk_size.to_le_bytes()); + bytes.extend_from_slice(&1_u16.to_le_bytes()); + bytes.extend_from_slice(&channel_count.to_le_bytes()); + bytes.extend_from_slice(&sample_rate.to_le_bytes()); + bytes.extend_from_slice(&byte_rate.to_le_bytes()); + bytes.extend_from_slice(&block_align.to_le_bytes()); + bytes.extend_from_slice(&bits_per_sample.to_le_bytes()); + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&data_chunk_size.to_le_bytes()); + bytes.extend_from_slice(&data); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_aiff_pcm_file(prefix: &str, frames: &[[i16; 2]]) -> PathBuf { + write_test_aiff_like_pcm_file(prefix, frames, None) +} + +#[cfg(feature = "mux")] +pub fn write_test_aifc_pcm_file(prefix: &str, frames: &[[i16; 2]]) -> PathBuf { + write_test_aiff_like_pcm_file(prefix, frames, Some(*b"twos")) +} + +#[cfg(feature = "mux")] +pub fn write_test_aifc_float64_file( + prefix: &str, + sample_rate: u32, + channel_count: u16, + frames: &[&[f64]], +) -> PathBuf { + write_test_aifc_float_file(prefix, sample_rate, channel_count, 64, *b"fl64", frames) +} + +#[cfg(feature = "mux")] +pub fn write_test_aifc_alaw_file( + prefix: &str, + sample_rate: u32, + channel_count: u16, + packets: &[&[u8]], +) -> PathBuf { + write_test_aifc_companded_file(prefix, sample_rate, channel_count, *b"ALAW", 8, packets) +} + +#[cfg(feature = "mux")] +pub fn write_test_aifc_alaw_file_with_declared_bits( + prefix: &str, + sample_rate: u32, + channel_count: u16, + declared_bits_per_sample: u16, + packets: &[&[u8]], +) -> PathBuf { + write_test_aifc_companded_file( + prefix, + sample_rate, + channel_count, + *b"ALAW", + declared_bits_per_sample, + packets, + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_aifc_ulaw_file( + prefix: &str, + sample_rate: u32, + channel_count: u16, + packets: &[&[u8]], +) -> PathBuf { + write_test_aifc_companded_file(prefix, sample_rate, channel_count, *b"ULAW", 8, packets) +} + +#[cfg(feature = "mux")] +pub fn write_test_aifc_ulaw_file_with_declared_bits( + prefix: &str, + sample_rate: u32, + channel_count: u16, + declared_bits_per_sample: u16, + packets: &[&[u8]], +) -> PathBuf { + write_test_aifc_companded_file( + prefix, + sample_rate, + channel_count, + *b"ULAW", + declared_bits_per_sample, + packets, + ) +} + +#[cfg(feature = "mux")] +fn write_test_aifc_companded_file( + prefix: &str, + sample_rate: u32, + channel_count: u16, + compression: [u8; 4], + declared_bits_per_sample: u16, + packets: &[&[u8]], +) -> PathBuf { + let data = packets + .iter() + .flat_map(|packet| packet.iter().copied()) + .collect::>(); + let sample_frames = u32::try_from(data.len() / usize::from(channel_count)).unwrap(); + + let mut comm_payload = Vec::new(); + comm_payload.extend_from_slice(&channel_count.to_be_bytes()); + comm_payload.extend_from_slice(&sample_frames.to_be_bytes()); + comm_payload.extend_from_slice(&declared_bits_per_sample.to_be_bytes()); + comm_payload.extend_from_slice(&encode_aiff_extended_sample_rate(sample_rate)); + comm_payload.extend_from_slice(&compression); + + let mut ssnd_payload = Vec::new(); + ssnd_payload.extend_from_slice(&0_u32.to_be_bytes()); + ssnd_payload.extend_from_slice(&0_u32.to_be_bytes()); + ssnd_payload.extend_from_slice(&data); + + let mut bytes = Vec::new(); + let total_size = 4 + (8 + comm_payload.len()) + (8 + ssnd_payload.len()); + bytes.extend_from_slice(b"FORM"); + bytes.extend_from_slice(&u32::try_from(total_size).unwrap().to_be_bytes()); + bytes.extend_from_slice(b"AIFC"); + bytes.extend_from_slice(b"COMM"); + bytes.extend_from_slice(&u32::try_from(comm_payload.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(&comm_payload); + bytes.extend_from_slice(b"SSND"); + bytes.extend_from_slice(&u32::try_from(ssnd_payload.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(&ssnd_payload); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +fn write_test_aifc_float_file( + prefix: &str, + sample_rate: u32, + channel_count: u16, + bits_per_sample: u16, + compression: [u8; 4], + frames: &[&[f64]], +) -> PathBuf { + let bytes_per_sample = usize::from(bits_per_sample / 8); + let mut data = Vec::with_capacity(frames.len() * usize::from(channel_count) * bytes_per_sample); + for frame in frames { + assert_eq!(frame.len(), usize::from(channel_count)); + for &sample in *frame { + match bits_per_sample { + 64 => data.extend_from_slice(&sample.to_be_bytes()), + 32 => data.extend_from_slice(&(sample as f32).to_be_bytes()), + _ => unreachable!(), + } + } + } + let sample_frames = u32::try_from(frames.len()).unwrap(); + + let mut comm_payload = Vec::new(); + comm_payload.extend_from_slice(&channel_count.to_be_bytes()); + comm_payload.extend_from_slice(&sample_frames.to_be_bytes()); + comm_payload.extend_from_slice(&bits_per_sample.to_be_bytes()); + comm_payload.extend_from_slice(&encode_aiff_extended_sample_rate(sample_rate)); + comm_payload.extend_from_slice(&compression); + + let mut ssnd_payload = Vec::new(); + ssnd_payload.extend_from_slice(&0_u32.to_be_bytes()); + ssnd_payload.extend_from_slice(&0_u32.to_be_bytes()); + ssnd_payload.extend_from_slice(&data); + + let mut bytes = Vec::new(); + let total_size = 4 + (8 + comm_payload.len()) + (8 + ssnd_payload.len()); + bytes.extend_from_slice(b"FORM"); + bytes.extend_from_slice(&u32::try_from(total_size).unwrap().to_be_bytes()); + bytes.extend_from_slice(b"AIFC"); + bytes.extend_from_slice(b"COMM"); + bytes.extend_from_slice(&u32::try_from(comm_payload.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(&comm_payload); + bytes.extend_from_slice(b"SSND"); + bytes.extend_from_slice(&u32::try_from(ssnd_payload.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(&ssnd_payload); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +fn write_test_aiff_like_pcm_file( + prefix: &str, + frames: &[[i16; 2]], + compression: Option<[u8; 4]>, +) -> PathBuf { + let channel_count = 2_u16; + let sample_rate = 48_000_u32; + let bits_per_sample = 16_u16; + + let mut data = Vec::with_capacity(frames.len() * usize::from(channel_count) * 2); + for frame in frames { + for sample in frame { + data.extend_from_slice(&sample.to_be_bytes()); + } + } + + let mut comm_payload = Vec::new(); + comm_payload.extend_from_slice(&channel_count.to_be_bytes()); + comm_payload.extend_from_slice(&u32::try_from(frames.len()).unwrap().to_be_bytes()); + comm_payload.extend_from_slice(&bits_per_sample.to_be_bytes()); + comm_payload.extend_from_slice(&encode_aiff_extended_sample_rate(sample_rate)); + if let Some(compression) = compression { + comm_payload.extend_from_slice(&compression); + } + + let mut ssnd_payload = Vec::new(); + ssnd_payload.extend_from_slice(&0_u32.to_be_bytes()); + ssnd_payload.extend_from_slice(&0_u32.to_be_bytes()); + ssnd_payload.extend_from_slice(&data); + + let form_type = if compression.is_some() { + *b"AIFC" + } else { + *b"AIFF" + }; + let mut bytes = Vec::new(); + let total_size = 4 + (8 + comm_payload.len()) + (8 + ssnd_payload.len()); + bytes.extend_from_slice(b"FORM"); + bytes.extend_from_slice(&u32::try_from(total_size).unwrap().to_be_bytes()); + bytes.extend_from_slice(&form_type); + bytes.extend_from_slice(b"COMM"); + bytes.extend_from_slice(&u32::try_from(comm_payload.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(&comm_payload); + bytes.extend_from_slice(b"SSND"); + bytes.extend_from_slice(&u32::try_from(ssnd_payload.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(&ssnd_payload); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +fn encode_aiff_extended_sample_rate(sample_rate: u32) -> [u8; 10] { + let msb_index = 31_u32 - sample_rate.leading_zeros(); + let exponent = 16383_u16 + u16::try_from(msb_index).unwrap(); + let mantissa = u64::from(sample_rate) << (63 - msb_index); + + let mut bytes = [0_u8; 10]; + bytes[..2].copy_from_slice(&exponent.to_be_bytes()); + bytes[2..].copy_from_slice(&mantissa.to_be_bytes()); + bytes +} + +#[cfg(feature = "mux")] +pub struct TestAviPcmStream<'a> { + pub sample_rate: u32, + pub channel_count: u16, + pub bits_per_sample: u16, + pub chunks: &'a [&'a [u8]], +} + +#[cfg(feature = "mux")] +pub struct TestAviMp4vStream<'a> { + pub width: u16, + pub height: u16, + pub frame_scale: u32, + pub frame_rate: u32, + pub compression: [u8; 4], + pub decoder_specific_info: &'a [u8], + pub frames: &'a [&'a [u8]], +} + +#[cfg(feature = "mux")] +pub struct TestAviH264Stream<'a> { + pub width: u16, + pub height: u16, + pub frame_scale: u32, + pub frame_rate: u32, + pub compression: [u8; 4], + pub sample_payloads: &'a [&'a [u8]], +} + +#[cfg(feature = "mux")] +pub struct TestAviAvc1Stream<'a> { + pub width: u16, + pub height: u16, + pub frame_scale: u32, + pub frame_rate: u32, + pub sample_payloads: &'a [&'a [u8]], +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_mp3_file( + prefix: &str, + sample_rate: u32, + channel_count: u16, + payloads: &[&[u8]], +) -> PathBuf { + let frames = payloads + .iter() + .map(|payload| build_mp3_frame(payload)) + .collect::>(); + let frame_refs = frames.iter().map(Vec::as_slice).collect::>(); + write_test_avi_framed_audio_file(prefix, 0x0055, sample_rate, channel_count, 16, &frame_refs) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_ac3_file( + prefix: &str, + sample_rate: u32, + channel_count: u16, + payloads: &[&[u8]], +) -> PathBuf { + let frames = payloads + .iter() + .map(|payload| build_ac3_frame(payload)) + .collect::>(); + let frame_refs = frames.iter().map(Vec::as_slice).collect::>(); + write_test_avi_framed_audio_file(prefix, 0x2000, sample_rate, channel_count, 16, &frame_refs) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_pcm_file(prefix: &str, streams: &[TestAviPcmStream<'_>]) -> PathBuf { + let avih = build_test_avi_avih_payload( + streams.len(), + streams + .iter() + .flat_map(|stream| stream.chunks.iter().map(|chunk| chunk.len())) + .max() + .unwrap_or(0), + ); + let mut hdrl_children = encode_riff_chunk(*b"avih", &avih); + for (index, stream) in streams.iter().enumerate() { + hdrl_children.extend_from_slice(&encode_riff_list( + *b"strl", + &build_test_avi_pcm_stream_list(index, stream), + )); + } + let hdrl = encode_riff_list(*b"hdrl", &hdrl_children); + let movi = encode_riff_list(*b"movi", &build_test_avi_movi_payload(streams)); + + let mut riff_payload = Vec::new(); + riff_payload.extend_from_slice(b"AVI "); + riff_payload.extend_from_slice(&hdrl); + riff_payload.extend_from_slice(&movi); + write_temp_file(prefix, &encode_riff_chunk(*b"RIFF", &riff_payload)) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_alaw_file( + prefix: &str, + sample_rate: u32, + channel_count: u16, + chunks: &[&[u8]], +) -> PathBuf { + write_test_avi_companded_audio_file(prefix, 0x0006, sample_rate, channel_count, chunks) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_mulaw_file( + prefix: &str, + sample_rate: u32, + channel_count: u16, + chunks: &[&[u8]], +) -> PathBuf { + write_test_avi_companded_audio_file(prefix, 0x0007, sample_rate, channel_count, chunks) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_extensible_pcm_file( + prefix: &str, + sample_rate: u32, + channel_count: u16, + bits_per_sample: u16, + chunks: &[&[u8]], +) -> PathBuf { + write_test_avi_extensible_audio_file( + prefix, + sample_rate, + channel_count, + bits_per_sample, + chunks, + &[ + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, + 0x9B, 0x71, + ], + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_extensible_float_file( + prefix: &str, + sample_rate: u32, + channel_count: u16, + bits_per_sample: u16, + chunks: &[&[u8]], +) -> PathBuf { + write_test_avi_extensible_audio_file( + prefix, + sample_rate, + channel_count, + bits_per_sample, + chunks, + &[ + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, + 0x9B, 0x71, + ], + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_extensible_alaw_file( + prefix: &str, + sample_rate: u32, + channel_count: u16, + chunks: &[&[u8]], +) -> PathBuf { + write_test_avi_extensible_audio_file( + prefix, + sample_rate, + channel_count, + 8, + chunks, + &[ + 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, + 0x9B, 0x71, + ], + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_extensible_mulaw_file( + prefix: &str, + sample_rate: u32, + channel_count: u16, + chunks: &[&[u8]], +) -> PathBuf { + write_test_avi_extensible_audio_file( + prefix, + sample_rate, + channel_count, + 8, + chunks, + &[ + 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, + 0x9B, 0x71, + ], + ) +} + +#[cfg(feature = "mux")] +fn write_test_avi_companded_audio_file( + prefix: &str, + format_tag: u16, + sample_rate: u32, + channel_count: u16, + chunks: &[&[u8]], +) -> PathBuf { + let stream = TestAviPcmStream { + sample_rate, + channel_count, + bits_per_sample: 8, + chunks, + }; + let avih = + build_test_avi_avih_payload(1, chunks.iter().map(|chunk| chunk.len()).max().unwrap_or(0)); + let mut hdrl_children = encode_riff_chunk(*b"avih", &avih); + hdrl_children.extend_from_slice(&encode_riff_list( + *b"strl", + &build_test_avi_pcm_stream_list_with_format_tag(0, &stream, format_tag), + )); + let hdrl = encode_riff_list(*b"hdrl", &hdrl_children); + let movi = encode_riff_list(*b"movi", &build_test_avi_audio_movi_payload(chunks)); + + let mut riff_payload = Vec::new(); + riff_payload.extend_from_slice(b"AVI "); + riff_payload.extend_from_slice(&hdrl); + riff_payload.extend_from_slice(&movi); + write_temp_file(prefix, &encode_riff_chunk(*b"RIFF", &riff_payload)) +} + +#[cfg(feature = "mux")] +fn write_test_avi_extensible_audio_file( + prefix: &str, + sample_rate: u32, + channel_count: u16, + bits_per_sample: u16, + chunks: &[&[u8]], + subtype_guid: &[u8; 16], +) -> PathBuf { + let stream = TestAviPcmStream { + sample_rate, + channel_count, + bits_per_sample, + chunks, + }; + let avih = + build_test_avi_avih_payload(1, chunks.iter().map(|chunk| chunk.len()).max().unwrap_or(0)); + let mut hdrl_children = encode_riff_chunk(*b"avih", &avih); + hdrl_children.extend_from_slice(&encode_riff_list( + *b"strl", + &build_test_avi_pcm_stream_list_with_extensible_subtype(0, &stream, subtype_guid), + )); + let hdrl = encode_riff_list(*b"hdrl", &hdrl_children); + let movi = encode_riff_list(*b"movi", &build_test_avi_audio_movi_payload(chunks)); + + let mut riff_payload = Vec::new(); + riff_payload.extend_from_slice(b"AVI "); + riff_payload.extend_from_slice(&hdrl); + riff_payload.extend_from_slice(&movi); + write_temp_file(prefix, &encode_riff_chunk(*b"RIFF", &riff_payload)) +} + +#[cfg(feature = "mux")] +struct TestAviVideoFileSpec<'a> { + width: u16, + height: u16, + frame_scale: u32, + frame_rate: u32, + compression: [u8; 4], + decoder_specific_info: &'a [u8], + frames: &'a [&'a [u8]], +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_h263_file( + prefix: &str, + width: u16, + height: u16, + frame_scale: u32, + frame_rate: u32, + sample_payloads: &[&[u8]], +) -> PathBuf { + let frames = sample_payloads + .iter() + .enumerate() + .map(|(index, payload)| build_test_h263_frame(u8::try_from(index).unwrap(), payload)) + .collect::>(); + let frame_refs = frames.iter().map(Vec::as_slice).collect::>(); + write_test_avi_video_file( + prefix, + TestAviVideoFileSpec { + width, + height, + frame_scale, + frame_rate, + compression: *b"H263", + decoder_specific_info: &[], + frames: &frame_refs, + }, + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_jpeg_file( + prefix: &str, + width: u16, + height: u16, + frame_scale: u32, + frame_rate: u32, + frames: &[&[u8]], +) -> PathBuf { + write_test_avi_video_file( + prefix, + TestAviVideoFileSpec { + width, + height, + frame_scale, + frame_rate, + compression: *b"MJPG", + decoder_specific_info: &[], + frames, + }, + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_png_file( + prefix: &str, + width: u16, + height: u16, + frame_scale: u32, + frame_rate: u32, + frames: &[&[u8]], +) -> PathBuf { + write_test_avi_video_file( + prefix, + TestAviVideoFileSpec { + width, + height, + frame_scale, + frame_rate, + compression: *b"PNG ", + decoder_specific_info: &[], + frames, + }, + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_video_tag_file( + prefix: &str, + width: u16, + height: u16, + frame_scale: u32, + frame_rate: u32, + compression: [u8; 4], + frames: &[&[u8]], +) -> PathBuf { + write_test_avi_video_file( + prefix, + TestAviVideoFileSpec { + width, + height, + frame_scale, + frame_rate, + compression, + decoder_specific_info: &[], + frames, + }, + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_raw_bgr_file( + prefix: &str, + width: u16, + height: u16, + frame_scale: u32, + frame_rate: u32, + frames: &[&[u8]], +) -> PathBuf { + write_test_avi_video_tag_file( + prefix, + width, + height, + frame_scale, + frame_rate, + [0, 0, 0, 0], + frames, + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_mp4v_file(prefix: &str, stream: &TestAviMp4vStream<'_>) -> PathBuf { + let avih = build_test_avi_avih_payload( + 1, + stream + .frames + .iter() + .map(|frame| frame.len()) + .max() + .unwrap_or(0), + ); + let mut hdrl_children = encode_riff_chunk(*b"avih", &avih); + hdrl_children.extend_from_slice(&encode_riff_list( + *b"strl", + &build_test_avi_mp4v_stream_list(stream), + )); + let hdrl = encode_riff_list(*b"hdrl", &hdrl_children); + let movi = encode_riff_list(*b"movi", &build_test_avi_mp4v_movi_payload(stream)); + + let mut riff_payload = Vec::new(); + riff_payload.extend_from_slice(b"AVI "); + riff_payload.extend_from_slice(&hdrl); + riff_payload.extend_from_slice(&movi); + write_temp_file(prefix, &encode_riff_chunk(*b"RIFF", &riff_payload)) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_h264_file(prefix: &str, stream: &TestAviH264Stream<'_>) -> PathBuf { + let frames = build_test_h264_annexb_chunks(stream.sample_payloads); + let frame_refs = frames.iter().map(Vec::as_slice).collect::>(); + let avih = build_test_avi_avih_payload( + 1, + frame_refs + .iter() + .map(|frame| frame.len()) + .max() + .unwrap_or(0), + ); + let mut hdrl_children = encode_riff_chunk(*b"avih", &avih); + hdrl_children.extend_from_slice(&encode_riff_list( + *b"strl", + &build_test_avi_video_stream_list( + stream.width, + stream.height, + stream.frame_scale, + stream.frame_rate, + stream.compression, + &[], + &frame_refs, + ), + )); + let hdrl = encode_riff_list(*b"hdrl", &hdrl_children); + let movi = encode_riff_list(*b"movi", &build_test_avi_video_movi_payload(&frame_refs)); + + let mut riff_payload = Vec::new(); + riff_payload.extend_from_slice(b"AVI "); + riff_payload.extend_from_slice(&hdrl); + riff_payload.extend_from_slice(&movi); + write_temp_file(prefix, &encode_riff_chunk(*b"RIFF", &riff_payload)) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_avc1_file(prefix: &str, stream: &TestAviAvc1Stream<'_>) -> PathBuf { + let frames = build_test_h264_avc1_chunks(stream.sample_payloads); + let frame_refs = frames.iter().map(Vec::as_slice).collect::>(); + let avih = build_test_avi_avih_payload( + 1, + frame_refs + .iter() + .map(|frame| frame.len()) + .max() + .unwrap_or(0), + ); + let mut hdrl_children = encode_riff_chunk(*b"avih", &avih); + hdrl_children.extend_from_slice(&encode_riff_list( + *b"strl", + &build_test_avi_video_stream_list( + stream.width, + stream.height, + stream.frame_scale, + stream.frame_rate, + *b"AVC1", + &build_test_avcc_decoder_specific_info(), + &frame_refs, + ), + )); + let hdrl = encode_riff_list(*b"hdrl", &hdrl_children); + let movi = encode_riff_list(*b"movi", &build_test_avi_video_movi_payload(&frame_refs)); + + let mut riff_payload = Vec::new(); + riff_payload.extend_from_slice(b"AVI "); + riff_payload.extend_from_slice(&hdrl); + riff_payload.extend_from_slice(&movi); + write_temp_file(prefix, &encode_riff_chunk(*b"RIFF", &riff_payload)) +} + +#[cfg(feature = "mux")] +fn write_test_avi_framed_audio_file( + prefix: &str, + format_tag: u16, + sample_rate: u32, + channel_count: u16, + bits_per_sample: u16, + frames: &[&[u8]], +) -> PathBuf { + let avih = + build_test_avi_avih_payload(1, frames.iter().map(|frame| frame.len()).max().unwrap_or(0)); + let mut hdrl_children = encode_riff_chunk(*b"avih", &avih); + hdrl_children.extend_from_slice(&encode_riff_list( + *b"strl", + &build_test_avi_framed_audio_stream_list( + format_tag, + sample_rate, + channel_count, + bits_per_sample, + frames, + ), + )); + let hdrl = encode_riff_list(*b"hdrl", &hdrl_children); + let movi = encode_riff_list(*b"movi", &build_test_avi_audio_movi_payload(frames)); + + let mut riff_payload = Vec::new(); + riff_payload.extend_from_slice(b"AVI "); + riff_payload.extend_from_slice(&hdrl); + riff_payload.extend_from_slice(&movi); + write_temp_file(prefix, &encode_riff_chunk(*b"RIFF", &riff_payload)) +} + +#[cfg(feature = "mux")] +pub fn write_test_avi_audio_tag_file( + prefix: &str, + format_tag: u16, + sample_rate: u32, + channel_count: u16, + bits_per_sample: u16, + frames: &[&[u8]], +) -> PathBuf { + write_test_avi_framed_audio_file( + prefix, + format_tag, + sample_rate, + channel_count, + bits_per_sample, + frames, + ) +} + +#[cfg(feature = "mux")] +fn write_test_avi_video_file(prefix: &str, spec: TestAviVideoFileSpec<'_>) -> PathBuf { + let avih = build_test_avi_avih_payload( + 1, + spec.frames + .iter() + .map(|frame| frame.len()) + .max() + .unwrap_or(0), + ); + let mut hdrl_children = encode_riff_chunk(*b"avih", &avih); + hdrl_children.extend_from_slice(&encode_riff_list( + *b"strl", + &build_test_avi_video_stream_list( + spec.width, + spec.height, + spec.frame_scale, + spec.frame_rate, + spec.compression, + spec.decoder_specific_info, + spec.frames, + ), + )); + let hdrl = encode_riff_list(*b"hdrl", &hdrl_children); + let movi = encode_riff_list(*b"movi", &build_test_avi_video_movi_payload(spec.frames)); + + let mut riff_payload = Vec::new(); + riff_payload.extend_from_slice(b"AVI "); + riff_payload.extend_from_slice(&hdrl); + riff_payload.extend_from_slice(&movi); + write_temp_file(prefix, &encode_riff_chunk(*b"RIFF", &riff_payload)) +} + +#[cfg(feature = "mux")] +pub fn write_test_mp4v_file(prefix: &str, bytes: &[u8]) -> PathBuf { + write_temp_file(prefix, bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_mpeg2v_file(prefix: &str, bytes: &[u8]) -> PathBuf { + write_temp_file(prefix, bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_saf_aac_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + const STREAM_ID: u16 = 1; + const TIMESCALE: u32 = 48_000; + let mut bytes = Vec::new(); + bytes.extend_from_slice(&build_test_saf_declaration_au(TestSafDeclaration { + au_sn: 0, + cts: 0, + au_type: 1, + stream_id: STREAM_ID, + object_type_indication: 0x40, + stream_type: 0x05, + timescale: TIMESCALE, + decoder_specific_info: &[0x11, 0x90], + })); + for (index, payload) in payloads.iter().enumerate() { + bytes.extend_from_slice(&build_test_saf_data_au( + u16::try_from(index + 1).unwrap(), + u32::try_from(index).unwrap() * 1_024, + STREAM_ID, + index == 0, + payload, + )); + } + write_temp_file_with_extension(prefix, "saf", &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_saf_scene_plus_mp4v_file( + prefix: &str, + scene_payloads: &[&[u8]], + video_payloads: &[&[u8]], +) -> PathBuf { + const SCENE_STREAM_ID: u16 = 1; + const VIDEO_STREAM_ID: u16 = 2; + const TIMESCALE: u32 = 1_000; + let mut bytes = Vec::new(); + bytes.extend_from_slice(&build_test_saf_declaration_au(TestSafDeclaration { + au_sn: 0, + cts: 0, + au_type: 1, + stream_id: SCENE_STREAM_ID, + object_type_indication: 0x01, + stream_type: 0x03, + timescale: TIMESCALE, + decoder_specific_info: &[0x12, 0x34], + })); + bytes.extend_from_slice(&build_test_saf_declaration_au(TestSafDeclaration { + au_sn: 1, + cts: 0, + au_type: 1, + stream_id: VIDEO_STREAM_ID, + object_type_indication: 0x20, + stream_type: 0x04, + timescale: TIMESCALE, + decoder_specific_info: &build_test_mp4v_decoder_specific_info(320, 180), + })); + let mut au_sn = 2_u16; + for (index, payload) in scene_payloads.iter().enumerate() { + let cts = u32::try_from(index).unwrap() * 1_000; + bytes.extend_from_slice(&build_test_saf_data_au( + au_sn, + cts, + SCENE_STREAM_ID, + true, + payload, + )); + au_sn += 1; + } + for (index, payload) in video_payloads.iter().enumerate() { + let cts = u32::try_from(index).unwrap() * 1_000; + bytes.extend_from_slice(&build_test_saf_data_au( + au_sn, + cts, + VIDEO_STREAM_ID, + index == 0, + payload, + )); + au_sn += 1; + } + write_temp_file_with_extension(prefix, "saf", &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_saf_remote_url_file(prefix: &str) -> PathBuf { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&build_test_saf_declaration_au(TestSafDeclaration { + au_sn: 0, + cts: 0, + au_type: 7, + stream_id: 1, + object_type_indication: 0x01, + stream_type: 0x03, + timescale: 1_000, + decoder_specific_info: b"https://example.invalid/scene.lsr", + })); + write_temp_file_with_extension(prefix, "saf", &bytes) +} + +#[cfg(feature = "mux")] +struct TestSafDeclaration<'a> { + au_sn: u16, + cts: u32, + au_type: u8, + stream_id: u16, + object_type_indication: u8, + stream_type: u8, + timescale: u32, + decoder_specific_info: &'a [u8], +} + +#[cfg(feature = "mux")] +fn build_test_saf_declaration_au(declaration: TestSafDeclaration<'_>) -> Vec { + let mut payload = vec![ + declaration.object_type_indication, + declaration.stream_type, + u8::try_from((declaration.timescale >> 16) & 0xFF).unwrap(), + u8::try_from((declaration.timescale >> 8) & 0xFF).unwrap(), + u8::try_from(declaration.timescale & 0xFF).unwrap(), + 0, + 0, + ]; + payload.extend_from_slice(declaration.decoder_specific_info); + build_test_saf_au( + true, + declaration.au_sn, + declaration.cts, + declaration.au_type, + declaration.stream_id, + &payload, + ) +} + +#[cfg(feature = "mux")] +fn build_test_saf_data_au( + au_sn: u16, + cts: u32, + stream_id: u16, + is_rap: bool, + payload: &[u8], +) -> Vec { + build_test_saf_au(is_rap, au_sn, cts, 4, stream_id, payload) +} + +#[cfg(feature = "mux")] +fn build_test_saf_au( + is_rap: bool, + au_sn: u16, + cts: u32, + au_type: u8, + stream_id: u16, + payload: &[u8], +) -> Vec { + let payload_size = u16::try_from(payload.len() + 2).unwrap(); + let outer = ((u64::from(is_rap as u8)) << 63) + | ((u64::from(au_sn & 0x7FFF)) << 48) + | ((u64::from(cts & 0x3FFF_FFFF)) << 16) + | u64::from(payload_size); + let inner = (u16::from(au_type & 0x0F) << 12) | (stream_id & 0x0FFF); + let mut bytes = outer.to_be_bytes().to_vec(); + bytes.extend_from_slice(&inner.to_be_bytes()); + bytes.extend_from_slice(payload); + bytes +} + +#[cfg(feature = "mux")] +pub fn build_test_mp4v_decoder_specific_info(width: u16, height: u16) -> Vec { + let mut writer = BitWriter::new(Vec::new()); + writer.write_bit(false).unwrap(); + write_test_bits_u64(&mut writer, 1, 8); + writer.write_bit(false).unwrap(); + write_test_bits_u64(&mut writer, 1, 4); + writer.write_bit(false).unwrap(); + write_test_bits_u64(&mut writer, 0, 2); + writer.write_bit(true).unwrap(); + write_test_bits_u64(&mut writer, 1_000, 16); + writer.write_bit(true).unwrap(); + writer.write_bit(false).unwrap(); + writer.write_bit(true).unwrap(); + write_test_bits_u64(&mut writer, u64::from(width), 13); + writer.write_bit(true).unwrap(); + write_test_bits_u64(&mut writer, u64::from(height), 13); + writer.write_bit(true).unwrap(); + align_test_bit_writer(&mut writer); + + let mut bytes = vec![0x00, 0x00, 0x01, 0x20]; + bytes.extend_from_slice(&writer.into_inner().unwrap()); + bytes +} + +#[cfg(feature = "mux")] +pub fn build_test_mp4v_decoder_specific_info_with_vol_control(width: u16, height: u16) -> Vec { + let mut writer = BitWriter::new(Vec::new()); + writer.write_bit(false).unwrap(); + write_test_bits_u64(&mut writer, 1, 8); + writer.write_bit(false).unwrap(); + write_test_bits_u64(&mut writer, 1, 4); + writer.write_bit(true).unwrap(); + write_test_bits_u64(&mut writer, 1, 2); + writer.write_bit(false).unwrap(); + writer.write_bit(false).unwrap(); + write_test_bits_u64(&mut writer, 0, 2); + writer.write_bit(true).unwrap(); + write_test_bits_u64(&mut writer, 1_000, 16); + writer.write_bit(true).unwrap(); + writer.write_bit(false).unwrap(); + writer.write_bit(true).unwrap(); + write_test_bits_u64(&mut writer, u64::from(width), 13); + writer.write_bit(true).unwrap(); + write_test_bits_u64(&mut writer, u64::from(height), 13); + writer.write_bit(true).unwrap(); + align_test_bit_writer(&mut writer); + + let mut bytes = vec![0x00, 0x00, 0x01, 0x20]; + bytes.extend_from_slice(&writer.into_inner().unwrap()); + bytes +} + +#[cfg(feature = "mux")] +pub fn build_test_mpeg2v_bytes(width: u16, height: u16, sample_payloads: &[&[u8]]) -> Vec { + let mut bytes = vec![ + 0x00, + 0x00, + 0x01, + 0xB3, + u8::try_from(width >> 4).unwrap(), + u8::try_from(((width & 0x0F) << 4) | (height >> 8)).unwrap(), + u8::try_from(height & 0xFF).unwrap(), + 0x13, + 0x00, + 0x00, + 0x01, + 0xB5, + 0x14, + 0x80, + 0x00, + 0x00, + 0x00, + 0x00, + ]; + for (index, payload) in sample_payloads.iter().enumerate() { + bytes.extend_from_slice(&build_test_mpeg2v_picture_bytes(index, payload)); + } + bytes +} + +#[cfg(feature = "mux")] +fn build_test_mpeg2v_picture_bytes(index: usize, payload: &[u8]) -> Vec { + let mut writer = BitWriter::new(Vec::new()); + write_test_bits_u64(&mut writer, u64::try_from(index).unwrap(), 10); + write_test_bits_u64(&mut writer, 1, 3); + write_test_bits_u64(&mut writer, 0xFFFF, 16); + align_test_bit_writer(&mut writer); + + let mut bytes = vec![0x00, 0x00, 0x01, 0x00]; + bytes.extend_from_slice(&writer.into_inner().unwrap()); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0x01]); + bytes.extend_from_slice(payload); + bytes +} + +#[cfg(feature = "mux")] +pub fn write_test_program_stream_mp3_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = build_test_program_stream_pack_header(); + for payload in payloads { + bytes.extend_from_slice(&build_test_program_stream_mp3_pes_packet(payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_program_stream_mp2_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = build_test_program_stream_pack_header(); + for payload in payloads { + bytes.extend_from_slice(&build_test_program_stream_mp2_pes_packet(payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_program_stream_ac3_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = build_test_program_stream_pack_header(); + for payload in payloads { + bytes.extend_from_slice(&build_test_program_stream_ac3_pes_packet(payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_program_stream_lpcm_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = build_test_program_stream_pack_header(); + for payload in payloads { + bytes.extend_from_slice(&build_test_program_stream_lpcm_pes_packet(payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_program_stream_mp4v_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = build_test_program_stream_pack_header(); + for payload in payloads { + bytes.extend_from_slice(&build_test_program_stream_mp4v_pes_packet(payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_program_stream_mpeg2v_file(prefix: &str, sample_payloads: &[&[u8]]) -> PathBuf { + let mut bytes = build_test_program_stream_pack_header(); + for (index, payload) in sample_payloads.iter().enumerate() { + let mut elementary_sample = if index == 0 { + build_test_mpeg2v_bytes(320, 180, &[*payload]) + } else { + build_test_mpeg2v_picture_bytes(index, payload) + }; + if index + 1 == sample_payloads.len() { + elementary_sample.extend_from_slice(&[0x00, 0x00, 0x01, 0xB7]); + } + bytes.extend_from_slice(&build_test_program_stream_video_pes_packet_with_pts( + u64::try_from(index).unwrap() * 3_600, + &elementary_sample, + )); + } + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_program_stream_mpeg2v_pts_dts_file( + prefix: &str, + sample_payloads: &[&[u8]], +) -> PathBuf { + let mut bytes = build_test_program_stream_pack_header(); + for (index, payload) in sample_payloads.iter().enumerate() { + let mut elementary_sample = if index == 0 { + build_test_mpeg2v_bytes(320, 180, &[*payload]) + } else { + build_test_mpeg2v_picture_bytes(index, payload) + }; + if index + 1 == sample_payloads.len() { + elementary_sample.extend_from_slice(&[0x00, 0x00, 0x01, 0xB7]); + } + let timestamp = u64::try_from(index).unwrap() * 3_600; + bytes.extend_from_slice( + &build_test_program_stream_video_pes_packet_with_pts_and_dts( + timestamp, + timestamp, + &elementary_sample, + ), + ); + } + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_program_stream_h264_file(prefix: &str, sample_payloads: &[&[u8]]) -> PathBuf { + let mut bytes = build_test_program_stream_pack_header(); + bytes.extend_from_slice(&build_test_program_stream_video_pes_packet( + &build_test_h264_annexb_bytes(sample_payloads), + )); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_program_stream_h264_open_ended_file( + prefix: &str, + sample_payloads: &[&[u8]], +) -> PathBuf { + let mut bytes = build_test_program_stream_pack_header(); + bytes.extend_from_slice(&build_test_program_stream_open_ended_video_pes_packet( + &build_test_h264_annexb_bytes(sample_payloads), + )); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_program_stream_h265_file(prefix: &str, sample_payloads: &[&[u8]]) -> PathBuf { + let mut bytes = build_test_program_stream_pack_header(); + bytes.extend_from_slice(&build_test_program_stream_video_pes_packet( + &build_test_h265_annexb_bytes(sample_payloads), + )); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_program_stream_vvc_file(prefix: &str, sample_payloads: &[&[u8]]) -> PathBuf { + let mut bytes = build_test_program_stream_pack_header(); + let raw_vvc = fixture_path("mux/raw_vvc_idr.vvc"); + let mut annex_b = fs::read(raw_vvc).unwrap(); + for extra in sample_payloads { + annex_b.extend_from_slice(extra); + } + bytes.extend_from_slice(&build_test_program_stream_video_pes_packet(&annex_b)); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_mp3_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + for payload in payloads { + let pes_packet = build_test_transport_stream_mp3_pes_packet(payload); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_multi_program_mp3_file( + prefix: &str, + payloads: &[&[u8]], +) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_multi_program_pat_packet( + continuity_counter, + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + for payload in payloads { + let pes_packet = build_test_transport_stream_mp3_pes_packet(payload); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_latm_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_stream_type( + continuity_counter, + 0x11, + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + for (index, payload) in payloads.iter().enumerate() { + let pes_packet = build_test_transport_stream_mpeg_audio_pes_packet_with_pts( + u64::try_from(index).unwrap() * 1_920, + &build_test_latm_frame(index != 0, payload), + ); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_latm_other_data_file( + prefix: &str, + payloads: &[&[u8]], +) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_stream_type( + continuity_counter, + 0x11, + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + for (index, payload) in payloads.iter().enumerate() { + let pes_packet = build_test_transport_stream_mpeg_audio_pes_packet_with_pts( + u64::try_from(index).unwrap() * 1_920, + &build_test_latm_frame_with_options(index != 0, payload, 2, true, false), + ); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_mhas_file(prefix: &str, frame_payloads: &[&[u8]]) -> PathBuf { + assert!(!frame_payloads.is_empty()); + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_stream_type( + continuity_counter, + 0x2D, + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + let mut first_payload = Vec::new(); + first_payload.extend_from_slice(&build_mhas_packet(6, &[0xA5])); + first_payload.extend_from_slice(&build_mhas_packet(1, &build_test_mhas_config_payload())); + for (index, frame_payload) in frame_payloads.iter().enumerate() { + let mut frame = Vec::with_capacity(frame_payload.len() + 1); + frame.push(0x80); + frame.extend_from_slice(frame_payload); + let carried_frame = build_mhas_packet(2, &frame); + if index == 0 { + first_payload.extend_from_slice(&carried_frame); + } + } + let pes_packet = build_test_transport_stream_mpeg_audio_pes_packet_with_pts(0, &first_payload); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + for (index, frame_payload) in frame_payloads.iter().enumerate().skip(1) { + let mut frame = Vec::with_capacity(frame_payload.len() + 1); + frame.push(0x80); + frame.extend_from_slice(frame_payload); + let pes_packet = build_test_transport_stream_mpeg_audio_pes_packet_with_pts( + u64::try_from(index).unwrap() * 1_920, + &build_mhas_packet(2, &frame), + ); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_ac3_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_stream_type( + continuity_counter, + 0x81, + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + for payload in payloads { + let pes_packet = + build_test_transport_stream_private_data_pes_packet(&build_ac3_frame(payload)); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_eac3_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_stream_type( + continuity_counter, + 0x84, + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + for payload in payloads { + let pes_packet = + build_test_transport_stream_private_data_pes_packet(&build_eac3_frame(payload)); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_mp4v_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_stream_type( + continuity_counter, + 0x10, + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + for payload in payloads { + let pes_packet = build_test_transport_stream_mp4v_pes_packet(payload); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_mpeg2v_file(prefix: &str, sample_payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_stream_type( + continuity_counter, + 0x02, + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + for (index, payload) in sample_payloads.iter().enumerate() { + let elementary_sample = if index == 0 { + build_test_mpeg2v_bytes(320, 180, &[*payload]) + } else { + build_test_mpeg2v_picture_bytes(index, payload) + }; + let pes_packet = build_test_transport_stream_mpeg2v_pes_packet_with_pts( + u64::try_from(index).unwrap() * 3_600, + &elementary_sample, + ); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_av1_file(prefix: &str, frame_payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_private_data( + continuity_counter, + &[ + build_test_transport_stream_registration_descriptor(*b"AV01"), + build_test_transport_stream_private_data_specifier_descriptor(*b"AOMS"), + build_test_transport_stream_av1_video_descriptor(), + ] + .concat(), + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + for (index, payload) in frame_payloads.iter().enumerate() { + let elementary_sample = build_test_transport_stream_av1_sample_bytes(payload); + let pes_packet = build_test_transport_stream_video_pes_packet_with_pts( + u64::try_from(index).unwrap() * 3_600, + &elementary_sample, + ); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_avs3_file(prefix: &str, sample_payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + let sequence_header = build_test_avs3_sequence_header_bytes(320, 180, 0x03); + let decoder_config = build_test_transport_stream_avs3_decoder_config(&sequence_header); + bytes.extend_from_slice( + &build_test_transport_stream_pmt_packet_for_stream_type_with_descriptors( + continuity_counter, + 0xD4, + &build_test_transport_stream_avs3_registration_descriptor(&decoder_config), + ), + ); + continuity_counter = (continuity_counter + 1) & 0x0F; + for (index, payload) in sample_payloads.iter().enumerate() { + let elementary_sample = if index == 0 { + [ + sequence_header.clone(), + build_test_avs3_picture_bytes(true, payload), + ] + .concat() + } else { + build_test_avs3_picture_bytes(false, payload) + }; + let pes_packet = build_test_transport_stream_mpeg2v_pes_packet_with_pts( + u64::try_from(index).unwrap() * 3_600, + &elementary_sample, + ); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_h264_file(prefix: &str, sample_payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_stream_type( + continuity_counter, + 0x1B, + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + let pes_packet = build_test_transport_stream_video_pes_packet(&build_test_h264_annexb_bytes( + sample_payloads, + )); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_h265_file(prefix: &str, sample_payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_stream_type( + continuity_counter, + 0x24, + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + let pes_packet = build_test_transport_stream_video_pes_packet(&build_test_h265_annexb_bytes( + sample_payloads, + )); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_vvc_file(prefix: &str, sample_payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_stream_type( + continuity_counter, + 0x33, + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + let raw_vvc = fixture_path("mux/raw_vvc_idr.vvc"); + let mut annex_b = fs::read(raw_vvc).unwrap(); + for extra in sample_payloads { + annex_b.extend_from_slice(extra); + } + let pes_packet = build_test_transport_stream_video_pes_packet(&annex_b); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_dts_file(prefix: &str, frame_count: usize) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_private_data( + continuity_counter, + &build_test_transport_stream_registration_descriptor(*b"DTS1"), + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + for index in 0..frame_count { + let pes_packet = + build_test_transport_stream_private_data_pes_packet(&build_dts_frame(index)); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_dts_stream_type_file( + prefix: &str, + frame_count: usize, +) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_stream_type( + continuity_counter, + 0x82, + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + for index in 0..frame_count { + let pes_packet = + build_test_transport_stream_private_data_pes_packet(&build_dts_frame(index)); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_ac4_file(prefix: &str, frame_count: usize) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_private_data( + continuity_counter, + &build_test_transport_stream_registration_descriptor(*b"AC-4"), + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + let pes_packet = build_test_transport_stream_private_data_pes_packet( + &build_test_ac4_stream_bytes(frame_count), + ); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_truehd_file(prefix: &str, payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_stream_type( + continuity_counter, + 0x83, + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + let pes_packet = build_test_transport_stream_private_data_pes_packet( + &build_test_truehd_stream_bytes(payloads), + ); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_dvb_subtitle_file( + prefix: &str, + subtitle_payloads: &[&[u8]], +) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_private_data( + continuity_counter, + &build_test_transport_stream_dvb_subtitle_descriptor(*b"eng", 0x10, 0x0123, 0x0456), + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + for payload in subtitle_payloads { + let pes_packet = build_test_transport_stream_private_data_pes_packet(payload); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_transport_stream_dvb_teletext_file( + prefix: &str, + teletext_payloads: &[&[u8]], +) -> PathBuf { + let mut bytes = Vec::new(); + let mut continuity_counter = 0_u8; + bytes.extend_from_slice(&build_test_transport_stream_pat_packet(continuity_counter)); + continuity_counter = (continuity_counter + 1) & 0x0F; + bytes.extend_from_slice(&build_test_transport_stream_pmt_packet_for_private_data( + continuity_counter, + &build_test_transport_stream_dvb_teletext_descriptor(*b"eng", 0x10, 0x01), + )); + continuity_counter = (continuity_counter + 1) & 0x0F; + for payload in teletext_payloads { + let pes_packet = build_test_transport_stream_private_data_pes_packet(payload); + bytes.extend_from_slice(&packetize_test_transport_stream_pes( + 0x0101, + &mut continuity_counter, + &pes_packet, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_vobsub_files( + prefix: &str, + start_times_ms: &[u32], + sample_payloads: &[&[u8]], +) -> (PathBuf, PathBuf) { + assert_eq!(start_times_ms.len(), sample_payloads.len()); + + let dir = Builder::new() + .prefix(&format!("mp4forge-{prefix}-")) + .tempdir() + .unwrap(); + let idx_path = dir.path().join("input.idx"); + let sub_path = dir.path().join("input.sub"); + + let mut sub_bytes = Vec::new(); + let mut positions = Vec::with_capacity(sample_payloads.len()); + for (start_ms, payload) in start_times_ms + .iter() + .copied() + .zip(sample_payloads.iter().copied()) + { + let filepos = u64::try_from(sub_bytes.len()).unwrap(); + positions.push((start_ms, filepos)); + let packet = build_test_vobsub_packet(payload); + sub_bytes.extend_from_slice(&packetize_test_vobsub_subpicture( + u64::from(start_ms) * 90, + 0x20, + &packet, + )); + } + + let mut idx = String::from("# VobSub index file, v7 (do not modify this line!)\n#\n"); + idx.push_str("size: 720x480\n"); + idx.push_str( + "palette: 000000, 101010, 202020, 303030, 404040, 505050, 606060, 707070, 808080, 909090, A0A0A0, B0B0B0, C0C0C0, D0D0D0, E0E0E0, F0F0F0\n", + ); + idx.push_str("id: en, index: 0\n"); + for (start_ms, filepos) in positions { + idx.push_str(&format!( + "timestamp: {}, filepos: {:09X}\n", + format_vobsub_timestamp_ms(start_ms), + filepos + )); + } + + fs::write(&idx_path, idx.as_bytes()).unwrap(); + fs::write(&sub_path, &sub_bytes).unwrap(); + let owner = Arc::new(TestTempPathOwner::Dir(dir)); + ( + TestTempPath::from_temp_dir_entry(idx_path, owner.clone()), + TestTempPath::from_temp_dir_entry(sub_path, owner), + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_program_stream_vobsub_file( + prefix: &str, + start_times_ms: &[u32], + sample_payloads: &[&[u8]], +) -> PathBuf { + assert_eq!(start_times_ms.len(), sample_payloads.len()); + + let mut bytes = build_test_program_stream_pack_header(); + for (start_ms, payload) in start_times_ms + .iter() + .copied() + .zip(sample_payloads.iter().copied()) + { + let packet = build_test_vobsub_packet(payload); + bytes.extend_from_slice(&build_test_program_stream_vobsub_pes_packet( + u64::from(start_ms) * 90, + 0x20, + &packet, + )); + } + write_temp_file_with_extension(prefix, "ps", &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_ogg_vorbis_file(prefix: &str, audio_payloads: &[&[u8]]) -> PathBuf { + let serial = 0x564F_5242_u32; + let mut bytes = Vec::new(); + bytes.extend_from_slice(&build_ogg_page( + serial, + 0, + 0x02, + 0, + &[build_vorbis_identification_packet()], + )); + bytes.extend_from_slice(&build_ogg_page( + serial, + 1, + 0, + 0, + &[build_vorbis_comment_packet()], + )); + bytes.extend_from_slice(&build_ogg_page( + serial, + 2, + 0, + 0, + &[build_vorbis_setup_packet()], + )); + let mut granule_position = 0_u64; + for (index, payload) in audio_payloads.iter().enumerate() { + granule_position += 64; + let header_type = if index + 1 == audio_payloads.len() { + 0x04 + } else { + 0 + }; + bytes.extend_from_slice(&build_ogg_page( + serial, + u32::try_from(index + 3).unwrap(), + header_type, + granule_position, + &[build_vorbis_audio_packet(payload)], + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +fn format_vobsub_timestamp_ms(total_ms: u32) -> String { + let hours = total_ms / 3_600_000; + let minutes = (total_ms / 60_000) % 60; + let seconds = (total_ms / 1_000) % 60; + let milliseconds = total_ms % 1_000; + format!("{hours:02}:{minutes:02}:{seconds:02}:{milliseconds:03}") +} + +#[cfg(feature = "mux")] +fn build_test_vobsub_packet(payload: &[u8]) -> Vec { + let control_offset = 4_u16 + u16::try_from(payload.len()).unwrap(); + let packet_size = control_offset + 6; + let mut packet = Vec::with_capacity(usize::from(packet_size)); + packet.extend_from_slice(&packet_size.to_be_bytes()); + packet.extend_from_slice(&control_offset.to_be_bytes()); + packet.extend_from_slice(payload); + packet.extend_from_slice(&0_u16.to_be_bytes()); + packet.extend_from_slice(&control_offset.to_be_bytes()); + packet.extend_from_slice(&[0x00, 0xFF]); + packet +} + +#[cfg(feature = "mux")] +fn packetize_test_vobsub_subpicture(pts: u64, substream_id: u8, data: &[u8]) -> Vec { + let ptsbuf = [ + (((pts >> 29) & 0x0E) as u8) | 0x21, + ((pts >> 22) & 0xFF) as u8, + (((pts >> 14) & 0xFE) as u8) | 0x01, + ((pts >> 7) & 0xFF) as u8, + (((pts << 1) & 0xFE) as u8) | 0x01, + ]; + let mut packetized = Vec::new(); + let mut remaining = data; + let mut emit_pts = true; + while !remaining.is_empty() { + let mut sector = [0_u8; 0x800]; + sector[..5].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA, 0x40]); + + let mut write = 14usize; + sector[write..write + 4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBD]); + write += 4; + + let mut data_len = sector.len() - 14 - 4 - 2 - 3 - 1; + if emit_pts { + data_len -= 5; + } + let mut pad_len = 0usize; + if remaining.len() <= data_len { + pad_len = data_len - remaining.len(); + data_len = remaining.len(); + } + + let pes_header_extension_len = + if emit_pts { 5 } else { 0 } + usize::from(pad_len < 6) * pad_len; + let pes_packet_size = + 3 + if emit_pts { 5 } else { 0 } + 1 + data_len + if pad_len < 6 { pad_len } else { 0 }; + sector[write..write + 2] + .copy_from_slice(&(u16::try_from(pes_packet_size).unwrap()).to_be_bytes()); + write += 2; + sector[write] = 0x80; + sector[write + 1] = if emit_pts { 0x80 } else { 0x00 }; + sector[write + 2] = u8::try_from(pes_header_extension_len).unwrap(); + write += 3; + + if emit_pts { + sector[write..write + 5].copy_from_slice(&ptsbuf); + write += 5; + } + + if pad_len < 6 { + write += pad_len; + } + + sector[write] = substream_id; + write += 1; + sector[write..write + data_len].copy_from_slice(&remaining[..data_len]); + write += data_len; + remaining = &remaining[data_len..]; + + if pad_len >= 6 { + let stream_padding = pad_len - 6; + sector[write..write + 4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBE]); + sector[write + 4..write + 6] + .copy_from_slice(&(u16::try_from(stream_padding).unwrap()).to_be_bytes()); + } + + packetized.extend_from_slice(§or); + emit_pts = false; + } + packetized +} + +#[cfg(feature = "mux")] +pub fn write_test_ogg_speex_file(prefix: &str, audio_payloads: &[&[u8]]) -> PathBuf { + let serial = 0x5350_5858_u32; + let mut bytes = Vec::new(); + bytes.extend_from_slice(&build_ogg_page( + serial, + 0, + 0x02, + 0, + &[build_speex_header_packet()], + )); + bytes.extend_from_slice(&build_ogg_page(serial, 1, 0, 0, &[b"SpeexTags".to_vec()])); + let mut granule_position = 0_u64; + for (index, payload) in audio_payloads.iter().enumerate() { + granule_position += 160; + let header_type = if index + 1 == audio_payloads.len() { + 0x04 + } else { + 0 + }; + bytes.extend_from_slice(&build_ogg_page( + serial, + u32::try_from(index + 2).unwrap(), + header_type, + granule_position, + &[payload.to_vec()], + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_ogg_theora_file(prefix: &str, frame_payloads: &[&[u8]]) -> PathBuf { + let serial = 0x5448_454F_u32; + let mut bytes = Vec::new(); + bytes.extend_from_slice(&build_ogg_page( + serial, + 0, + 0x02, + 0, + &[build_theora_identification_packet(4, 3)], + )); + bytes.extend_from_slice(&build_ogg_page( + serial, + 1, + 0, + 0, + &[build_theora_comment_packet()], + )); + bytes.extend_from_slice(&build_ogg_page( + serial, + 2, + 0, + 0, + &[build_theora_setup_packet()], + )); + let mut granule_position = 0_u64; + for (index, payload) in frame_payloads.iter().enumerate() { + granule_position += 1; + let header_type = if index + 1 == frame_payloads.len() { + 0x04 + } else { + 0 + }; + bytes.extend_from_slice(&build_ogg_page( + serial, + u32::try_from(index + 3).unwrap(), + header_type, + granule_position, + &[build_theora_frame_packet(payload)], + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +/// Writes one deterministic 1x1 JPEG fixture for direct-ingest mux tests. +pub fn write_test_jpeg_file(prefix: &str) -> PathBuf { + write_temp_file(prefix, include_bytes!("../fixtures/generated-1x1.jpg")) +} + +#[cfg(feature = "mux")] +pub fn write_test_png_file(prefix: &str) -> PathBuf { + write_temp_file( + prefix, + &[ + 0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, b'I', b'H', + b'D', b'R', 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, + 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, b'I', b'D', b'A', b'T', 0x78, + 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, + 0x00, 0x00, 0x00, b'I', b'E', b'N', b'D', 0xAE, 0x42, 0x60, 0x82, + ], + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_iamf_file(prefix: &str, frame_payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&build_test_iamf_obu( + 31, + &build_test_iamf_sequence_header_payload(), + )); + bytes.extend_from_slice(&build_test_iamf_obu( + 0, + &build_test_iamf_codec_config_payload(), + )); + bytes.extend_from_slice(&build_test_iamf_obu( + 1, + &build_test_iamf_audio_element_payload(), + )); + for payload in frame_payloads { + bytes.extend_from_slice(&build_test_iamf_obu(4, &[])); + bytes.extend_from_slice(&build_test_iamf_obu(5, payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_caf_alac_file(prefix: &str, packets: &[&[u8]]) -> PathBuf { + assert!(!packets.is_empty()); + let bytes_per_packet = u32::try_from(packets[0].len()).unwrap(); + assert!(bytes_per_packet > 0); + for packet in &packets[1..] { + assert_eq!(packet.len(), usize::try_from(bytes_per_packet).unwrap()); + } + + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"caff"); + bytes.extend_from_slice(&1_u16.to_be_bytes()); + bytes.extend_from_slice(&0_u16.to_be_bytes()); + + let desc_payload = build_caf_alac_description_chunk(bytes_per_packet, 1_024, 2, 16, 48_000.0); + bytes.extend_from_slice(b"desc"); + bytes.extend_from_slice(&u64::try_from(desc_payload.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(&desc_payload); + + let cookie = b"alac-cookie"; + bytes.extend_from_slice(b"kuki"); + bytes.extend_from_slice(&u64::try_from(cookie.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(cookie); + + let mut data_payload = + Vec::with_capacity(4 + packets.iter().map(|packet| packet.len()).sum::()); + data_payload.extend_from_slice(&0_u32.to_be_bytes()); + for packet in packets { + data_payload.extend_from_slice(packet); + } + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&u64::try_from(data_payload.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(&data_payload); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_caf_alac_variable_packet_file(prefix: &str, packets: &[&[u8]]) -> PathBuf { + assert!(!packets.is_empty()); + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"caff"); + bytes.extend_from_slice(&1_u16.to_be_bytes()); + bytes.extend_from_slice(&0_u16.to_be_bytes()); + + let desc_payload = build_caf_alac_description_chunk(0, 4_096, 0, 0, 44_100.0); + bytes.extend_from_slice(b"desc"); + bytes.extend_from_slice(&u64::try_from(desc_payload.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(&desc_payload); + + let cookie = build_caf_alac_magic_cookie(4_096, 16, 1, 44_100); + bytes.extend_from_slice(b"kuki"); + bytes.extend_from_slice(&u64::try_from(cookie.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(&cookie); + + let chan_payload = 0_u32.to_be_bytes(); + bytes.extend_from_slice(b"chan"); + bytes.extend_from_slice(&u64::try_from(chan_payload.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(&chan_payload); + + let mut data_payload = + Vec::with_capacity(4 + packets.iter().map(|packet| packet.len()).sum::()); + data_payload.extend_from_slice(&0_u32.to_be_bytes()); + for packet in packets { + data_payload.extend_from_slice(packet); + } + bytes.extend_from_slice(b"data"); + bytes.extend_from_slice(&u64::try_from(data_payload.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(&data_payload); + + let packet_table = build_caf_packet_table( + u64::try_from(packets.len()).unwrap(), + u64::try_from(packets.len()).unwrap() * 4_096, + 0, + 0, + &packets + .iter() + .map(|packet| u32::try_from(packet.len()).unwrap()) + .collect::>(), + ); + bytes.extend_from_slice(b"pakt"); + bytes.extend_from_slice(&u64::try_from(packet_table.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(&packet_table); + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_mhas_file(prefix: &str, frame_payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&build_mhas_packet(6, &[0xA5])); + bytes.extend_from_slice(&build_mhas_packet(1, &build_test_mhas_config_payload())); + for payload in frame_payloads { + let mut frame_payload = Vec::with_capacity(payload.len() + 1); + frame_payload.push(0x80); + frame_payload.extend_from_slice(payload); + bytes.extend_from_slice(&build_mhas_packet(2, &frame_payload)); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_h265_annexb_file(prefix: &str, sample_payloads: &[&[u8]]) -> PathBuf { + write_temp_file(prefix, &build_test_h265_annexb_bytes(sample_payloads)) +} + +#[cfg(feature = "mux")] +pub fn write_test_h264_annexb_file(prefix: &str, sample_payloads: &[&[u8]]) -> PathBuf { + write_temp_file(prefix, &build_test_h264_annexb_bytes(sample_payloads)) +} + +#[cfg(feature = "mux")] +fn build_test_h264_annexb_bytes(sample_payloads: &[&[u8]]) -> Vec { + build_test_h264_annexb_chunks(sample_payloads) + .into_iter() + .flatten() + .collect() +} + +#[cfg(feature = "mux")] +fn build_test_h264_annexb_chunks(sample_payloads: &[&[u8]]) -> Vec> { + const START_CODE: &[u8] = &[0, 0, 0, 1]; + const SPS: &[u8] = &[ + 0x67, 0x64, 0x00, 0x0c, 0xac, 0xd9, 0x41, 0x41, 0x9f, 0x9f, 0x01, 0x6c, 0x80, 0x00, 0x00, + 0x03, 0x00, 0x80, 0x00, 0x00, 0x0a, 0x07, 0x8a, 0x14, 0xcb, + ]; + const PPS: &[u8] = &[0x68, 0xeb, 0xec, 0xb2, 0x2c]; + const AUD: &[u8] = &[0x09, 0xf0]; + + let mut chunks = Vec::with_capacity(sample_payloads.len()); + for (index, payload) in sample_payloads.iter().enumerate() { + let mut chunk = Vec::new(); + if index == 0 { + for nal in [SPS, PPS] { + chunk.extend_from_slice(START_CODE); + chunk.extend_from_slice(nal); + } + } else { + chunk.extend_from_slice(START_CODE); + chunk.extend_from_slice(AUD); + } + chunk.extend_from_slice(START_CODE); + chunk.extend_from_slice(&[0x65, 0x80]); + chunk.extend_from_slice(payload); + chunks.push(chunk); + } + chunks +} + +#[cfg(feature = "mux")] +fn build_test_h264_avc1_chunks(sample_payloads: &[&[u8]]) -> Vec> { + sample_payloads + .iter() + .enumerate() + .map(|(index, payload)| { + let nal = if index == 0 { + build_test_h264_idr_nal(payload) + } else { + build_test_h264_non_idr_nal(payload) + }; + let mut chunk = Vec::with_capacity(4 + nal.len()); + chunk.extend_from_slice(&u32::try_from(nal.len()).unwrap().to_be_bytes()); + chunk.extend_from_slice(&nal); + chunk + }) + .collect() +} + +#[cfg(feature = "mux")] +fn build_test_h264_idr_nal(payload: &[u8]) -> Vec { + let mut nal = Vec::with_capacity(payload.len() + 2); + nal.extend_from_slice(&[0x65, 0x80]); + nal.extend_from_slice(payload); + nal +} + +#[cfg(feature = "mux")] +fn build_test_h264_non_idr_nal(payload: &[u8]) -> Vec { + let mut nal = Vec::with_capacity(payload.len() + 2); + nal.extend_from_slice(&[0x41, 0x80]); + nal.extend_from_slice(payload); + nal +} + +#[cfg(feature = "mux")] +fn build_test_avcc_decoder_specific_info() -> Vec { + const SPS: &[u8] = &[ + 0x67, 0x64, 0x00, 0x0c, 0xac, 0xd9, 0x41, 0x41, 0x9f, 0x9f, 0x01, 0x6c, 0x80, 0x00, 0x00, + 0x03, 0x00, 0x80, 0x00, 0x00, 0x0a, 0x07, 0x8a, 0x14, 0xcb, + ]; + const PPS: &[u8] = &[0x68, 0xeb, 0xec, 0xb2, 0x2c]; + + let mut bytes = vec![1, SPS[1], SPS[2], SPS[3], 0xFF, 0xE1]; + bytes.extend_from_slice(&u16::try_from(SPS.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(SPS); + bytes.push(1); + bytes.extend_from_slice(&u16::try_from(PPS.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(PPS); + bytes +} + +#[cfg(feature = "mux")] +pub fn write_test_h263_file(prefix: &str, sample_payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + for (index, payload) in sample_payloads.iter().enumerate() { + bytes.extend_from_slice(&build_test_h263_frame( + u8::try_from(index).unwrap(), + payload, + )); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +fn build_test_h263_frame(temporal_reference: u8, payload: &[u8]) -> Vec { + let mut writer = BitWriter::new(Vec::new()); + write_test_bits_u64(&mut writer, 0x20, 22); + write_test_bits_u64(&mut writer, u64::from(temporal_reference), 8); + write_test_bits_u64(&mut writer, 0, 5); + write_test_bits_u64(&mut writer, 2, 3); + write_test_bits_u64(&mut writer, 0, 2); + let mut bytes = writer.into_inner().unwrap(); + bytes.extend_from_slice(payload); + bytes +} + +#[cfg(feature = "mux")] +pub fn write_test_h265_annexb_file_with_timing(prefix: &str, sample_payloads: &[&[u8]]) -> PathBuf { + write_test_h265_annexb_file_with_sps( + prefix, + &[ + 0x42, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, + 0x00, 0x03, 0x00, 0x5d, 0xa0, 0x02, 0x80, 0x80, 0x24, 0x1f, 0x26, 0x59, 0x99, 0xa4, + 0x93, 0x2b, 0xff, 0xc0, 0xd5, 0xc0, 0xd6, 0x40, 0x40, 0x00, 0x00, 0x03, 0x00, 0x40, + 0x00, 0x00, 0x06, 0x02, + ], + sample_payloads, + ) +} + +#[cfg(feature = "mux")] +fn write_test_h265_annexb_file_with_sps( + prefix: &str, + sps: &[u8], + sample_payloads: &[&[u8]], +) -> PathBuf { + write_temp_file( + prefix, + &build_test_h265_annexb_bytes_with_sps(sps, sample_payloads), + ) +} + +#[cfg(feature = "mux")] +fn build_test_h265_annexb_bytes(sample_payloads: &[&[u8]]) -> Vec { + build_test_h265_annexb_bytes_with_sps( + &[ + 0x42, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, + 0x00, 0x03, 0x00, 0x78, 0xa0, 0x03, 0xc0, 0x80, 0x10, 0xe5, 0x96, 0x66, 0x69, 0x24, + 0xca, 0xe0, 0x10, 0x00, 0x00, 0x03, 0x00, 0x10, 0x00, 0x00, 0x03, 0x01, 0xe0, 0x80, + ], + sample_payloads, + ) +} + +#[cfg(feature = "mux")] +fn build_test_h265_annexb_bytes_with_sps(sps: &[u8], sample_payloads: &[&[u8]]) -> Vec { + const START_CODE: &[u8] = &[0, 0, 0, 1]; + const AUD: &[u8] = &[0x46, 0x01, 0x50]; + const VPS: &[u8] = &[ + 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, + 0x03, 0x00, 0x00, 0x03, 0x00, 0x78, 0x99, 0x98, 0x09, + ]; + const PPS: &[u8] = &[0x44, 0x01, 0xc1, 0x72, 0xb4, 0x62, 0x40]; + + let mut bytes = Vec::new(); + for nal in [VPS, sps, PPS] { + bytes.extend_from_slice(START_CODE); + bytes.extend_from_slice(nal); + } + for (index, payload) in sample_payloads.iter().enumerate() { + if index != 0 { + bytes.extend_from_slice(START_CODE); + bytes.extend_from_slice(AUD); + } + bytes.extend_from_slice(START_CODE); + bytes.extend_from_slice(&[0x26, 0x01]); + bytes.extend_from_slice(payload); + } + bytes +} + +#[cfg(feature = "mux")] +pub fn write_test_av1_ivf_file( + prefix: &str, + width: u16, + height: u16, + frame_timestamps: &[u64], + frame_payloads: &[&[u8]], +) -> PathBuf { + write_test_ivf_file( + prefix, + *b"AV01", + IvfHeaderFields { + width, + height, + timescale: 1_000, + timestamp_scale: 1, + }, + frame_timestamps, + frame_payloads, + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_av1_obu_file(prefix: &str, frame_payloads: &[&[u8]]) -> PathBuf { + let mut bytes = Vec::new(); + for payload in frame_payloads { + bytes.extend_from_slice(&build_test_av1_temporal_delimiter_obu()); + bytes.extend_from_slice(payload); + } + write_temp_file_with_extension(prefix, "obu", &bytes) +} + +#[cfg(feature = "mux")] +pub fn write_test_av1_annex_b_file(prefix: &str, frame_payloads: &[&[u8]]) -> PathBuf { + write_temp_file_with_extension( + prefix, + "av1b", + &build_test_av1_annex_b_file_bytes(frame_payloads), + ) +} + +#[cfg(feature = "mux")] +pub fn build_test_av1_temporal_delimiter_obu() -> Vec { + vec![0x12, 0x00] +} + +#[cfg(feature = "mux")] +pub fn build_test_av1_annex_b_file_bytes(frame_payloads: &[&[u8]]) -> Vec { + let mut bytes = Vec::new(); + for payload in frame_payloads { + let obu_units = split_test_av1_obu_units(payload); + let frame_unit_payload = obu_units + .iter() + .flat_map(|obu| { + let mut bytes = encode_test_leb128(u32::try_from(obu.len()).unwrap()); + bytes.extend_from_slice(obu); + bytes + }) + .collect::>(); + let mut temporal_unit_payload = + encode_test_leb128(u32::try_from(frame_unit_payload.len()).unwrap()); + temporal_unit_payload.extend_from_slice(&frame_unit_payload); + bytes.extend_from_slice(&encode_test_leb128( + u32::try_from(temporal_unit_payload.len()).unwrap(), + )); + bytes.extend_from_slice(&temporal_unit_payload); + } + bytes +} + +#[cfg(feature = "mux")] +pub fn write_test_vp8_ivf_file( + prefix: &str, + width: u16, + height: u16, + frame_timestamps: &[u64], + frame_payloads: &[&[u8]], +) -> PathBuf { + write_test_ivf_file( + prefix, + *b"VP80", + IvfHeaderFields { + width, + height, + timescale: 1_000, + timestamp_scale: 1, + }, + frame_timestamps, + frame_payloads, + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_vp9_ivf_file( + prefix: &str, + width: u16, + height: u16, + frame_timestamps: &[u64], + frame_payloads: &[&[u8]], +) -> PathBuf { + write_test_ivf_file( + prefix, + *b"VP90", + IvfHeaderFields { + width, + height, + timescale: 1_000, + timestamp_scale: 1, + }, + frame_timestamps, + frame_payloads, + ) +} + +#[cfg(feature = "mux")] +pub fn write_test_vp10_ivf_file( + prefix: &str, + width: u16, + height: u16, + frame_timestamps: &[u64], + frame_payloads: &[&[u8]], +) -> PathBuf { + write_test_ivf_file( + prefix, + *b"VP10", + IvfHeaderFields { + width, + height, + timescale: 1_000, + timestamp_scale: 1, + }, + frame_timestamps, + frame_payloads, + ) +} + +#[cfg(feature = "mux")] +pub fn build_test_av1_sequence_header_obu(width: u16, height: u16) -> Vec { + let mut payload_writer = BitWriter::new(Vec::new()); + write_test_bits_u64(&mut payload_writer, 0, 3); + payload_writer.write_bit(true).unwrap(); + payload_writer.write_bit(true).unwrap(); + write_test_bits_u64(&mut payload_writer, 0, 5); + write_test_bits_u64(&mut payload_writer, 9, 4); + write_test_bits_u64(&mut payload_writer, 8, 4); + write_test_bits_u64(&mut payload_writer, u64::from(width.saturating_sub(1)), 10); + write_test_bits_u64(&mut payload_writer, u64::from(height.saturating_sub(1)), 9); + payload_writer.write_bit(false).unwrap(); + payload_writer.write_bit(false).unwrap(); + payload_writer.write_bit(false).unwrap(); + payload_writer.write_bit(false).unwrap(); + payload_writer.write_bit(false).unwrap(); + payload_writer.write_bit(false).unwrap(); + payload_writer.write_bit(false).unwrap(); + payload_writer.write_bit(false).unwrap(); + payload_writer.write_bit(false).unwrap(); + payload_writer.write_bit(false).unwrap(); + payload_writer.write_bit(false).unwrap(); + write_test_bits_u64(&mut payload_writer, 0, 2); + payload_writer.write_bit(false).unwrap(); + payload_writer.write_bit(false).unwrap(); + align_test_bit_writer(&mut payload_writer); + let payload = payload_writer.into_inner().unwrap(); + + let mut obu = Vec::with_capacity(2 + payload.len()); + obu.push(0x0A); + obu.push(u8::try_from(payload.len()).unwrap()); + obu.extend_from_slice(&payload); + obu +} + +#[cfg(feature = "mux")] +fn split_test_av1_obu_units(sample_payload: &[u8]) -> Vec> { + let mut units = Vec::new(); + let mut offset = 0usize; + while offset < sample_payload.len() { + let header = sample_payload[offset]; + let extension_flag = (header >> 2) & 0x01 != 0; + let has_size_field = (header >> 1) & 0x01 != 0; + assert!( + has_size_field, + "test AV1 OBU payloads must use explicit size fields" + ); + let mut cursor = offset + 1; + if extension_flag { + cursor += 1; + } + let (payload_size, leb_size) = decode_test_leb128(&sample_payload[cursor..]); + cursor += leb_size; + let obu_end = cursor + usize::try_from(payload_size).unwrap(); + units.push(sample_payload[offset..obu_end].to_vec()); + offset = obu_end; + } + units +} + +#[cfg(feature = "mux")] +fn encode_test_leb128(mut value: u32) -> Vec { + let mut bytes = Vec::new(); + loop { + let mut byte = u8::try_from(value & 0x7F).unwrap(); + value >>= 7; + if value != 0 { + byte |= 0x80; + } + bytes.push(byte); + if value == 0 { + return bytes; + } + } +} + +#[cfg(feature = "mux")] +fn decode_test_leb128(bytes: &[u8]) -> (u32, usize) { + let mut value = 0u32; + let mut shift = 0u32; + for (index, byte) in bytes.iter().copied().enumerate() { + value |= u32::from(byte & 0x7F) << shift; + if byte & 0x80 == 0 { + return (value, index + 1); + } + shift += 7; + } + panic!("unterminated test leb128"); +} + +#[cfg(feature = "mux")] +pub fn build_test_vp8_keyframe(width: u16, height: u16, profile: u8, payload: &[u8]) -> Vec { + let mut frame = Vec::with_capacity(10 + payload.len()); + let first_partition_size = u32::try_from(payload.len()).unwrap(); + let frame_tag = + (u32::from(profile & 0x07) << 1) | (1 << 4) | ((first_partition_size & 0x7FFFF) << 5); + frame.extend_from_slice(&[ + u8::try_from(frame_tag & 0xFF).unwrap(), + u8::try_from((frame_tag >> 8) & 0xFF).unwrap(), + u8::try_from((frame_tag >> 16) & 0xFF).unwrap(), + ]); + frame.extend_from_slice(&[0x9D, 0x01, 0x2A]); + frame.extend_from_slice(&(width & 0x3FFF).to_le_bytes()); + frame.extend_from_slice(&(height & 0x3FFF).to_le_bytes()); + frame.extend_from_slice(payload); + frame +} + +#[cfg(feature = "mux")] +pub fn build_test_vp9_keyframe(width: u16, height: u16, profile: u8) -> Vec { + let mut writer = BitWriter::new(Vec::new()); + write_test_bits_u64(&mut writer, 0b10, 2); + writer.write_bit(profile & 0x01 != 0).unwrap(); + writer.write_bit(profile & 0x02 != 0).unwrap(); + if profile == 3 { + writer.write_bit(false).unwrap(); + } + writer.write_bit(false).unwrap(); + writer.write_bit(false).unwrap(); + writer.write_bit(true).unwrap(); + writer.write_bit(false).unwrap(); + write_test_bits_u64(&mut writer, 0x49_83_42, 24); + if profile >= 2 { + writer.write_bit(false).unwrap(); + } + write_test_bits_u64(&mut writer, 1, 3); + writer.write_bit(false).unwrap(); + write_test_bits_u64(&mut writer, u64::from(width.saturating_sub(1)), 16); + write_test_bits_u64(&mut writer, u64::from(height.saturating_sub(1)), 16); + writer.write_bit(false).unwrap(); + align_test_bit_writer(&mut writer); + writer.into_inner().unwrap() +} + +#[cfg(feature = "mux")] +pub fn build_test_vp10_keyframe(width: u16, height: u16, profile: u8) -> Vec { + build_test_vp9_keyframe(width, height, profile) +} + +#[cfg(feature = "mux")] +struct IvfHeaderFields { + width: u16, + height: u16, + timescale: u32, + timestamp_scale: u32, +} + +#[cfg(feature = "mux")] +fn write_test_ivf_file( + prefix: &str, + codec_fourcc: [u8; 4], + header: IvfHeaderFields, + frame_timestamps: &[u64], + frame_payloads: &[&[u8]], +) -> PathBuf { + assert_eq!(frame_timestamps.len(), frame_payloads.len()); + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"DKIF"); + bytes.extend_from_slice(&0_u16.to_le_bytes()); + bytes.extend_from_slice(&32_u16.to_le_bytes()); + bytes.extend_from_slice(&codec_fourcc); + bytes.extend_from_slice(&header.width.to_le_bytes()); + bytes.extend_from_slice(&header.height.to_le_bytes()); + bytes.extend_from_slice(&header.timescale.to_le_bytes()); + bytes.extend_from_slice(&header.timestamp_scale.to_le_bytes()); + bytes.extend_from_slice(&u32::try_from(frame_payloads.len()).unwrap().to_le_bytes()); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + for (timestamp, payload) in frame_timestamps.iter().zip(frame_payloads.iter()) { + bytes.extend_from_slice(&u32::try_from(payload.len()).unwrap().to_le_bytes()); + bytes.extend_from_slice(×tamp.to_le_bytes()); + bytes.extend_from_slice(payload); + } + write_temp_file(prefix, &bytes) +} + +#[cfg(feature = "mux")] +fn build_flac_streaminfo_block( + sample_rate: u32, + channel_count: u8, + bits_per_sample: u8, + total_samples: u64, +) -> [u8; 34] { + assert!(sample_rate > 0 && sample_rate < (1 << 20)); + assert!((1..=8).contains(&channel_count)); + assert!((1..=32).contains(&bits_per_sample)); + assert!(total_samples < (1_u64 << 36)); + + let mut block = [0_u8; 34]; + block[0..2].copy_from_slice(&0x0400_u16.to_be_bytes()); + block[2..4].copy_from_slice(&0x0400_u16.to_be_bytes()); + block[10] = u8::try_from((sample_rate >> 12) & 0xFF).unwrap(); + block[11] = u8::try_from((sample_rate >> 4) & 0xFF).unwrap(); + block[12] = (u8::try_from(sample_rate & 0x0F).unwrap() << 4) + | (((channel_count - 1) & 0x07) << 1) + | (((bits_per_sample - 1) >> 4) & 0x01); + block[13] = + (((bits_per_sample - 1) & 0x0F) << 4) | u8::try_from((total_samples >> 32) & 0x0F).unwrap(); + block[14] = u8::try_from((total_samples >> 24) & 0xFF).unwrap(); + block[15] = u8::try_from((total_samples >> 16) & 0xFF).unwrap(); + block[16] = u8::try_from((total_samples >> 8) & 0xFF).unwrap(); + block[17] = u8::try_from(total_samples & 0xFF).unwrap(); + block +} + +#[cfg(feature = "mux")] +fn build_test_amr_frame(payload: &[u8]) -> Vec { + build_test_amr_like_frame(7, 31, payload) +} + +#[cfg(feature = "mux")] +fn build_test_latm_frame(use_same_stream_mux: bool, payload: &[u8]) -> Vec { + build_test_latm_frame_with_audio_object_type(use_same_stream_mux, payload, 2) +} + +#[cfg(feature = "mux")] +fn build_test_usac_latm_frame(use_same_stream_mux: bool, payload: &[u8]) -> Vec { + build_test_latm_frame_with_audio_object_type(use_same_stream_mux, payload, 42) +} + +#[cfg(feature = "mux")] +fn build_test_latm_frame_with_audio_object_type( + use_same_stream_mux: bool, + payload: &[u8], + audio_object_type: u8, +) -> Vec { + build_test_latm_frame_with_options( + use_same_stream_mux, + payload, + audio_object_type, + false, + false, + ) +} + +#[cfg(feature = "mux")] +fn build_test_latm_frame_with_options( + use_same_stream_mux: bool, + payload: &[u8], + audio_object_type: u8, + other_data_present: bool, + crc_check_present: bool, +) -> Vec { + let mut writer = BitWriter::new(Vec::new()); + writer.write_bit(use_same_stream_mux).unwrap(); + if !use_same_stream_mux { + writer.write_bit(false).unwrap(); + writer.write_bit(true).unwrap(); + write_test_bits_u64(&mut writer, 0, 6); + write_test_bits_u64(&mut writer, 0, 4); + write_test_bits_u64(&mut writer, 0, 3); + write_test_latm_audio_specific_config(&mut writer, audio_object_type, 3, 2); + write_test_bits_u64(&mut writer, 0, 3); + write_test_bits_u64(&mut writer, 0, 8); + writer.write_bit(other_data_present).unwrap(); + writer.write_bit(crc_check_present).unwrap(); + } + write_test_latm_payload_length(&mut writer, payload.len()); + for byte in payload { + write_test_bits_u64(&mut writer, u64::from(*byte), 8); + } + align_test_bit_writer(&mut writer); + let body = writer.into_inner().unwrap(); + let mux_size = u16::try_from(body.len()).unwrap(); + assert!(mux_size < 0x2000); + + let mut frame = Vec::with_capacity(3 + body.len()); + frame.push(0x56); + frame.push(0xE0 | u8::try_from((mux_size >> 8) & 0x1F).unwrap()); + frame.push(u8::try_from(mux_size & 0x00FF).unwrap()); + frame.extend_from_slice(&body); + frame +} + +#[cfg(feature = "mux")] +fn write_test_latm_audio_specific_config( + writer: &mut BitWriter>, + audio_object_type: u8, + sample_rate_index: u8, + channel_configuration: u8, +) { + if audio_object_type >= 32 { + write_test_bits_u64(writer, 31, 5); + write_test_bits_u64(writer, u64::from(audio_object_type - 32), 6); + } else { + write_test_bits_u64(writer, u64::from(audio_object_type), 5); + } + write_test_bits_u64(writer, u64::from(sample_rate_index), 4); + write_test_bits_u64(writer, u64::from(channel_configuration), 4); + write_test_bits_u64(writer, 0, 3); +} + +#[cfg(feature = "mux")] +fn write_test_latm_payload_length(writer: &mut BitWriter>, payload_len: usize) { + let mut remaining = payload_len; + while remaining >= 255 { + write_test_bits_u64(writer, 255, 8); + remaining -= 255; + } + write_test_bits_u64(writer, u64::try_from(remaining).unwrap(), 8); +} + +#[cfg(feature = "mux")] +fn build_test_truehd_frame(payload: &[u8]) -> Vec { + const TRUEHD_TEST_FRAME_HEADER_BYTES: usize = 20; + + let frame_size = u16::try_from(TRUEHD_TEST_FRAME_HEADER_BYTES + payload.len()).unwrap(); + assert_eq!(frame_size & 1, 0, "TrueHD test frame size must be even"); + + let mut writer = BitWriter::new(Vec::new()); + write_test_bits_u64(&mut writer, 0, 4); + write_test_bits_u64(&mut writer, u64::from(frame_size / 2), 12); + write_test_bits_u64(&mut writer, 0, 16); + write_test_bits_u64(&mut writer, 0xF872_6FBA, 32); + write_test_bits_u64(&mut writer, 0, 4); + write_test_bits_u64(&mut writer, 0, 1); + write_test_bits_u64(&mut writer, 0, 1); + write_test_bits_u64(&mut writer, 0, 2); + write_test_bits_u64(&mut writer, 0, 2); + write_test_bits_u64(&mut writer, 0, 2); + write_test_bits_u64(&mut writer, 0, 5); + write_test_bits_u64(&mut writer, 0, 2); + write_test_bits_u64(&mut writer, 0, 13); + write_test_bits_u64(&mut writer, 0xB752, 16); + write_test_bits_u64(&mut writer, 0, 16); + write_test_bits_u64(&mut writer, 0, 16); + write_test_bits_u64(&mut writer, 0, 1); + write_test_bits_u64(&mut writer, 120, 15); + align_test_bit_writer(&mut writer); + let mut frame = writer.into_inner().unwrap(); + assert_eq!(frame.len(), TRUEHD_TEST_FRAME_HEADER_BYTES); + frame.extend_from_slice(payload); + frame +} + +#[cfg(feature = "mux")] +fn build_test_amr_wb_frame(payload: &[u8]) -> Vec { + build_test_amr_like_frame(8, 60, payload) +} + +#[cfg(feature = "mux")] +struct TestQcpFileSpec<'a> { + codec: TestQcpCodecKind, + decoder_version: u8, + packet_size: u16, + block_size: u16, + sample_rate: u16, + rate_entries: &'a [(u8, u8)], + rate_flag: u32, +} + +#[cfg(feature = "mux")] +fn build_test_qcp_file_bytes(spec: TestQcpFileSpec<'_>, packets: &[Vec]) -> Vec { + let mut fmt_payload = Vec::with_capacity(150); + fmt_payload.push(1); + fmt_payload.push(0); + fmt_payload.extend_from_slice(test_qcp_codec_guid(spec.codec)); + fmt_payload.extend_from_slice(&u16::from(spec.decoder_version).to_le_bytes()); + let mut name = [0_u8; 80]; + let label: &[u8] = match spec.codec { + TestQcpCodecKind::Qcelp => b"QCELP", + TestQcpCodecKind::Evrc => b"EVRC", + TestQcpCodecKind::Smv => b"SMV", + }; + name[..label.len()].copy_from_slice(label); + fmt_payload.extend_from_slice(&name); + let avg_bps = if packets.is_empty() { + 0 + } else { + let avg = packets + .iter() + .map(|packet| packet.len() as u64) + .sum::() + * 8 + * u64::from(spec.sample_rate) + / (u64::from(spec.block_size) * u64::try_from(packets.len()).unwrap()); + u16::try_from(avg).unwrap_or(u16::MAX) + }; + fmt_payload.extend_from_slice(&avg_bps.to_le_bytes()); + fmt_payload.extend_from_slice(&spec.packet_size.to_le_bytes()); + fmt_payload.extend_from_slice(&spec.block_size.to_le_bytes()); + fmt_payload.extend_from_slice(&spec.sample_rate.to_le_bytes()); + fmt_payload.extend_from_slice(&16_u16.to_le_bytes()); + fmt_payload.extend_from_slice( + &u32::try_from(spec.rate_entries.len()) + .unwrap() + .to_le_bytes(), + ); + for index in 0..8 { + if let Some((rate_index, payload_size)) = spec.rate_entries.get(index) { + fmt_payload.push(*payload_size); + fmt_payload.push(*rate_index); + } else { + fmt_payload.extend_from_slice(&[0, 0]); + } + } + fmt_payload.extend_from_slice(&[0_u8; 20]); + debug_assert_eq!(fmt_payload.len(), 150); + + let mut vrat_payload = Vec::with_capacity(8); + vrat_payload.extend_from_slice(&spec.rate_flag.to_le_bytes()); + vrat_payload.extend_from_slice(&u32::from(spec.packet_size).to_le_bytes()); + + let mut data_payload = Vec::new(); + for packet in packets { + data_payload.extend_from_slice(packet); + } + + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(b"QLCM"); + append_test_riff_chunk(&mut bytes, b"fmt ", &fmt_payload); + append_test_riff_chunk(&mut bytes, b"vrat", &vrat_payload); + append_test_riff_chunk(&mut bytes, b"data", &data_payload); + let riff_size = u32::try_from(bytes.len() - 8).unwrap(); + bytes[4..8].copy_from_slice(&riff_size.to_le_bytes()); + bytes +} + +#[cfg(feature = "mux")] +fn append_test_riff_chunk(bytes: &mut Vec, chunk_type: &[u8; 4], payload: &[u8]) { + bytes.extend_from_slice(chunk_type); + bytes.extend_from_slice(&u32::try_from(payload.len()).unwrap().to_le_bytes()); + bytes.extend_from_slice(payload); + if !payload.len().is_multiple_of(2) { + bytes.push(0); + } +} + +#[cfg(feature = "mux")] +fn test_qcp_codec_guid(codec: TestQcpCodecKind) -> &'static [u8; 16] { + match codec { + TestQcpCodecKind::Qcelp => { + b"\x41\x6D\x7F\x5E\x15\xB1\xD0\x11\xBA\x91\x00\x80\x5F\xB4\xB9\x7E" + } + TestQcpCodecKind::Evrc => { + b"\x8D\xD4\x89\xE6\x76\x90\xB5\x46\x91\xEF\x73\x6A\x51\x00\xCE\xB4" + } + TestQcpCodecKind::Smv => { + b"\x75\x2B\x7C\x8D\x97\xA7\x46\xED\x98\x5E\xD5\x3C\x8C\xC7\x5F\x84" + } + } +} + +#[cfg(feature = "mux")] +fn build_test_amr_like_frame(frame_type: u8, payload_len: usize, payload: &[u8]) -> Vec { + let mut frame = Vec::with_capacity(1 + payload_len); + frame.push((frame_type & 0x0F) << 3); + frame.extend((0..payload_len).map(|index| payload.get(index).copied().unwrap_or(index as u8))); + frame +} + +#[cfg(feature = "mux")] +fn build_flac_vorbis_comment_block() -> Vec { + let mut block = Vec::new(); + block.push(0x84); + block.extend_from_slice(&8_u32.to_be_bytes()[1..]); + block.extend_from_slice(&0_u32.to_le_bytes()); + block.extend_from_slice(&0_u32.to_le_bytes()); + block +} + +#[cfg(feature = "mux")] +pub fn build_test_flac_frame(seed_payload: &[u8]) -> Vec { + build_test_flac_frame_with_block_size(seed_payload, 1_024) +} + +#[cfg(feature = "mux")] +pub fn build_test_flac_frame_with_block_size(seed_payload: &[u8], block_size: u32) -> Vec { + assert!((1..=u32::from(u16::MAX) + 1).contains(&block_size)); + let mut writer = BitWriter::new(Vec::new()); + write_test_bits_u64(&mut writer, 0x7FFC, 15); + writer.write_bit(false).unwrap(); + if block_size == 1_024 { + write_test_bits_u64(&mut writer, 10, 4); + } else { + write_test_bits_u64(&mut writer, 7, 4); + } + write_test_bits_u64(&mut writer, 0, 4); + write_test_bits_u64(&mut writer, 1, 4); + write_test_bits_u64(&mut writer, 4, 3); + writer.write_bit(false).unwrap(); + write_test_bits_u64(&mut writer, 0, 8); + if block_size != 1_024 { + write_test_bits_u64(&mut writer, u64::from(block_size - 1), 16); + } + align_test_bit_writer(&mut writer); + let mut frame = writer.into_inner().unwrap(); + let header_crc = flac_crc8_for_test(&frame); + frame.push(header_crc); + + let left_sample = u16::from(*seed_payload.first().unwrap_or(&0x11)); + let right_sample = u16::from(*seed_payload.get(1).unwrap_or(&0x22)); + + let mut subframe_writer = BitWriter::new(Vec::new()); + for sample in [left_sample, right_sample] { + subframe_writer.write_bit(false).unwrap(); + write_test_bits_u64(&mut subframe_writer, 0, 6); + subframe_writer.write_bit(false).unwrap(); + write_test_bits_u64(&mut subframe_writer, u64::from(sample), 16); + } + align_test_bit_writer(&mut subframe_writer); + frame.extend_from_slice(&subframe_writer.into_inner().unwrap()); + let footer_crc = flac_crc16_for_test(&frame); + frame.extend_from_slice(&footer_crc.to_be_bytes()); + frame +} + +#[cfg(feature = "mux")] +fn build_test_mhas_config_payload() -> Vec { + let mut writer = BitWriter::new(Vec::new()); + write_test_bits_u64(&mut writer, 12, 8); + write_test_bits_u64(&mut writer, 3, 5); + write_test_bits_u64(&mut writer, 1, 3); + writer.write_bit(false).unwrap(); + writer.write_bit(false).unwrap(); + write_test_bits_u64(&mut writer, 1, 2); + write_test_mhas_escaped_value(&mut writer, 1, 5, 8, 16); + align_test_bit_writer(&mut writer); + writer.into_inner().unwrap() +} + +#[cfg(feature = "mux")] +fn build_mhas_packet(packet_type: u64, payload: &[u8]) -> Vec { + let mut writer = BitWriter::new(Vec::new()); + write_test_mhas_escaped_value(&mut writer, packet_type, 3, 8, 8); + write_test_mhas_escaped_value(&mut writer, 0, 2, 8, 32); + write_test_mhas_escaped_value( + &mut writer, + u64::try_from(payload.len()).unwrap(), + 11, + 24, + 24, + ); + align_test_bit_writer(&mut writer); + let mut packet = writer.into_inner().unwrap(); + packet.extend_from_slice(payload); + packet +} + +#[cfg(feature = "mux")] +fn write_test_mhas_escaped_value( + writer: &mut BitWriter>, + value: u64, + first_width: usize, + second_width: usize, + third_width: usize, +) { + let first_max = (1_u64 << first_width) - 1; + if value < first_max { + write_test_bits_u64(writer, value, first_width); + return; + } + write_test_bits_u64(writer, first_max, first_width); + let remainder = value - first_max; + let second_max = (1_u64 << second_width) - 1; + if remainder < second_max { + write_test_bits_u64(writer, remainder, second_width); + return; + } + write_test_bits_u64(writer, second_max, second_width); + write_test_bits_u64(writer, remainder - second_max, third_width); +} + +#[cfg(feature = "mux")] +fn write_test_bits_u64(writer: &mut BitWriter>, value: u64, width: usize) { + writer.write_bits(&value.to_be_bytes(), width).unwrap(); +} + +#[cfg(feature = "mux")] +fn align_test_bit_writer(writer: &mut BitWriter>) { + while !writer.is_aligned() { + writer.write_bit(false).unwrap(); + } +} + +#[cfg(feature = "mux")] +fn build_opus_head_packet(channel_count: u8) -> Vec { + let mut packet = Vec::with_capacity(19); + packet.extend_from_slice(b"OpusHead"); + packet.push(1); + packet.push(channel_count); + packet.extend_from_slice(&312_u16.to_le_bytes()); + packet.extend_from_slice(&48_000_u32.to_le_bytes()); + packet.extend_from_slice(&0_i16.to_le_bytes()); + packet.push(0); + packet +} + +#[cfg(feature = "mux")] +fn build_vorbis_identification_packet() -> Vec { + let mut packet = Vec::with_capacity(30); + packet.push(0x01); + packet.extend_from_slice(b"vorbis"); + packet.extend_from_slice(&0_u32.to_le_bytes()); + packet.push(2); + packet.extend_from_slice(&48_000_u32.to_le_bytes()); + packet.extend_from_slice(&0_i32.to_le_bytes()); + packet.extend_from_slice(&0_i32.to_le_bytes()); + packet.extend_from_slice(&0_i32.to_le_bytes()); + packet.push(0x76); + packet.push(1); + packet +} + +#[cfg(feature = "mux")] +fn build_vorbis_comment_packet() -> Vec { + let mut packet = Vec::new(); + packet.push(0x03); + packet.extend_from_slice(b"vorbis"); + packet +} + +#[cfg(feature = "mux")] +fn build_vorbis_setup_packet() -> Vec { + let mut packet = Vec::new(); + packet.push(0x05); + packet.extend_from_slice(b"vorbis"); + + let mut writer = TestLsbBitWriter::default(); + writer.write(0, 8); + writer.write(0, 24); + writer.write(1, 16); + writer.write(1, 24); + writer.write(0, 1); + writer.write(0, 1); + writer.write(0, 5); + writer.write(0, 4); + writer.write(0, 6); + writer.write(0, 16); + writer.write(0, 6); + writer.write(0, 16); + writer.write(0, 8); + writer.write(0, 16); + writer.write(0, 16); + writer.write(0, 6); + writer.write(0, 8); + writer.write(0, 4); + writer.write(0, 8); + writer.write(0, 6); + writer.write(0, 16); + writer.write(0, 24); + writer.write(0, 24); + writer.write(0, 24); + writer.write(0, 6); + writer.write(0, 8); + writer.write(0, 3); + writer.write(0, 1); + writer.write(0, 6); + writer.write(0, 16); + writer.write(0, 1); + writer.write(0, 1); + writer.write(0, 2); + writer.write(0, 8); + writer.write(0, 8); + writer.write(0, 8); + writer.write(1, 6); + writer.write(0, 1); + writer.write(0, 16); + writer.write(0, 16); + writer.write(0, 8); + writer.write(1, 1); + writer.write(0, 16); + writer.write(0, 16); + writer.write(0, 8); + + packet.extend_from_slice(&writer.finish()); + packet +} + +#[cfg(feature = "mux")] +fn build_vorbis_audio_packet(payload: &[u8]) -> Vec { + let mut packet = Vec::with_capacity(payload.len() + 1); + packet.push(0x02); + packet.extend_from_slice(payload); + packet +} + +#[cfg(feature = "mux")] +fn build_speex_header_packet() -> Vec { + let mut packet = vec![0_u8; 80]; + packet[..8].copy_from_slice(b"Speex "); + packet[8..28].copy_from_slice(b"mp4forge-test\0\0\0\0\0\0\0"); + packet[28..32].copy_from_slice(&1_u32.to_le_bytes()); + packet[32..36].copy_from_slice(&80_u32.to_le_bytes()); + packet[36..40].copy_from_slice(&16_000_u32.to_le_bytes()); + packet[40..44].copy_from_slice(&0_u32.to_le_bytes()); + packet[44..48].copy_from_slice(&1_u32.to_le_bytes()); + packet[48..52].copy_from_slice(&1_u32.to_le_bytes()); + packet[52..56].copy_from_slice(&0_i32.to_le_bytes()); + packet[56..60].copy_from_slice(&160_u32.to_le_bytes()); + packet[60..64].copy_from_slice(&0_u32.to_le_bytes()); + packet[64..68].copy_from_slice(&1_u32.to_le_bytes()); + packet[68..72].copy_from_slice(&0_u32.to_le_bytes()); + packet[72..76].copy_from_slice(&0_u32.to_le_bytes()); + packet[76..80].copy_from_slice(&0_u32.to_le_bytes()); + packet +} + +#[cfg(feature = "mux")] +fn build_theora_identification_packet(sar_num: u32, sar_den: u32) -> Vec { + let mut packet = vec![0_u8; 42]; + packet[0] = 0x80; + packet[1..7].copy_from_slice(b"theora"); + packet[7] = 3; + packet[10..12].copy_from_slice(&(320_u16 / 16).to_be_bytes()); + packet[12..14].copy_from_slice(&(240_u16 / 16).to_be_bytes()); + packet[22..26].copy_from_slice(&30_000_u32.to_be_bytes()); + packet[26..30].copy_from_slice(&1_001_u32.to_be_bytes()); + packet[30] = u8::try_from((sar_num >> 16) & 0xFF).unwrap(); + packet[31] = u8::try_from((sar_num >> 8) & 0xFF).unwrap(); + packet[32] = u8::try_from(sar_num & 0xFF).unwrap(); + packet[33] = u8::try_from((sar_den >> 16) & 0xFF).unwrap(); + packet[34] = u8::try_from((sar_den >> 8) & 0xFF).unwrap(); + packet[35] = u8::try_from(sar_den & 0xFF).unwrap(); + packet +} + +#[cfg(feature = "mux")] +fn build_theora_comment_packet() -> Vec { + let mut packet = Vec::new(); + packet.push(0x81); + packet.extend_from_slice(b"theora"); + packet +} + +#[cfg(feature = "mux")] +fn build_theora_setup_packet() -> Vec { + let mut packet = Vec::new(); + packet.push(0x82); + packet.extend_from_slice(b"theora"); + packet +} + +#[cfg(feature = "mux")] +fn build_theora_frame_packet(payload: &[u8]) -> Vec { + let mut packet = Vec::with_capacity(payload.len() + 1); + packet.push(0x00); + packet.extend_from_slice(payload); + packet +} + +#[cfg(feature = "mux")] +fn build_test_iamf_sequence_header_payload() -> Vec { + let mut payload = Vec::with_capacity(6); + payload.extend_from_slice(b"iamf"); + payload.push(0); + payload.push(0); + payload +} + +#[cfg(feature = "mux")] +fn build_test_iamf_codec_config_payload() -> Vec { + let mut payload = Vec::new(); + append_leb128_for_test(&mut payload, 0); + payload.extend_from_slice(b"Opus"); + append_leb128_for_test(&mut payload, 960); + payload.extend_from_slice(&0_i16.to_be_bytes()); + payload +} + +#[cfg(feature = "mux")] +fn build_test_iamf_audio_element_payload() -> Vec { + let mut payload = Vec::new(); + append_leb128_for_test(&mut payload, 0); + payload.push(0); + append_leb128_for_test(&mut payload, 0); + append_leb128_for_test(&mut payload, 1); + payload +} + +#[cfg(feature = "mux")] +fn build_test_iamf_obu(obu_type: u8, payload: &[u8]) -> Vec { + let mut bytes = Vec::new(); + bytes.push(obu_type << 3); + append_leb128_for_test(&mut bytes, u64::try_from(payload.len()).unwrap()); + bytes.extend_from_slice(payload); + bytes +} + +#[cfg(feature = "mux")] +fn build_ogg_page( + serial: u32, + sequence_number: u32, + header_type: u8, + granule_position: u64, + packets: &[Vec], +) -> Vec { + let mut lacing_values = Vec::new(); + let mut payload = Vec::new(); + for packet in packets { + let mut remaining = packet.len(); + while remaining >= 255 { + lacing_values.push(255_u8); + remaining -= 255; + } + lacing_values.push(u8::try_from(remaining).unwrap()); + payload.extend_from_slice(packet); + } + + let mut page = Vec::with_capacity(27 + lacing_values.len() + payload.len()); + page.extend_from_slice(b"OggS"); + page.push(0); + page.push(header_type); + page.extend_from_slice(&granule_position.to_le_bytes()); + page.extend_from_slice(&serial.to_le_bytes()); + page.extend_from_slice(&sequence_number.to_le_bytes()); + page.extend_from_slice(&0_u32.to_le_bytes()); + page.push(u8::try_from(lacing_values.len()).unwrap()); + page.extend_from_slice(&lacing_values); + page.extend_from_slice(&payload); + let crc = compute_ogg_page_crc_for_test(&page); + page[22..26].copy_from_slice(&crc.to_le_bytes()); + page +} + +#[cfg(feature = "mux")] +fn compute_ogg_page_crc_for_test(page_bytes: &[u8]) -> u32 { + let mut crc = 0_u32; + for byte in page_bytes { + crc ^= u32::from(*byte) << 24; + for _ in 0..8 { + crc = if crc & 0x8000_0000 != 0 { + (crc << 1) ^ 0x04C1_1DB7 + } else { + crc << 1 + }; + } + } + crc +} + +#[cfg(feature = "mux")] +fn append_leb128_for_test(bytes: &mut Vec, mut value: u64) { + loop { + let mut byte = u8::try_from(value & 0x7F).unwrap(); + value >>= 7; + if value != 0 { + byte |= 0x80; + } + bytes.push(byte); + if value == 0 { + break; + } + } +} -pub fn encode_supported_box(box_value: &B, children: &[u8]) -> Vec -where - B: CodecBox, -{ - let mut payload = Vec::new(); - marshal(&mut payload, box_value, None).unwrap(); - payload.extend_from_slice(children); - encode_raw_box(box_value.box_type(), &payload) +#[cfg(feature = "mux")] +#[derive(Default)] +struct TestLsbBitWriter { + bytes: Vec, + current: u8, + bit_offset: u8, } -pub fn encode_raw_box(box_type: FourCc, payload: &[u8]) -> Vec { - let info = BoxInfo::new(box_type, 8 + payload.len() as u64); - let mut bytes = info.encode(); - bytes.extend_from_slice(payload); +#[cfg(feature = "mux")] +impl TestLsbBitWriter { + fn write(&mut self, mut value: u32, width: u8) { + for _ in 0..width { + if value & 1 != 0 { + self.current |= 1 << self.bit_offset; + } + self.bit_offset += 1; + if self.bit_offset == 8 { + self.bytes.push(self.current); + self.current = 0; + self.bit_offset = 0; + } + value >>= 1; + } + } + + fn finish(mut self) -> Vec { + if self.bit_offset != 0 { + self.bytes.push(self.current); + } + self.bytes + } +} + +#[cfg(feature = "mux")] +fn flac_crc8_for_test(data: &[u8]) -> u8 { + let mut crc = 0_u8; + for byte in data { + crc ^= *byte; + for _ in 0..8 { + crc = if crc & 0x80 != 0 { + (crc << 1) ^ 0x07 + } else { + crc << 1 + }; + } + } + crc +} + +#[cfg(feature = "mux")] +fn flac_crc16_for_test(data: &[u8]) -> u16 { + let mut crc = 0_u16; + for byte in data { + crc ^= u16::from(*byte) << 8; + for _ in 0..8 { + crc = if crc & 0x8000 != 0 { + (crc << 1) ^ 0x8005 + } else { + crc << 1 + }; + } + } + crc +} + +#[cfg(feature = "mux")] +fn build_caf_alac_description_chunk( + bytes_per_packet: u32, + frames_per_packet: u32, + channels_per_frame: u32, + bits_per_channel: u32, + sample_rate: f64, +) -> [u8; 32] { + let mut bytes = [0_u8; 32]; + bytes[..8].copy_from_slice(&sample_rate.to_bits().to_be_bytes()); + bytes[8..12].copy_from_slice(b"alac"); + bytes[16..20].copy_from_slice(&bytes_per_packet.to_be_bytes()); + bytes[20..24].copy_from_slice(&frames_per_packet.to_be_bytes()); + bytes[24..28].copy_from_slice(&channels_per_frame.to_be_bytes()); + bytes[28..32].copy_from_slice(&bits_per_channel.to_be_bytes()); bytes } -pub fn fourcc(value: &str) -> FourCc { - FourCc::try_from(value).unwrap() +#[cfg(feature = "mux")] +fn build_caf_alac_magic_cookie( + frame_length: u32, + bit_depth: u8, + channel_count: u8, + sample_rate: u32, +) -> Vec { + let mut cookie = Vec::new(); + cookie.extend_from_slice(&12_u32.to_be_bytes()); + cookie.extend_from_slice(b"frma"); + cookie.extend_from_slice(b"alac"); + + let mut payload = Vec::with_capacity(28); + payload.extend_from_slice(&0_u32.to_be_bytes()); + payload.extend_from_slice(&frame_length.to_be_bytes()); + payload.push(0); + payload.push(bit_depth); + payload.push(40); + payload.push(10); + payload.push(14); + payload.push(channel_count); + payload.extend_from_slice(&0_u16.to_be_bytes()); + payload.extend_from_slice(&(frame_length * u32::from(channel_count) * 2).to_be_bytes()); + payload.extend_from_slice(&0_u32.to_be_bytes()); + payload.extend_from_slice(&sample_rate.to_be_bytes()); + + cookie.extend_from_slice(&u32::try_from(payload.len() + 8).unwrap().to_be_bytes()); + cookie.extend_from_slice(b"alac"); + cookie.extend_from_slice(&payload); + cookie +} + +#[cfg(feature = "mux")] +fn build_caf_packet_table( + number_packets: u64, + number_valid_frames: u64, + priming_frames: u32, + remainder_frames: u32, + packet_sizes: &[u32], +) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&number_packets.to_be_bytes()); + bytes.extend_from_slice(&number_valid_frames.to_be_bytes()); + bytes.extend_from_slice(&priming_frames.to_be_bytes()); + bytes.extend_from_slice(&remainder_frames.to_be_bytes()); + for packet_size in packet_sizes { + bytes.extend_from_slice(&encode_caf_packet_size_vlint(*packet_size)); + } + bytes } -pub fn write_temp_file(prefix: &str, data: &[u8]) -> PathBuf { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = std::env::temp_dir().join(format!( - "mp4forge-{prefix}-{}-{unique}.mp4", - std::process::id() - )); - fs::write(&path, data).unwrap(); - path +#[cfg(feature = "mux")] +fn encode_caf_packet_size_vlint(value: u32) -> Vec { + let mut parts = Vec::new(); + let mut remaining = value; + parts.push(u8::try_from(remaining & 0x7F).unwrap()); + remaining >>= 7; + while remaining != 0 { + parts.push(u8::try_from(remaining & 0x7F).unwrap() | 0x80); + remaining >>= 7; + } + parts.reverse(); + parts +} + +#[cfg(feature = "mux")] +fn build_adts_frame(payload: &[u8]) -> Vec { + let profile = 1_u8; + let sampling_frequency_index = 4_u8; + let channel_configuration = 2_u8; + let frame_length = payload.len() + 7; + + let mut header = [0_u8; 7]; + header[0] = 0xFF; + header[1] = 0xF1; + header[2] = + (profile << 6) | (sampling_frequency_index << 2) | ((channel_configuration >> 2) & 0x01); + header[3] = + ((channel_configuration & 0x03) << 6) | u8::try_from((frame_length >> 11) & 0x03).unwrap(); + header[4] = u8::try_from((frame_length >> 3) & 0xFF).unwrap(); + header[5] = (u8::try_from(frame_length & 0x07).unwrap() << 5) | 0x1F; + header[6] = 0xFC; + + let mut frame = header.to_vec(); + frame.extend_from_slice(payload); + frame +} + +#[cfg(feature = "mux")] +fn build_mp3_frame(payload: &[u8]) -> Vec { + const FRAME_LENGTH: usize = 384; + assert!(payload.len() <= FRAME_LENGTH - 4); + let mut frame = vec![0_u8; FRAME_LENGTH]; + frame[0] = 0xFF; + frame[1] = 0xFB; + frame[2] = 0x94; + frame[3] = 0x00; + frame[4..4 + payload.len()].copy_from_slice(payload); + frame +} + +#[cfg(feature = "mux")] +fn build_mp2_frame(payload: &[u8]) -> Vec { + const FRAME_LENGTH: usize = 1_152; + assert!(payload.len() <= FRAME_LENGTH - 4); + let mut frame = vec![0_u8; FRAME_LENGTH]; + frame[0] = 0xFF; + frame[1] = 0xFD; + frame[2] = 0xE4; + frame[3] = 0x44; + frame[4..4 + payload.len()].copy_from_slice(payload); + frame +} + +#[cfg(feature = "mux")] +fn build_mp3_frame_44100(payload: &[u8]) -> Vec { + const FRAME_LENGTH: usize = 417; + assert!(payload.len() <= FRAME_LENGTH - 4); + let mut frame = vec![0_u8; FRAME_LENGTH]; + frame[0] = 0xFF; + frame[1] = 0xFB; + frame[2] = 0x90; + frame[3] = 0x00; + frame[4..4 + payload.len()].copy_from_slice(payload); + frame +} + +#[cfg(feature = "mux")] +fn build_id3v2_tag(payload: &[u8]) -> Vec { + assert!(payload.len() <= 0x0FFF_FFFF); + let size = payload.len(); + let mut tag = vec![ + b'I', + b'D', + b'3', + 3, + 0, + 0, + u8::try_from((size >> 21) & 0x7F).unwrap(), + u8::try_from((size >> 14) & 0x7F).unwrap(), + u8::try_from((size >> 7) & 0x7F).unwrap(), + u8::try_from(size & 0x7F).unwrap(), + ]; + tag.extend_from_slice(payload); + tag +} + +#[cfg(feature = "mux")] +fn build_ac3_frame(payload: &[u8]) -> Vec { + const FRAME_LENGTH: usize = 256; + assert!(payload.len() <= FRAME_LENGTH - 7); + let mut frame = vec![0_u8; FRAME_LENGTH]; + frame[0] = 0x0B; + frame[1] = 0x77; + frame[4] = 0x08; + frame[5] = 0x40; + frame[6] = 0x44; + frame[7..7 + payload.len()].copy_from_slice(payload); + frame +} + +#[cfg(feature = "mux")] +fn build_ac3_44100_frame(payload: &[u8]) -> Vec { + const FRAME_LENGTH: usize = 138; + assert!(payload.len() <= FRAME_LENGTH - 7); + let mut frame = vec![0_u8; FRAME_LENGTH]; + frame[0] = 0x0B; + frame[1] = 0x77; + frame[4] = 0x40; + frame[5] = 0x40; + frame[6] = 0x44; + frame[7..7 + payload.len()].copy_from_slice(payload); + frame +} + +#[cfg(feature = "mux")] +fn build_eac3_frame(payload: &[u8]) -> Vec { + const FRAME_LENGTH: usize = 64; + assert!(payload.len() <= FRAME_LENGTH - 6); + let mut header_writer = BitWriter::new(Vec::new()); + header_writer.write_bits(&[0_u8], 2).unwrap(); + header_writer.write_bits(&[0_u8], 3).unwrap(); + header_writer + .write_bits( + &u16::try_from((FRAME_LENGTH / 2) - 1).unwrap().to_be_bytes(), + 11, + ) + .unwrap(); + header_writer.write_bits(&[0_u8], 2).unwrap(); + header_writer.write_bits(&[3_u8], 2).unwrap(); + header_writer.write_bits(&[2_u8], 3).unwrap(); + header_writer.write_bits(&[1_u8], 1).unwrap(); + header_writer.write_bits(&[16_u8], 5).unwrap(); + header_writer.write_bits(&[0_u8], 3).unwrap(); + let header_suffix = header_writer.into_inner().unwrap(); + + let mut frame = vec![0_u8; FRAME_LENGTH]; + frame[0] = 0x0B; + frame[1] = 0x77; + frame[2..2 + header_suffix.len()].copy_from_slice(&header_suffix); + frame[6..6 + payload.len()].copy_from_slice(payload); + frame +} + +#[cfg(feature = "mux")] +fn build_eac3_dependent_substream_frame(payload: &[u8]) -> Vec { + const FRAME_LENGTH: usize = 64; + let mut header_writer = BitWriter::new(Vec::new()); + header_writer.write_bits(&[1_u8], 2).unwrap(); + header_writer.write_bits(&[0_u8], 3).unwrap(); + header_writer + .write_bits( + &u16::try_from((FRAME_LENGTH / 2) - 1).unwrap().to_be_bytes(), + 11, + ) + .unwrap(); + header_writer.write_bits(&[0_u8], 2).unwrap(); + header_writer.write_bits(&[3_u8], 2).unwrap(); + header_writer.write_bits(&[2_u8], 3).unwrap(); + header_writer.write_bits(&[1_u8], 1).unwrap(); + header_writer.write_bits(&[16_u8], 5).unwrap(); + header_writer.write_bits(&[0_u8], 5).unwrap(); + header_writer.write_bits(&[0_u8], 1).unwrap(); + header_writer.write_bits(&[1_u8], 1).unwrap(); + header_writer + .write_bits(&(1_u16 << 9).to_be_bytes(), 16) + .unwrap(); + header_writer.write_bits(&[0_u8], 1).unwrap(); + header_writer.write_bits(&[0_u8], 1).unwrap(); + header_writer.write_bits(&[0_u8], 1).unwrap(); + align_test_bit_writer(&mut header_writer); + let header_suffix = header_writer.into_inner().unwrap(); + assert!(payload.len() <= FRAME_LENGTH - 2 - header_suffix.len()); + + let mut frame = vec![0_u8; FRAME_LENGTH]; + frame[0] = 0x0B; + frame[1] = 0x77; + frame[2..2 + header_suffix.len()].copy_from_slice(&header_suffix); + let payload_offset = 2 + header_suffix.len(); + frame[payload_offset..payload_offset + payload.len()].copy_from_slice(payload); + frame +} + +#[cfg(feature = "mux")] +fn build_dts_frame(seed: usize) -> Vec { + const FRAME_LENGTH: usize = 2_048; + let mut writer = BitWriter::new(Vec::new()); + write_test_bits_u64(&mut writer, 0x7FFE_8001, 32); + write_test_bits_u64(&mut writer, 0, 1); + write_test_bits_u64(&mut writer, 0, 5); + write_test_bits_u64(&mut writer, 0, 1); + write_test_bits_u64(&mut writer, 31, 7); + write_test_bits_u64(&mut writer, u64::try_from(FRAME_LENGTH - 1).unwrap(), 14); + write_test_bits_u64(&mut writer, 2, 6); + write_test_bits_u64(&mut writer, 13, 4); + write_test_bits_u64(&mut writer, 15, 5); + write_test_bits_u64(&mut writer, 0, 1); + write_test_bits_u64(&mut writer, 0, 1); + write_test_bits_u64(&mut writer, 0, 1); + write_test_bits_u64(&mut writer, 0, 1); + write_test_bits_u64(&mut writer, 0, 1); + write_test_bits_u64(&mut writer, 0, 3); + write_test_bits_u64(&mut writer, 0, 1); + write_test_bits_u64(&mut writer, 0, 1); + write_test_bits_u64(&mut writer, 0, 2); + align_test_bit_writer(&mut writer); + let mut frame = writer.into_inner().unwrap(); + frame.resize(FRAME_LENGTH, 0); + for (offset, byte) in frame[11..].iter_mut().enumerate() { + *byte = u8::try_from((seed + offset) & 0xFF).unwrap(); + } + frame } -pub fn temp_output_dir(prefix: &str) -> PathBuf { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!("mp4forge-{prefix}-{}-{unique}", std::process::id())) +#[cfg(feature = "mux")] +fn swap_test_dts_16bit_words(frame: &[u8]) -> Vec { + assert!(frame.len().is_multiple_of(2)); + let mut swapped = vec![0_u8; frame.len()]; + for (index, chunk) in frame.chunks_exact(2).enumerate() { + swapped[index * 2] = chunk[1]; + swapped[index * 2 + 1] = chunk[0]; + } + swapped +} + +#[cfg(feature = "mux")] +fn pack_test_dts_14bit_words(frame: &[u8], little_endian: bool) -> Vec { + let packed_word_count = (frame.len() * 8).div_ceil(14); + let mut words = Vec::with_capacity(packed_word_count * 2); + let mut bit_buffer = 0_u64; + let mut buffered_bits = 0usize; + let mut word_index = 0usize; + for &byte in frame { + bit_buffer = (bit_buffer << 8) | u64::from(byte); + buffered_bits += 8; + while buffered_bits >= 14 { + buffered_bits -= 14; + let mut payload = ((bit_buffer >> buffered_bits) & 0x3FFF) as u16; + if word_index != 0 { + payload |= 0xC000; + } + let bytes = if little_endian { + payload.to_le_bytes() + } else { + payload.to_be_bytes() + }; + words.extend_from_slice(&bytes); + bit_buffer &= (1_u64 << buffered_bits).saturating_sub(1); + word_index += 1; + } + } + if buffered_bits != 0 { + let mut payload = ((bit_buffer << (14 - buffered_bits)) & 0x3FFF) as u16; + if word_index != 0 { + payload |= 0xC000; + } + let bytes = if little_endian { + payload.to_le_bytes() + } else { + payload.to_be_bytes() + }; + words.extend_from_slice(&bytes); + } + words +} + +#[cfg(feature = "mux")] +const TEST_AC4_FRAME_HEX: &str = concat!( + "ac41ffff00015cbfcee7984004a7012e2c20304d805c8458d0a0c06013b58354cb613912144b0232be85", + "4b4800025c71fd3eaacd4a86324c1498a4bd6021dfa8b016b42115ba6b684770fd34e31a264f66703f14", + "090541b22397fd7c837ef68f05211a79862d48d5c46d87857bedd9f69bbdb26682bcf49b036bccb100ab84", + "4568e5a54fc32e4302233b9144cb4bd0ca86c64794cf4e7eca5191e8d8c48ccef686868ae56b5f5e416097", + "07ad77775b5bfa5b61bff5f32ed963f6caee5ac968a743e60e578f5a4892c90101e18a7246f88c51161028", + "870564d088f0799f9d11701ecd86f202692868b8649e14e10f0304bc20f4b47d06b3ba58fcd3c950fecd1a", + "137dd410334797b62d82ed35073d1131e2f10a02ce51c269e1248e423c299956b2c53ad26a6c5ddcb1d7cd", + "c999265bb1954775fbc72cd8cf322a47091169f3fff19ff6aca15a5894fe68d2fa20c1f55000000000f010", + "4a51e02094a880a3c134b5ff00", +); + +#[cfg(feature = "mux")] +fn decode_test_hex_bytes(hex: &str) -> Vec { + assert!(hex.len().is_multiple_of(2)); + let mut bytes = Vec::with_capacity(hex.len() / 2); + for index in (0..hex.len()).step_by(2) { + bytes.push(u8::from_str_radix(&hex[index..index + 2], 16).unwrap()); + } + bytes +} + +pub fn temp_output_dir(prefix: &str) -> TestTempDir { + let dir = Builder::new() + .prefix(&format!("mp4forge-{prefix}-")) + .tempdir() + .unwrap(); + let path = dir.path().join("out"); + TestTempDir { + path, + _dir: Arc::new(dir), + } } -pub fn fixture_path(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) +pub fn fixture_path(name: &str) -> StdPathBuf { + StdPathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests") .join("fixtures") .join(name) @@ -101,16 +4554,16 @@ pub fn fixture_path(name: &str) -> PathBuf { #[cfg(feature = "decrypt")] pub struct RetainedDecryptFileFixture { - pub encrypted_path: PathBuf, - pub decrypted_path: PathBuf, + pub encrypted_path: StdPathBuf, + pub decrypted_path: StdPathBuf, pub keys: Vec, } #[cfg(feature = "decrypt")] pub struct RetainedFragmentedDecryptFixture { - pub fragments_info_path: PathBuf, - pub encrypted_segment_path: PathBuf, - pub clear_segment_path: PathBuf, + pub fragments_info_path: StdPathBuf, + pub encrypted_segment_path: StdPathBuf, + pub clear_segment_path: StdPathBuf, pub keys: Vec, } @@ -290,12 +4743,12 @@ pub fn piff_cbc_segment_fixture() -> RetainedFragmentedDecryptFixture { } #[cfg(feature = "decrypt")] -pub fn marlin_ipmp_acbc_encrypted_fixture_path() -> PathBuf { +pub fn marlin_ipmp_acbc_encrypted_fixture_path() -> StdPathBuf { fixture_path("marlin_ipmp_acbc_encrypted.mp4") } #[cfg(feature = "decrypt")] -pub fn marlin_ipmp_acbc_decrypted_fixture_path() -> PathBuf { +pub fn marlin_ipmp_acbc_decrypted_fixture_path() -> StdPathBuf { fixture_path("marlin_ipmp_acbc_decrypted.mp4") } @@ -329,12 +4782,12 @@ pub fn marlin_ipmp_acbc_fixture() -> RetainedDecryptFileFixture { } #[cfg(feature = "decrypt")] -pub fn marlin_ipmp_acgk_encrypted_fixture_path() -> PathBuf { +pub fn marlin_ipmp_acgk_encrypted_fixture_path() -> StdPathBuf { fixture_path("marlin_ipmp_acgk_encrypted.mp4") } #[cfg(feature = "decrypt")] -pub fn marlin_ipmp_acgk_decrypted_fixture_path() -> PathBuf { +pub fn marlin_ipmp_acgk_decrypted_fixture_path() -> StdPathBuf { fixture_path("marlin_ipmp_acgk_decrypted.mp4") } @@ -1941,7 +6394,7 @@ pub fn read_text(path: &Path) -> String { pub fn read_golden(relative_path: &str) -> String { read_text( - &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + &StdPathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests") .join("golden") .join(relative_path), @@ -3620,6 +8073,870 @@ fn compute_fixture_ctr_counter_block(iv: [u8; 16], stream_offset: u64) -> Block< counter_block } +#[cfg(feature = "mux")] +fn build_test_avi_avih_payload(stream_count: usize, max_chunk_size: usize) -> Vec { + let mut payload = Vec::new(); + payload.extend_from_slice(&21_333_u32.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload.extend_from_slice(&u32::try_from(stream_count).unwrap().to_le_bytes()); + payload.extend_from_slice(&u32::try_from(max_chunk_size).unwrap().to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload +} + +#[cfg(feature = "mux")] +fn build_test_avi_pcm_stream_list(index: usize, stream: &TestAviPcmStream<'_>) -> Vec { + build_test_avi_pcm_stream_list_with_format_tag(index, stream, 0x0001) +} + +#[cfg(feature = "mux")] +fn build_test_avi_pcm_stream_list_with_format_tag( + index: usize, + stream: &TestAviPcmStream<'_>, + format_tag: u16, +) -> Vec { + let block_align = stream.channel_count * (stream.bits_per_sample / 8); + let byte_rate = stream.sample_rate * u32::from(block_align); + let total_samples = stream + .chunks + .iter() + .map(|chunk| u32::try_from(chunk.len()).unwrap() / u32::from(block_align)) + .sum::(); + + let mut strh = Vec::new(); + strh.extend_from_slice(b"auds"); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&0_u16.to_le_bytes()); + strh.extend_from_slice(&0_u16.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&u32::from(block_align).to_le_bytes()); + strh.extend_from_slice(&byte_rate.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&total_samples.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&u32::from(block_align).to_le_bytes()); + strh.extend_from_slice(&0_i16.to_le_bytes()); + strh.extend_from_slice(&0_i16.to_le_bytes()); + strh.extend_from_slice(&0_i16.to_le_bytes()); + strh.extend_from_slice(&0_i16.to_le_bytes()); + + let mut strf = Vec::new(); + strf.extend_from_slice(&format_tag.to_le_bytes()); + strf.extend_from_slice(&stream.channel_count.to_le_bytes()); + strf.extend_from_slice(&stream.sample_rate.to_le_bytes()); + strf.extend_from_slice(&byte_rate.to_le_bytes()); + strf.extend_from_slice(&block_align.to_le_bytes()); + strf.extend_from_slice(&stream.bits_per_sample.to_le_bytes()); + + let mut bytes = Vec::new(); + let _ = index; + bytes.extend_from_slice(&encode_riff_chunk(*b"strh", &strh)); + bytes.extend_from_slice(&encode_riff_chunk(*b"strf", &strf)); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_avi_pcm_stream_list_with_extensible_subtype( + index: usize, + stream: &TestAviPcmStream<'_>, + subtype_guid: &[u8; 16], +) -> Vec { + let block_align = stream.channel_count * (stream.bits_per_sample / 8); + let byte_rate = stream.sample_rate * u32::from(block_align); + let total_samples = stream + .chunks + .iter() + .map(|chunk| u32::try_from(chunk.len()).unwrap() / u32::from(block_align)) + .sum::(); + + let mut strh = Vec::new(); + strh.extend_from_slice(b"auds"); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&0_u16.to_le_bytes()); + strh.extend_from_slice(&0_u16.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&u32::from(block_align).to_le_bytes()); + strh.extend_from_slice(&byte_rate.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&total_samples.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&u32::from(block_align).to_le_bytes()); + strh.extend_from_slice(&0_i16.to_le_bytes()); + strh.extend_from_slice(&0_i16.to_le_bytes()); + strh.extend_from_slice(&0_i16.to_le_bytes()); + strh.extend_from_slice(&0_i16.to_le_bytes()); + + let mut strf = Vec::new(); + strf.extend_from_slice(&0xFFFE_u16.to_le_bytes()); + strf.extend_from_slice(&stream.channel_count.to_le_bytes()); + strf.extend_from_slice(&stream.sample_rate.to_le_bytes()); + strf.extend_from_slice(&byte_rate.to_le_bytes()); + strf.extend_from_slice(&block_align.to_le_bytes()); + strf.extend_from_slice(&stream.bits_per_sample.to_le_bytes()); + strf.extend_from_slice(&22_u16.to_le_bytes()); + strf.extend_from_slice(&stream.bits_per_sample.to_le_bytes()); + strf.extend_from_slice(&0_u32.to_le_bytes()); + strf.extend_from_slice(subtype_guid); + + let mut bytes = Vec::new(); + let _ = index; + bytes.extend_from_slice(&encode_riff_chunk(*b"strh", &strh)); + bytes.extend_from_slice(&encode_riff_chunk(*b"strf", &strf)); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_avi_framed_audio_stream_list( + format_tag: u16, + sample_rate: u32, + channel_count: u16, + bits_per_sample: u16, + frames: &[&[u8]], +) -> Vec { + let max_chunk_size = frames.iter().map(|frame| frame.len()).max().unwrap_or(0); + let block_align = u16::try_from(max_chunk_size).unwrap_or(u16::MAX).max(1); + let sample_duration = match format_tag { + 0x0055 => 1_152, + 0x2000 => 1_536, + _ => 1, + }; + let byte_rate = u32::try_from(max_chunk_size) + .unwrap_or(u32::MAX) + .saturating_mul(sample_rate) + / sample_duration.max(1); + let total_samples = u32::try_from(frames.len()).unwrap(); + + let mut strh = Vec::new(); + strh.extend_from_slice(b"auds"); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&0_u16.to_le_bytes()); + strh.extend_from_slice(&0_u16.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&sample_duration.to_le_bytes()); + strh.extend_from_slice(&sample_rate.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&total_samples.to_le_bytes()); + strh.extend_from_slice( + &u32::try_from(max_chunk_size) + .unwrap_or(u32::MAX) + .to_le_bytes(), + ); + strh.extend_from_slice(&u32::MAX.to_le_bytes()); + strh.extend_from_slice(&u32::from(block_align).to_le_bytes()); + strh.extend_from_slice(&0_i16.to_le_bytes()); + strh.extend_from_slice(&0_i16.to_le_bytes()); + strh.extend_from_slice(&0_i16.to_le_bytes()); + strh.extend_from_slice(&0_i16.to_le_bytes()); + + let mut strf = Vec::new(); + strf.extend_from_slice(&format_tag.to_le_bytes()); + strf.extend_from_slice(&channel_count.to_le_bytes()); + strf.extend_from_slice(&sample_rate.to_le_bytes()); + strf.extend_from_slice(&byte_rate.to_le_bytes()); + strf.extend_from_slice(&block_align.to_le_bytes()); + strf.extend_from_slice(&bits_per_sample.to_le_bytes()); + + let mut bytes = Vec::new(); + bytes.extend_from_slice(&encode_riff_chunk(*b"strh", &strh)); + bytes.extend_from_slice(&encode_riff_chunk(*b"strf", &strf)); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_avi_mp4v_stream_list(stream: &TestAviMp4vStream<'_>) -> Vec { + build_test_avi_video_stream_list( + stream.width, + stream.height, + stream.frame_scale, + stream.frame_rate, + stream.compression, + stream.decoder_specific_info, + stream.frames, + ) +} + +#[cfg(feature = "mux")] +fn build_test_avi_movi_payload(streams: &[TestAviPcmStream<'_>]) -> Vec { + let mut bytes = Vec::new(); + let max_chunk_count = streams + .iter() + .map(|stream| stream.chunks.len()) + .max() + .unwrap_or(0); + for chunk_index in 0..max_chunk_count { + for (stream_index, stream) in streams.iter().enumerate() { + if let Some(chunk) = stream.chunks.get(chunk_index) { + let chunk_id = format!("{stream_index:02}wb"); + bytes.extend_from_slice(&encode_riff_chunk( + chunk_id.as_bytes().try_into().unwrap(), + chunk, + )); + } + } + } + bytes +} + +#[cfg(feature = "mux")] +fn build_test_avi_audio_movi_payload(frames: &[&[u8]]) -> Vec { + let mut bytes = Vec::new(); + for frame in frames { + bytes.extend_from_slice(&encode_riff_chunk(*b"00wb", frame)); + } + bytes +} + +#[cfg(feature = "mux")] +fn build_test_avi_mp4v_movi_payload(stream: &TestAviMp4vStream<'_>) -> Vec { + build_test_avi_video_movi_payload(stream.frames) +} + +#[cfg(feature = "mux")] +fn build_test_avi_video_stream_list( + width: u16, + height: u16, + frame_scale: u32, + frame_rate: u32, + compression: [u8; 4], + decoder_specific_info: &[u8], + frames: &[&[u8]], +) -> Vec { + let total_frames = u32::try_from(frames.len()).unwrap(); + let max_chunk_size = frames.iter().map(|frame| frame.len()).max().unwrap_or(0); + + let mut strh = Vec::new(); + strh.extend_from_slice(b"vids"); + strh.extend_from_slice(&compression); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&0_u16.to_le_bytes()); + strh.extend_from_slice(&0_u16.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&frame_scale.to_le_bytes()); + strh.extend_from_slice(&frame_rate.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&total_frames.to_le_bytes()); + strh.extend_from_slice(&u32::try_from(max_chunk_size).unwrap().to_le_bytes()); + strh.extend_from_slice(&u32::MAX.to_le_bytes()); + strh.extend_from_slice(&0_u32.to_le_bytes()); + strh.extend_from_slice(&0_i16.to_le_bytes()); + strh.extend_from_slice(&0_i16.to_le_bytes()); + strh.extend_from_slice(&i16::try_from(width).unwrap().to_le_bytes()); + strh.extend_from_slice(&i16::try_from(height).unwrap().to_le_bytes()); + + let mut strf = Vec::new(); + strf.extend_from_slice(&40_u32.to_le_bytes()); + strf.extend_from_slice(&i32::from(width).to_le_bytes()); + strf.extend_from_slice(&i32::from(height).to_le_bytes()); + strf.extend_from_slice(&1_u16.to_le_bytes()); + strf.extend_from_slice(&24_u16.to_le_bytes()); + strf.extend_from_slice(&compression); + strf.extend_from_slice(&u32::try_from(max_chunk_size).unwrap().to_le_bytes()); + strf.extend_from_slice(&0_i32.to_le_bytes()); + strf.extend_from_slice(&0_i32.to_le_bytes()); + strf.extend_from_slice(&0_u32.to_le_bytes()); + strf.extend_from_slice(&0_u32.to_le_bytes()); + strf.extend_from_slice(decoder_specific_info); + + let mut bytes = Vec::new(); + bytes.extend_from_slice(&encode_riff_chunk(*b"strh", &strh)); + bytes.extend_from_slice(&encode_riff_chunk(*b"strf", &strf)); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_avi_video_movi_payload(frames: &[&[u8]]) -> Vec { + let mut bytes = Vec::new(); + for frame in frames { + bytes.extend_from_slice(&encode_riff_chunk(*b"00dc", frame)); + } + bytes +} + +#[cfg(feature = "mux")] +fn encode_riff_chunk(chunk_type: [u8; 4], payload: &[u8]) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&chunk_type); + bytes.extend_from_slice(&u32::try_from(payload.len()).unwrap().to_le_bytes()); + bytes.extend_from_slice(payload); + if !payload.len().is_multiple_of(2) { + bytes.push(0); + } + bytes +} + +#[cfg(feature = "mux")] +fn encode_riff_list(list_type: [u8; 4], payload: &[u8]) -> Vec { + let mut list_payload = Vec::new(); + list_payload.extend_from_slice(&list_type); + list_payload.extend_from_slice(payload); + encode_riff_chunk(*b"LIST", &list_payload) +} + +#[cfg(feature = "mux")] +fn build_test_program_stream_pack_header() -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + bytes.extend_from_slice(&[0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x89, 0xC3, 0xF8, 0x00]); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_program_stream_mp3_pes_packet(payload: &[u8]) -> Vec { + build_test_program_stream_mpeg_audio_pes_packet(&build_mp3_frame(payload)) +} + +#[cfg(feature = "mux")] +fn build_test_program_stream_mp2_pes_packet(payload: &[u8]) -> Vec { + build_test_program_stream_mpeg_audio_pes_packet(&build_mp2_frame(payload)) +} + +#[cfg(feature = "mux")] +fn build_test_program_stream_mpeg_audio_pes_packet(frame: &[u8]) -> Vec { + let pes_packet_length = u16::try_from(frame.len() + 3).unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xC0]); + bytes.extend_from_slice(&pes_packet_length.to_be_bytes()); + bytes.extend_from_slice(&[0x80, 0x00, 0x00]); + bytes.extend_from_slice(frame); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_program_stream_ac3_pes_packet(payload: &[u8]) -> Vec { + let frame = build_ac3_frame(payload); + let pes_packet_length = u16::try_from(frame.len() + 7).unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xBD]); + bytes.extend_from_slice(&pes_packet_length.to_be_bytes()); + bytes.extend_from_slice(&[0x80, 0x00, 0x00]); + bytes.extend_from_slice(&[0x80, 0x00, 0x00, 0x00]); + bytes.extend_from_slice(&frame); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_program_stream_lpcm_pes_packet(payload: &[u8]) -> Vec { + let pes_packet_length = u16::try_from(payload.len() + 7).unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xBD]); + bytes.extend_from_slice(&pes_packet_length.to_be_bytes()); + bytes.extend_from_slice(&[0x80, 0x00, 0x00]); + bytes.extend_from_slice(&[0xA0, 0x00, 0x00, 0x01]); + bytes.extend_from_slice(payload); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_program_stream_vobsub_pes_packet( + pts: u64, + substream_id: u8, + packet: &[u8], +) -> Vec { + let pts_bytes = [ + (((pts >> 29) & 0x0E) as u8) | 0x21, + ((pts >> 22) & 0xFF) as u8, + (((pts >> 14) & 0xFE) as u8) | 0x01, + ((pts >> 7) & 0xFF) as u8, + (((pts << 1) & 0xFE) as u8) | 0x01, + ]; + let pes_packet_length = u16::try_from(packet.len() + 12).unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xBD]); + bytes.extend_from_slice(&pes_packet_length.to_be_bytes()); + bytes.extend_from_slice(&[0x80, 0x80, 0x05]); + bytes.extend_from_slice(&pts_bytes); + bytes.extend_from_slice(&[substream_id, 0x00, 0x00, 0x00]); + bytes.extend_from_slice(packet); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_program_stream_mp4v_pes_packet(payload: &[u8]) -> Vec { + build_test_program_stream_video_pes_packet(payload) +} + +#[cfg(feature = "mux")] +fn build_test_program_stream_video_pes_packet(payload: &[u8]) -> Vec { + let pes_packet_length = u16::try_from(payload.len() + 3).unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xE0]); + bytes.extend_from_slice(&pes_packet_length.to_be_bytes()); + bytes.extend_from_slice(&[0x80, 0x00, 0x00]); + bytes.extend_from_slice(payload); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_program_stream_video_pes_packet_with_pts(pts: u64, payload: &[u8]) -> Vec { + let pts_bytes = [ + (((pts >> 29) & 0x0E) as u8) | 0x21, + ((pts >> 22) & 0xFF) as u8, + (((pts >> 14) & 0xFE) as u8) | 0x01, + ((pts >> 7) & 0xFF) as u8, + (((pts << 1) & 0xFE) as u8) | 0x01, + ]; + let pes_packet_length = u16::try_from(payload.len() + 8).unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xE0]); + bytes.extend_from_slice(&pes_packet_length.to_be_bytes()); + bytes.extend_from_slice(&[0x80, 0x80, 0x05]); + bytes.extend_from_slice(&pts_bytes); + bytes.extend_from_slice(payload); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_program_stream_video_pes_packet_with_pts_and_dts( + pts: u64, + dts: u64, + payload: &[u8], +) -> Vec { + let pts_bytes = [ + (((pts >> 29) & 0x0E) as u8) | 0x31, + ((pts >> 22) & 0xFF) as u8, + (((pts >> 14) & 0xFE) as u8) | 0x01, + ((pts >> 7) & 0xFF) as u8, + (((pts << 1) & 0xFE) as u8) | 0x01, + ]; + let dts_bytes = [ + (((dts >> 29) & 0x0E) as u8) | 0x11, + ((dts >> 22) & 0xFF) as u8, + (((dts >> 14) & 0xFE) as u8) | 0x01, + ((dts >> 7) & 0xFF) as u8, + (((dts << 1) & 0xFE) as u8) | 0x01, + ]; + let pes_packet_length = u16::try_from(payload.len() + 13).unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xE0]); + bytes.extend_from_slice(&pes_packet_length.to_be_bytes()); + bytes.extend_from_slice(&[0x80, 0xC0, 0x0A]); + bytes.extend_from_slice(&pts_bytes); + bytes.extend_from_slice(&dts_bytes); + bytes.extend_from_slice(payload); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_program_stream_open_ended_video_pes_packet(payload: &[u8]) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xE0]); + bytes.extend_from_slice(&0_u16.to_be_bytes()); + bytes.extend_from_slice(&[0x80, 0x00, 0x00]); + bytes.extend_from_slice(payload); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_pat_packet(continuity_counter: u8) -> Vec { + let mut section = Vec::new(); + section.push(0x00); + section.extend_from_slice(&0xB00D_u16.to_be_bytes()); + section.extend_from_slice(&1_u16.to_be_bytes()); + section.extend_from_slice(&[0xC1, 0x00, 0x00]); + section.extend_from_slice(&1_u16.to_be_bytes()); + section.extend_from_slice(&0xE100_u16.to_be_bytes()); + section.extend_from_slice(&mpeg2ts_crc32_for_test(§ion).to_be_bytes()); + build_test_transport_stream_section_packet(0x0000, continuity_counter, §ion) +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_multi_program_pat_packet(continuity_counter: u8) -> Vec { + let mut section = Vec::new(); + section.push(0x00); + section.extend_from_slice(&0xB011_u16.to_be_bytes()); + section.extend_from_slice(&1_u16.to_be_bytes()); + section.extend_from_slice(&[0xC1, 0x00, 0x00]); + section.extend_from_slice(&1_u16.to_be_bytes()); + section.extend_from_slice(&0xE100_u16.to_be_bytes()); + section.extend_from_slice(&2_u16.to_be_bytes()); + section.extend_from_slice(&0xE110_u16.to_be_bytes()); + section.extend_from_slice(&mpeg2ts_crc32_for_test(§ion).to_be_bytes()); + build_test_transport_stream_section_packet(0x0000, continuity_counter, §ion) +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_pmt_packet(continuity_counter: u8) -> Vec { + build_test_transport_stream_pmt_packet_for_stream_type(continuity_counter, 0x03) +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_pmt_packet_for_stream_type( + continuity_counter: u8, + stream_type: u8, +) -> Vec { + build_test_transport_stream_pmt_packet_for_stream_type_with_descriptors( + continuity_counter, + stream_type, + &[], + ) +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_pmt_packet_for_private_data( + continuity_counter: u8, + descriptors: &[u8], +) -> Vec { + build_test_transport_stream_pmt_packet_for_stream_type_with_descriptors( + continuity_counter, + 0x06, + descriptors, + ) +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_pmt_packet_for_stream_type_with_descriptors( + continuity_counter: u8, + stream_type: u8, + descriptors: &[u8], +) -> Vec { + let mut section = Vec::new(); + section.push(0x02); + let section_length = + u16::try_from(18 + descriptors.len()).expect("PMT descriptor payload should fit"); + section.extend_from_slice(&(0xB000_u16 | section_length).to_be_bytes()); + section.extend_from_slice(&1_u16.to_be_bytes()); + section.extend_from_slice(&[0xC1, 0x00, 0x00]); + section.extend_from_slice(&0xE101_u16.to_be_bytes()); + section.extend_from_slice(&0xF000_u16.to_be_bytes()); + section.push(stream_type); + section.extend_from_slice(&0xE101_u16.to_be_bytes()); + let es_info_length = + u16::try_from(descriptors.len()).expect("PMT descriptor payload should fit"); + section.extend_from_slice(&(0xF000_u16 | es_info_length).to_be_bytes()); + section.extend_from_slice(descriptors); + section.extend_from_slice(&mpeg2ts_crc32_for_test(§ion).to_be_bytes()); + build_test_transport_stream_section_packet(0x0100, continuity_counter, §ion) +} + +#[cfg(feature = "mux")] +fn mpeg2ts_crc32_for_test(data: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFF_u32; + for byte in data { + crc ^= u32::from(*byte) << 24; + for _ in 0..8 { + crc = if crc & 0x8000_0000 != 0 { + (crc << 1) ^ 0x04C1_1DB7 + } else { + crc << 1 + }; + } + } + crc +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_section_packet( + pid: u16, + continuity_counter: u8, + section: &[u8], +) -> Vec { + let mut packet = vec![0xFF; TS_PACKET_SIZE]; + packet[0] = 0x47; + packet[1] = 0x40 | u8::try_from((pid >> 8) & 0x1F).unwrap(); + packet[2] = u8::try_from(pid & 0xFF).unwrap(); + packet[3] = 0x10 | (continuity_counter & 0x0F); + packet[4] = 0x00; + let payload_end = 5 + section.len(); + packet[5..payload_end].copy_from_slice(section); + packet +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_mp3_pes_packet(payload: &[u8]) -> Vec { + let frame = build_mp3_frame(payload); + build_test_transport_stream_mpeg_audio_pes_packet(&frame) +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_mpeg_audio_pes_packet(payload: &[u8]) -> Vec { + let pes_packet_length = u16::try_from(payload.len() + 3).unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xC0]); + bytes.extend_from_slice(&pes_packet_length.to_be_bytes()); + bytes.extend_from_slice(&[0x80, 0x00, 0x00]); + bytes.extend_from_slice(payload); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_mpeg_audio_pes_packet_with_pts(pts: u64, payload: &[u8]) -> Vec { + let pts_bytes = [ + (((pts >> 29) & 0x0E) as u8) | 0x21, + ((pts >> 22) & 0xFF) as u8, + (((pts >> 14) & 0xFE) as u8) | 0x01, + ((pts >> 7) & 0xFF) as u8, + (((pts << 1) & 0xFE) as u8) | 0x01, + ]; + let pes_packet_length = u16::try_from(payload.len() + 8).unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xC0]); + bytes.extend_from_slice(&pes_packet_length.to_be_bytes()); + bytes.extend_from_slice(&[0x80, 0x80, 0x05]); + bytes.extend_from_slice(&pts_bytes); + bytes.extend_from_slice(payload); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_mp4v_pes_packet(payload: &[u8]) -> Vec { + build_test_transport_stream_video_pes_packet(payload) +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_mpeg2v_pes_packet_with_pts(pts: u64, payload: &[u8]) -> Vec { + build_test_transport_stream_video_pes_packet_with_pts(pts, payload) +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_private_data_pes_packet(payload: &[u8]) -> Vec { + let pes_packet_length = u16::try_from(payload.len() + 3).unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xBD]); + bytes.extend_from_slice(&pes_packet_length.to_be_bytes()); + bytes.extend_from_slice(&[0x80, 0x00, 0x00]); + bytes.extend_from_slice(payload); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_video_pes_packet(payload: &[u8]) -> Vec { + let pes_packet_length = u16::try_from(payload.len() + 3).unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xE0]); + bytes.extend_from_slice(&pes_packet_length.to_be_bytes()); + bytes.extend_from_slice(&[0x80, 0x00, 0x00]); + bytes.extend_from_slice(payload); + bytes +} + +fn build_test_transport_stream_video_pes_packet_with_pts(pts: u64, payload: &[u8]) -> Vec { + let pts_bytes = [ + (((pts >> 29) & 0x0E) as u8) | 0x21, + ((pts >> 22) & 0xFF) as u8, + (((pts >> 14) & 0xFE) as u8) | 0x01, + ((pts >> 7) & 0xFF) as u8, + (((pts << 1) & 0xFE) as u8) | 0x01, + ]; + let pes_packet_length = u16::try_from(payload.len() + 8).unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x00, 0x01, 0xE0]); + bytes.extend_from_slice(&pes_packet_length.to_be_bytes()); + bytes.extend_from_slice(&[0x80, 0x80, 0x05]); + bytes.extend_from_slice(&pts_bytes); + bytes.extend_from_slice(payload); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_dvb_subtitle_descriptor( + language: [u8; 3], + subtitle_type: u8, + composition_page_id: u16, + ancillary_page_id: u16, +) -> Vec { + let mut bytes = vec![0x59, 8]; + bytes.extend_from_slice(&language); + bytes.push(subtitle_type); + bytes.extend_from_slice(&composition_page_id.to_be_bytes()); + bytes.extend_from_slice(&ancillary_page_id.to_be_bytes()); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_dvb_teletext_descriptor( + language: [u8; 3], + teletext_type: u8, + page_byte: u8, +) -> Vec { + let mut bytes = vec![0x56, 5]; + bytes.extend_from_slice(&language); + bytes.push(teletext_type); + bytes.push(page_byte); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_registration_descriptor(registration: [u8; 4]) -> Vec { + let mut bytes = vec![0x05, 4]; + bytes.extend_from_slice(®istration); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_private_data_specifier_descriptor(specifier: [u8; 4]) -> Vec { + let mut bytes = vec![0x5F, 4]; + bytes.extend_from_slice(&specifier); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_av1_video_descriptor() -> Vec { + vec![0x80, 4, 0x81, 0x00, 0x0C, 0xC0] +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_avs3_registration_descriptor(decoder_config: &[u8]) -> Vec { + let mut bytes = vec![ + 0x05, + u8::try_from(4 + decoder_config.len()).expect("AVS3 registration descriptor should fit"), + ]; + bytes.extend_from_slice(b"AVSV"); + bytes.extend_from_slice(decoder_config); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_av1_sample_bytes(frame_payload: &[u8]) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&build_test_transport_stream_av1_framed_obu( + &build_test_av1_temporal_delimiter_obu(), + )); + for obu in split_test_av1_obu_units(frame_payload) { + bytes.extend_from_slice(&build_test_transport_stream_av1_framed_obu(&obu)); + } + bytes +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_av1_framed_obu(obu: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(3 + obu.len()); + bytes.extend_from_slice(&[0x00, 0x00, 0x01]); + let mut zero_run = 0usize; + for &byte in obu { + if zero_run >= 2 && byte <= 0x03 { + bytes.push(0x03); + zero_run = 0; + } + bytes.push(byte); + if byte == 0x00 { + zero_run += 1; + } else { + zero_run = 0; + } + } + bytes +} + +#[cfg(feature = "mux")] +fn packetize_test_transport_stream_pes( + pid: u16, + continuity_counter: &mut u8, + pes_packet: &[u8], +) -> Vec { + let mut bytes = Vec::new(); + let mut offset = 0usize; + let mut first = true; + while offset < pes_packet.len() { + let mut packet = vec![0xFF; TS_PACKET_SIZE]; + packet[0] = 0x47; + packet[1] = (if first { 0x40 } else { 0x00 }) | u8::try_from((pid >> 8) & 0x1F).unwrap(); + packet[2] = u8::try_from(pid & 0xFF).unwrap(); + + let remaining = pes_packet.len() - offset; + if remaining >= 184 { + packet[3] = 0x10 | (*continuity_counter & 0x0F); + let payload_end = offset + 184; + packet[4..188].copy_from_slice(&pes_packet[offset..payload_end]); + offset = payload_end; + } else { + let adaptation_length = 183 - remaining; + packet[3] = 0x30 | (*continuity_counter & 0x0F); + packet[4] = u8::try_from(adaptation_length).unwrap(); + if adaptation_length > 0 { + packet[5] = 0x00; + for byte in &mut packet[6..(5 + adaptation_length)] { + *byte = 0xFF; + } + } + let payload_start = 5 + adaptation_length; + packet[payload_start..payload_start + remaining] + .copy_from_slice(&pes_packet[offset..offset + remaining]); + offset = pes_packet.len(); + } + *continuity_counter = (*continuity_counter + 1) & 0x0F; + first = false; + bytes.extend_from_slice(&packet); + } + bytes +} + +#[cfg(feature = "mux")] +fn build_test_transport_stream_avs3_decoder_config(sequence_header: &[u8]) -> Vec { + assert!(sequence_header.len() >= 6); + let mut bytes = Vec::with_capacity(10); + bytes.push(1); + bytes.extend_from_slice(&6_u16.to_be_bytes()); + bytes.extend_from_slice(&sequence_header[..6]); + bytes.push(0xFC); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_avs3_sequence_header_bytes(width: u16, height: u16, frame_rate_code: u8) -> Vec { + let mut writer = BitWriter::new(Vec::new()); + write_test_bits_u64(&mut writer, 0x20, 8); + write_test_bits_u64(&mut writer, 0x10, 8); + write_test_bits_u64(&mut writer, 1, 1); + write_test_bits_u64(&mut writer, 0, 1); + write_test_bits_u64(&mut writer, 0, 2); + write_test_bits_u64(&mut writer, 1, 1); + write_test_bits_u64(&mut writer, u64::from(width), 14); + write_test_bits_u64(&mut writer, 1, 1); + write_test_bits_u64(&mut writer, u64::from(height), 14); + write_test_bits_u64(&mut writer, 1, 2); + write_test_bits_u64(&mut writer, 1, 3); + write_test_bits_u64(&mut writer, 1, 1); + write_test_bits_u64(&mut writer, 1, 4); + write_test_bits_u64(&mut writer, u64::from(frame_rate_code), 4); + write_test_bits_u64(&mut writer, 1, 1); + write_test_bits_u64(&mut writer, 0, 18); + write_test_bits_u64(&mut writer, 1, 1); + write_test_bits_u64(&mut writer, 0, 12); + write_test_bits_u64(&mut writer, 1, 1); + align_test_bit_writer(&mut writer); + + let mut bytes = vec![0x00, 0x00, 0x01, 0xB0]; + bytes.extend_from_slice(&writer.into_inner().unwrap()); + bytes +} + +#[cfg(feature = "mux")] +fn build_test_avs3_picture_bytes(is_sync_sample: bool, payload: &[u8]) -> Vec { + let start_code = if is_sync_sample { 0xB3 } else { 0xB6 }; + let picture_type = if is_sync_sample { 0x00 } else { 0x01 }; + let mut bytes = vec![ + 0x00, + 0x00, + 0x01, + start_code, + 0x00, + 0x00, + 0x00, + 0x00, + picture_type, + 0x00, + 0x00, + 0x01, + 0x00, + ]; + bytes.extend_from_slice(payload); + bytes +} + fn encrypted_fragment_default_kid() -> [u8; 16] { [ 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc,