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.
@@ -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