diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index d93a8c1..0d23d1a 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.1.0" + placeholder: "0.2.0" validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da445d7..8a4838f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,10 +121,23 @@ jobs: echo "new_release=true" >> "$GITHUB_OUTPUT" fi + semver: + name: Semver Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # stable + with: + toolchain: stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 + - name: Install cargo-semver-checks + run: cargo install cargo-semver-checks --locked + - run: cargo semver-checks -p mp4forge + release: name: GitHub Release runs-on: ubuntu-latest - needs: [fmt, clippy, test, msrv, docs, audit, check-version] + needs: [fmt, clippy, test, msrv, docs, audit, check-version, semver] if: github.event_name == 'push' && needs.check-version.outputs.new_release == 'true' permissions: contents: write diff --git a/.gitignore b/.gitignore index 9381b6a..902c07a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target /target* +/Cargo.lock +/fuzz/Cargo.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index af74ec7..9f86db5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 0.2.0 (April 21, 2026) + +- Added typed path-based extraction helpers for common read flows: `extract_box_as`, `extract_boxes_as`, and `extract_boxes_as_with_registry` +- Added typed path-based rewrite helpers for common edit flows: `rewrite_box_as`, `rewrite_boxes_as`, and `rewrite_boxes_as_with_registry` +- Improved matched payload diagnostics so extraction and rewrite failures report the path, box type, and byte offset that triggered the error +- Added higher-level examples for the ergonomic helper layer while preserving the existing low-level examples +- Polished public docs, README coverage, packaging metadata, and release validation around the new helper surface + # 0.1.0 (April 21, 2026) - Initial crate release diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index dd9fca1..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,76 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "libc" -version = "0.2.185" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "mp4forge" -version = "0.1.0" -dependencies = [ - "terminal_size", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "terminal_size" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" -dependencies = [ - "rustix", - "windows-sys", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] diff --git a/Cargo.toml b/Cargo.toml index d859c3e..8716ca8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "mp4forge" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.88" authors = ["bakgio"] license = "MIT OR Apache-2.0" -description = "Rust library and CLI for inspecting, extracting, and rewriting MP4 box structures" +description = "Rust library and CLI for inspecting, probing, extracting, and rewriting MP4 box structures" repository = "https://github.com/bakgio/mp4forge" readme = "README.md" keywords = ["mp4", "isobmff", "parser", "video", "cli"] diff --git a/README.md b/README.md index 7a1c2de..5080387 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - Typed MP4 and ISOBMFF box model with registry-backed custom box support - Low-level traversal, extraction, stringify, probe, and writer APIs +- Thin typed path-based helpers for common extraction and rewrite flows - Built-in CLI for `dump`, `extract`, `probe`, `psshdump`, `edit`, and `divide` - Shared-fixture coverage for regular MP4, fragmented MP4, encrypted init segments, and QuickTime-style metadata cases @@ -25,7 +26,7 @@ ```toml [dependencies] -mp4forge = "0.1.0" +mp4forge = "0.2.0" ``` Install the CLI from crates.io: @@ -68,7 +69,7 @@ mp4forge psshdump encrypted_init.mp4 `mp4forge` currently ships without public Cargo feature flags. -> See the [`examples/`](./examples) directory for structure walking, extraction, probing, writer-backed rewrite, and custom box registration examples. +> See the [`examples/`](./examples) directory for both the low-level and high-level public API story, including typed extraction in `extract_track_ids_typed.rs`, typed rewrite in `rewrite_emsg.rs`, structure walking, probing, writer-backed rewrite, and custom box registration. ## License diff --git a/examples/extract_track_ids_typed.rs b/examples/extract_track_ids_typed.rs new file mode 100644 index 0000000..4103d16 --- /dev/null +++ b/examples/extract_track_ids_typed.rs @@ -0,0 +1,38 @@ +use std::env; +use std::error::Error; +use std::fs::File; + +use mp4forge::FourCc; +use mp4forge::boxes::iso14496_12::Tkhd; +use mp4forge::extract::extract_box_as; +use mp4forge::walk::BoxPath; + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let Some(path) = env::args().nth(1) else { + return Err("usage: cargo run --example extract_track_ids_typed -- ".into()); + }; + + let mut file = File::open(path)?; + let headers = extract_box_as::<_, Tkhd>( + &mut file, + None, + BoxPath::from([ + FourCc::from_bytes(*b"moov"), + FourCc::from_bytes(*b"trak"), + FourCc::from_bytes(*b"tkhd"), + ]), + )?; + + for tkhd in headers { + println!("track ID: {}", tkhd.track_id); + } + + Ok(()) +} diff --git a/examples/rewrite_emsg.rs b/examples/rewrite_emsg.rs new file mode 100644 index 0000000..db4a76d --- /dev/null +++ b/examples/rewrite_emsg.rs @@ -0,0 +1,70 @@ +use std::env; +use std::error::Error; +use std::fs::File; +use std::io::Cursor; + +use mp4forge::FourCc; +use mp4forge::boxes::iso14496_12::Emsg; +use mp4forge::rewrite::rewrite_box_as; +use mp4forge::walk::BoxPath; + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let Some(output_path) = env::args().nth(1) else { + return Err("usage: cargo run --example rewrite_emsg -- ".into()); + }; + + let input = sample_emsg_file(); + let mut reader = Cursor::new(input); + let output = File::create(output_path)?; + rewrite_box_as::<_, _, Emsg, _>( + &mut reader, + output, + BoxPath::from([FourCc::from_bytes(*b"emsg")]), + |emsg| { + emsg.message_data = b"hello world".to_vec(); + }, + )?; + + Ok(()) +} + +fn sample_emsg_file() -> Vec { + let mut emsg_payload = vec![0x00, 0x00, 0x00, 0x00]; + append_null_terminated_string(&mut emsg_payload, "urn:test"); + append_null_terminated_string(&mut emsg_payload, "demo"); + append_u32(&mut emsg_payload, 1000); + append_u32(&mut emsg_payload, 0); + append_u32(&mut emsg_payload, 5); + append_u32(&mut emsg_payload, 1); + emsg_payload.extend_from_slice(b"hello"); + + let mut file = Vec::new(); + file.extend_from_slice(&box_bytes("free", &[0x01, 0x02, 0x03])); + file.extend_from_slice(&box_bytes("emsg", &emsg_payload)); + file.extend_from_slice(&box_bytes("free", &[0x04, 0x05])); + file +} + +fn append_null_terminated_string(dst: &mut Vec, value: &str) { + dst.extend_from_slice(value.as_bytes()); + dst.push(0x00); +} + +fn append_u32(dst: &mut Vec, value: u32) { + dst.extend_from_slice(&value.to_be_bytes()); +} + +fn box_bytes(box_type: &str, payload: &[u8]) -> Vec { + let mut box_bytes = Vec::with_capacity(8 + payload.len()); + box_bytes.extend_from_slice(&((payload.len() + 8) as u32).to_be_bytes()); + box_bytes.extend_from_slice(box_type.as_bytes()); + box_bytes.extend_from_slice(payload); + box_bytes +} diff --git a/fuzz/.gitignore b/fuzz/.gitignore index 85c4c32..1ad5240 100644 --- a/fuzz/.gitignore +++ b/fuzz/.gitignore @@ -1,3 +1,4 @@ artifacts corpus target +Cargo.lock diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock deleted file mode 100644 index 2d9c8b0..0000000 --- a/fuzz/Cargo.lock +++ /dev/null @@ -1,173 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - -[[package]] -name = "cc" -version = "1.2.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom", - "libc", -] - -[[package]] -name = "libc" -version = "0.2.185" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" - -[[package]] -name = "libfuzzer-sys" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" -dependencies = [ - "arbitrary", - "cc", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "mp4forge" -version = "0.1.0" -dependencies = [ - "terminal_size", -] - -[[package]] -name = "mp4forge-fuzz" -version = "0.0.0" -dependencies = [ - "libfuzzer-sys", - "mp4forge", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "terminal_size" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" -dependencies = [ - "rustix", - "windows-sys", -] - -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" diff --git a/src/extract.rs b/src/extract.rs index 880702d..4698e6f 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -1,5 +1,9 @@ //! Path-based box extraction helpers built on the structure walker. +//! +//! This module keeps the existing low-level extraction surface available while also exposing thin +//! typed helpers for callers that already know the payload type they expect at a given path. +use std::any::type_name; use std::error::Error; use std::fmt; use std::io::{self, Read, Seek}; @@ -7,7 +11,7 @@ use std::io::{self, Read, Seek}; use crate::BoxInfo; use crate::FourCc; use crate::boxes::{BoxRegistry, default_registry}; -use crate::codec::{CodecError, DynCodecBox, unmarshal_any_with_context}; +use crate::codec::{CodecBox, CodecError, DynCodecBox, unmarshal_any_with_context}; use crate::header::HeaderError; use crate::walk::{ BoxPath, PathMatch, WalkControl, WalkError, WalkHandle, walk_structure_from_box_with_registry, @@ -15,6 +19,10 @@ use crate::walk::{ }; /// Header metadata paired with a decoded runtime box payload. +/// +/// Use this when the caller needs both the matched [`BoxInfo`] and direct access to the decoded +/// runtime-erased payload. Callers that already know the concrete payload type can usually prefer +/// [`extract_box_as`] or [`extract_boxes_as`] to avoid manual downcasts. pub struct ExtractedBox { /// Header metadata captured during the structure walk. pub info: BoxInfo, @@ -22,7 +30,10 @@ pub struct ExtractedBox { pub payload: Box, } -/// Extracts every box that matches `path`. +/// Extracts every box that matches `path` and returns the matching header metadata. +/// +/// When `parent` is present, `path` is evaluated relative to that box. Returns an empty vector +/// when no boxes match. pub fn extract_box( reader: &mut R, parent: Option<&BoxInfo>, @@ -35,7 +46,10 @@ where extract_boxes(reader, parent, &paths) } -/// Extracts every box that matches any path in `paths`. +/// Extracts every box that matches any path in `paths` and returns the matching header metadata. +/// +/// When `parent` is present, every path is evaluated relative to that box. Returns an empty vector +/// when no boxes match. pub fn extract_boxes( reader: &mut R, parent: Option<&BoxInfo>, @@ -49,6 +63,9 @@ where } /// Extracts every box that matches `path` and decodes the payloads. +/// +/// When `parent` is present, `path` is evaluated relative to that box. Each match is returned as +/// an [`ExtractedBox`] so callers can inspect both the header metadata and decoded payload. pub fn extract_box_with_payload( reader: &mut R, parent: Option<&BoxInfo>, @@ -62,6 +79,8 @@ where } /// Extracts every box that matches any path in `paths` and decodes the payloads. +/// +/// When `parent` is present, every path is evaluated relative to that box. pub fn extract_boxes_with_payload( reader: &mut R, parent: Option<&BoxInfo>, @@ -74,13 +93,138 @@ where extract_boxes_with_payload_with_registry(reader, parent, paths, ®istry) } -/// Extracts every box that matches any path in `paths` using `registry`. +/// Extracts every box that matches `path`, decodes the payloads, and clones them as `T`. +/// +/// This is the smallest high-level extraction helper for common read flows that already know the +/// concrete payload type they expect. It keeps the existing low-level extraction layer intact +/// while removing the repeated downcast boilerplate from call sites. +/// +/// When `parent` is present, `path` is evaluated relative to that box. +pub fn extract_box_as( + reader: &mut R, + parent: Option<&BoxInfo>, + path: BoxPath, +) -> Result, ExtractError> +where + R: Read + Seek, + T: CodecBox + Clone + 'static, +{ + let paths = [path]; + extract_boxes_as(reader, parent, &paths) +} + +/// Extracts every box that matches any path in `paths`, decodes the payloads, and clones them as +/// `T`. +/// +/// Every matched box must decode to `T`, otherwise [`ExtractError::UnexpectedPayloadType`] is +/// returned with the matched path and offset for diagnostics. Returns an empty vector when no +/// boxes match. +pub fn extract_boxes_as( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], +) -> Result, ExtractError> +where + R: Read + Seek, + T: CodecBox + Clone + 'static, +{ + let registry = default_registry(); + extract_boxes_as_with_registry(reader, parent, paths, ®istry) +} + +/// Extracts every box that matches any path in `paths` using `registry` and returns the matching +/// header metadata. +/// +/// Use this when custom or context-sensitive box registrations must participate in the extraction. pub fn extract_boxes_with_registry( reader: &mut R, parent: Option<&BoxInfo>, paths: &[BoxPath], registry: &BoxRegistry, ) -> Result, ExtractError> +where + R: Read + Seek, +{ + Ok(collect_matches(reader, parent, paths, registry)? + .into_iter() + .map(|matched| matched.info) + .collect()) +} + +/// Extracts every box that matches any path in `paths`, then decodes the payloads with `registry`. +/// +/// Use this when custom or context-sensitive box registrations must participate in payload decode. +pub fn extract_boxes_with_payload_with_registry( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], + registry: &BoxRegistry, +) -> Result, ExtractError> +where + R: Read + Seek, +{ + let matched_boxes = collect_matches(reader, parent, paths, registry)?; + let mut matches = Vec::with_capacity(matched_boxes.len()); + + for matched in matched_boxes { + let payload = decode_payload(reader, &matched, registry)?; + matches.push(ExtractedBox { + info: matched.info, + payload, + }); + } + + Ok(matches) +} + +/// Extracts every box that matches any path in `paths`, decodes the payloads with `registry`, and +/// clones them as `T`. +/// +/// Use this when the active registry may include custom box registrations and all matched boxes are +/// expected to share the same concrete payload type. +pub fn extract_boxes_as_with_registry( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], + registry: &BoxRegistry, +) -> Result, ExtractError> +where + R: Read + Seek, + T: CodecBox + Clone + 'static, +{ + let matched_boxes = collect_matches(reader, parent, paths, registry)?; + let mut payloads = Vec::with_capacity(matched_boxes.len()); + + for matched in matched_boxes { + let payload = decode_payload(reader, &matched, registry)?; + let typed = payload + .as_ref() + .as_any() + .downcast_ref::() + .cloned() + .ok_or_else(|| ExtractError::UnexpectedPayloadType { + path: matched.path.clone(), + box_type: matched.info.box_type(), + offset: matched.info.offset(), + expected_type: type_name::(), + })?; + payloads.push(typed); + } + + Ok(payloads) +} + +struct MatchedBox { + info: BoxInfo, + path: BoxPath, +} + +fn collect_matches( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], + registry: &BoxRegistry, +) -> Result, ExtractError> where R: Read + Seek, { @@ -106,7 +250,10 @@ where exact_match, } = match_paths(paths, &relative_path); if exact_match { - matches.push(*handle.info()); + matches.push(MatchedBox { + info: *handle.info(), + path: relative_path.clone(), + }); } Ok(if forward_match { @@ -125,33 +272,31 @@ where Ok(matches) } -/// Extracts every box that matches any path in `paths`, then decodes the payloads with `registry`. -pub fn extract_boxes_with_payload_with_registry( +fn decode_payload( reader: &mut R, - parent: Option<&BoxInfo>, - paths: &[BoxPath], + matched: &MatchedBox, registry: &BoxRegistry, -) -> Result, ExtractError> +) -> Result, ExtractError> where R: Read + Seek, { - let infos = extract_boxes_with_registry(reader, parent, paths, registry)?; - let mut matches = Vec::with_capacity(infos.len()); - - for info in infos { - info.seek_to_payload(reader)?; - let (payload, _) = unmarshal_any_with_context( - reader, - info.payload_size()?, - info.box_type(), - registry, - info.lookup_context(), - None, - )?; - matches.push(ExtractedBox { info, payload }); - } - - Ok(matches) + matched.info.seek_to_payload(reader)?; + let payload_size = matched.info.payload_size()?; + 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 validate_paths(paths: &[BoxPath]) -> Result<(), ExtractError> { @@ -176,11 +321,38 @@ fn match_paths(paths: &[BoxPath], current: &BoxPath) -> PathMatch { /// Errors raised while extracting path-matched boxes. #[derive(Debug)] pub enum ExtractError { + /// An I/O operation failed while reading or seeking. Io(io::Error), + /// Box header metadata was invalid or truncated. Header(HeaderError), + /// Payload decode failed before a more specific matched-box context was available. Codec(CodecError), + /// Structure walking failed before a specific extraction match could be reported. Walk(WalkError), + /// One of the requested paths was empty. EmptyPath, + /// A matched payload failed to decode with contextual path metadata. + PayloadDecode { + /// Matched path that was being decoded when the failure happened. + path: BoxPath, + /// Concrete box type at that matched path. + box_type: FourCc, + /// File offset of the matched box header. + offset: u64, + /// Underlying decode failure. + source: CodecError, + }, + /// A matched payload decoded successfully but did not match the requested concrete type. + UnexpectedPayloadType { + /// Matched path whose payload downcast failed. + path: BoxPath, + /// Concrete box type at that matched path. + box_type: FourCc, + /// File offset of the matched box header. + offset: u64, + /// Fully qualified Rust type name requested by the caller. + expected_type: &'static str, + }, } impl fmt::Display for ExtractError { @@ -191,6 +363,24 @@ impl fmt::Display for ExtractError { Self::Codec(error) => error.fmt(f), Self::Walk(error) => error.fmt(f), Self::EmptyPath => f.write_str("box path must not be empty"), + Self::PayloadDecode { + path, + box_type, + offset, + source, + } => write!( + f, + "failed to decode payload at {path} (type={box_type}, offset={offset}): {source}" + ), + Self::UnexpectedPayloadType { + path, + box_type, + offset, + expected_type, + } => write!( + f, + "unexpected decoded payload type at {path} (type={box_type}, offset={offset}): expected {expected_type}" + ), } } } @@ -202,7 +392,8 @@ impl Error for ExtractError { Self::Header(error) => Some(error), Self::Codec(error) => Some(error), Self::Walk(error) => Some(error), - Self::EmptyPath => None, + Self::PayloadDecode { source, .. } => Some(source), + Self::EmptyPath | Self::UnexpectedPayloadType { .. } => None, } } } diff --git a/src/lib.rs b/src/lib.rs index 983841e..301eddf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -//! Low-level MP4 and ISOBMFF toolkit. +//! MP4 and ISOBMFF toolkit with low-level building blocks and thin ergonomic helpers. /// Bit-level reader and writer helpers used by the codec layer. pub mod bitio; @@ -8,7 +8,7 @@ pub mod boxes; pub mod cli; /// Descriptor-driven binary codec primitives. pub mod codec; -/// Path-based box extraction helpers built on the structure walker. +/// Path-based box extraction helpers, including typed convenience reads. pub mod extract; /// Four-character box identifier support. pub mod fourcc; @@ -16,6 +16,8 @@ pub mod fourcc; pub mod header; /// File-summary helpers built on the extraction and box layers. pub mod probe; +/// Path-based typed payload rewrite helpers built on the writer layer. +pub mod rewrite; /// Stable field-order string rendering for descriptor-backed boxes. pub mod stringify; /// Depth-first structure walking with path tracking and lazy payload access. diff --git a/src/rewrite.rs b/src/rewrite.rs new file mode 100644 index 0000000..f11bf1f --- /dev/null +++ b/src/rewrite.rs @@ -0,0 +1,517 @@ +//! Path-based typed payload rewrite helpers built on the writer layer. +//! +//! These helpers preserve the existing low-level writer flow for advanced use cases while offering +//! a small typed API for common "find payloads at this path and mutate them" rewrite operations. + +use std::any::type_name; +use std::error::Error; +use std::fmt; +use std::io::{self, Read, Seek, SeekFrom, Write}; + +use crate::FourCc; +use crate::boxes::iso14496_12::Ftyp; +use crate::boxes::metadata::Keys; +use crate::boxes::{BoxLookupContext, BoxRegistry, default_registry}; +use crate::codec::{CodecBox, CodecError, marshal_dyn, unmarshal, unmarshal_any_with_context}; +use crate::header::{BoxInfo, HeaderError, SMALL_HEADER_SIZE}; +use crate::walk::{BoxPath, PathMatch}; +use crate::writer::{Writer, WriterError}; + +const FTYP: FourCc = FourCc::from_bytes(*b"ftyp"); +const KEYS: FourCc = FourCc::from_bytes(*b"keys"); +const QT_BRAND: FourCc = FourCc::from_bytes(*b"qt "); + +/// Rewrites every payload at `path` by downcasting it to `T` and applying `edit`. +/// +/// The edit closure runs once per matched box in depth-first order. The returned count is the +/// number of payloads that were successfully rewritten. Unmatched boxes are copied through to the +/// output verbatim. +pub fn rewrite_box_as( + reader: &mut R, + writer: W, + path: BoxPath, + edit: F, +) -> Result +where + R: Read + Seek, + W: Write + Seek, + T: CodecBox + 'static, + F: FnMut(&mut T), +{ + let paths = [path]; + rewrite_boxes_as(reader, writer, &paths, edit) +} + +/// Rewrites every payload that matches any path in `paths` by downcasting it to `T` and applying +/// `edit`. +/// +/// Every matched payload must decode to `T`, otherwise +/// [`RewriteError::UnexpectedPayloadType`] is returned with the matched path and offset. Unmatched +/// boxes are copied through to the output verbatim. +pub fn rewrite_boxes_as( + reader: &mut R, + writer: W, + paths: &[BoxPath], + edit: F, +) -> Result +where + R: Read + Seek, + W: Write + Seek, + T: CodecBox + 'static, + F: FnMut(&mut T), +{ + let registry = default_registry(); + rewrite_boxes_as_with_registry(reader, writer, paths, ®istry, edit) +} + +/// Rewrites every payload that matches any path in `paths` using `registry`, downcasts each match +/// to `T`, and applies `edit`. +/// +/// Paths are evaluated from the file root. Subtrees that cannot possibly match are copied without +/// decoding so unrelated bytes remain untouched. The returned count reports how many payloads were +/// edited. +pub fn rewrite_boxes_as_with_registry( + reader: &mut R, + writer: W, + paths: &[BoxPath], + registry: &BoxRegistry, + mut edit: F, +) -> Result +where + R: Read + Seek, + W: Write + Seek, + T: CodecBox + 'static, + F: FnMut(&mut T), +{ + validate_paths(paths)?; + reader.seek(SeekFrom::Start(0))?; + + let mut writer = Writer::new(writer); + if paths.is_empty() { + io::copy(reader, &mut writer)?; + return Ok(0); + } + + let mut rewritten_count = 0; + let mut plan = RewritePlan { + paths, + edit: &mut edit, + rewritten_count: &mut rewritten_count, + }; + rewrite_sequence::( + reader, + &mut writer, + registry, + &mut plan, + RewriteFrame::root(), + )?; + Ok(rewritten_count) +} + +#[derive(Clone)] +struct RewriteFrame { + remaining_size: u64, + is_root: bool, + path: BoxPath, + sibling_context: BoxLookupContext, +} + +impl RewriteFrame { + const fn root() -> Self { + Self { + remaining_size: 0, + is_root: true, + path: BoxPath::empty(), + sibling_context: BoxLookupContext::new(), + } + } + + fn child(remaining_size: u64, path: BoxPath, sibling_context: BoxLookupContext) -> Self { + Self { + remaining_size, + is_root: false, + path, + sibling_context, + } + } +} + +struct RewritePlan<'a, F> { + paths: &'a [BoxPath], + edit: &'a mut F, + rewritten_count: &'a mut usize, +} + +fn rewrite_sequence( + reader: &mut R, + writer: &mut Writer, + registry: &BoxRegistry, + plan: &mut RewritePlan<'_, F>, + mut frame: RewriteFrame, +) -> Result<(), RewriteError> +where + R: Read + Seek, + W: Write + Seek, + T: CodecBox + 'static, + F: FnMut(&mut T), +{ + loop { + if !frame.is_root && frame.remaining_size < SMALL_HEADER_SIZE { + break; + } + + let start = reader.stream_position()?; + let mut info = match BoxInfo::read(reader) { + Ok(info) => info, + Err(HeaderError::Io(error)) + if frame.is_root && clean_root_eof(reader, start, &error)? => + { + return Ok(()); + } + Err(error) => return Err(error.into()), + }; + + if !frame.is_root && info.size() > frame.remaining_size { + return Err(RewriteError::TooLargeBoxSize { + box_type: info.box_type(), + size: info.size(), + available_size: frame.remaining_size, + }); + } + if !frame.is_root { + frame.remaining_size -= info.size(); + } + + info.set_lookup_context(frame.sibling_context); + inspect_context_carriers(reader, &mut info, &frame.path)?; + process_box::(reader, writer, registry, plan, &frame, &info)?; + + if info.lookup_context().is_quicktime_compatible() { + frame.sibling_context = frame.sibling_context.with_quicktime_compatible(true); + } + if info.box_type() == KEYS { + frame.sibling_context = frame + .sibling_context + .with_metadata_keys_entry_count(info.lookup_context().metadata_keys_entry_count()); + } + } + + if !frame.is_root + && frame.remaining_size != 0 + && !frame.sibling_context.is_quicktime_compatible() + { + return Err(RewriteError::UnexpectedEof); + } + + Ok(()) +} + +fn process_box( + reader: &mut R, + writer: &mut Writer, + registry: &BoxRegistry, + plan: &mut RewritePlan<'_, F>, + frame: &RewriteFrame, + info: &BoxInfo, +) -> Result<(), RewriteError> +where + R: Read + Seek, + W: Write + Seek, + T: CodecBox + 'static, + F: FnMut(&mut T), +{ + let current_path = child_path(&frame.path, info.box_type()); + let path_match = match_paths(plan.paths, ¤t_path); + if !path_match.forward_match && !path_match.exact_match { + writer.copy_box(reader, info)?; + return Ok(()); + } + + info.seek_to_payload(reader)?; + let payload_size = info.payload_size()?; + let (mut payload, payload_read) = unmarshal_any_with_context( + reader, + 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 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(placeholder)?; + marshal_dyn(&mut *writer, payload.as_ref(), None).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 = info + .offset() + .saturating_add(info.size()) + .saturating_sub(children_offset); + reader.seek(SeekFrom::Start(children_offset))?; + rewrite_sequence::( + reader, + writer, + registry, + plan, + RewriteFrame::child( + children_size, + current_path, + info.lookup_context().enter(info.box_type()), + ), + )?; + info.seek_to_end(reader)?; + writer.end_box()?; + Ok(()) +} + +fn inspect_context_carriers( + reader: &mut R, + info: &mut BoxInfo, + path: &BoxPath, +) -> Result<(), RewriteError> +where + R: Read + Seek, +{ + if path.is_empty() && info.box_type() == FTYP { + let ftyp = decode_box::<_, Ftyp>(reader, info)?; + if ftyp.has_compatible_brand(QT_BRAND) { + info.set_lookup_context(info.lookup_context().with_quicktime_compatible(true)); + } + } + + if info.box_type() == KEYS { + let keys = decode_box::<_, Keys>(reader, info)?; + info.set_lookup_context( + info.lookup_context() + .with_metadata_keys_entry_count(keys.entry_count as usize), + ); + } + + Ok(()) +} + +fn decode_box(reader: &mut R, info: &BoxInfo) -> Result +where + R: Read + Seek, + B: Default + CodecBox, +{ + info.seek_to_payload(reader)?; + let mut decoded = B::default(); + unmarshal(reader, info.payload_size()?, &mut decoded, None)?; + info.seek_to_payload(reader)?; + Ok(decoded) +} + +fn clean_root_eof(reader: &mut R, start: u64, error: &io::Error) -> Result +where + R: Seek, +{ + if error.kind() != io::ErrorKind::UnexpectedEof { + return Ok(false); + } + + let end = reader.seek(SeekFrom::End(0))?; + Ok(start == end) +} + +fn validate_paths(paths: &[BoxPath]) -> Result<(), RewriteError> { + if paths.iter().any(BoxPath::is_empty) { + return Err(RewriteError::EmptyPath); + } + + Ok(()) +} + +fn child_path(path: &BoxPath, box_type: FourCc) -> BoxPath { + path.iter() + .copied() + .chain(std::iter::once(box_type)) + .collect() +} + +fn match_paths(paths: &[BoxPath], current: &BoxPath) -> PathMatch { + paths + .iter() + .fold(PathMatch::default(), |mut matched, path| { + let next = current.compare_with(path); + matched.forward_match |= next.forward_match; + matched.exact_match |= next.exact_match; + matched + }) +} + +/// Errors raised while rewriting path-matched payloads. +#[derive(Debug)] +pub enum RewriteError { + /// An I/O operation failed while reading, seeking, or writing. + Io(io::Error), + /// Box header metadata was invalid or truncated. + Header(HeaderError), + /// Payload codec work failed before a more specific matched-box context was available. + Codec(CodecError), + /// Low-level writer state became invalid. + Writer(WriterError), + /// One of the requested paths was empty. + EmptyPath, + /// A matched payload failed to decode with contextual path metadata. + PayloadDecode { + /// Matched path that was being decoded when the failure happened. + path: BoxPath, + /// Concrete box type at that matched path. + box_type: FourCc, + /// File offset of the matched box header. + offset: u64, + /// Underlying decode failure. + source: CodecError, + }, + /// A matched payload failed to encode after the edit closure mutated it. + PayloadEncode { + /// Matched path that was being re-encoded when the failure happened. + path: BoxPath, + /// Concrete box type at that matched path. + box_type: FourCc, + /// File offset of the matched box header. + offset: u64, + /// Underlying encode failure. + source: CodecError, + }, + /// A matched payload decoded successfully but did not match the requested concrete type. + UnexpectedPayloadType { + /// Matched path whose payload downcast failed. + path: BoxPath, + /// Concrete box type at that matched path. + box_type: FourCc, + /// File offset of the matched box header. + offset: u64, + /// Fully qualified Rust type name requested by the caller. + expected_type: &'static str, + }, + /// A child box claimed more bytes than remain in its parent container. + TooLargeBoxSize { + /// Concrete box type whose declared size was invalid in the current container. + box_type: FourCc, + /// Declared child box size. + size: u64, + /// Remaining bytes available in the parent container. + available_size: u64, + }, + /// A non-QuickTime container ended before all advertised child bytes were consumed. + UnexpectedEof, +} + +impl fmt::Display for RewriteError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => error.fmt(f), + Self::Header(error) => error.fmt(f), + Self::Codec(error) => error.fmt(f), + Self::Writer(error) => error.fmt(f), + Self::EmptyPath => f.write_str("box path must not be empty"), + Self::PayloadDecode { + path, + box_type, + offset, + source, + } => write!( + f, + "failed to decode payload at {path} (type={box_type}, offset={offset}): {source}" + ), + Self::PayloadEncode { + path, + box_type, + offset, + source, + } => write!( + f, + "failed to encode payload at {path} (type={box_type}, offset={offset}): {source}" + ), + Self::UnexpectedPayloadType { + path, + box_type, + offset, + expected_type, + } => write!( + f, + "unexpected decoded payload type at {path} (type={box_type}, offset={offset}): expected {expected_type}" + ), + Self::TooLargeBoxSize { + box_type, + size, + available_size, + } => write!( + f, + "too large box size: type={box_type}, size={size}, actualBufSize={available_size}" + ), + Self::UnexpectedEof => f.write_str("unexpected EOF"), + } + } +} + +impl Error for RewriteError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::Header(error) => Some(error), + Self::Codec(error) => Some(error), + Self::Writer(error) => Some(error), + Self::PayloadDecode { source, .. } | Self::PayloadEncode { source, .. } => Some(source), + Self::EmptyPath + | Self::UnexpectedPayloadType { .. } + | Self::TooLargeBoxSize { .. } + | Self::UnexpectedEof => None, + } + } +} + +impl From for RewriteError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +impl From for RewriteError { + fn from(value: HeaderError) -> Self { + Self::Header(value) + } +} + +impl From for RewriteError { + fn from(value: CodecError) -> Self { + Self::Codec(value) + } +} + +impl From for RewriteError { + fn from(value: WriterError) -> Self { + Self::Writer(value) + } +} diff --git a/src/walk.rs b/src/walk.rs index f06a2a8..ff40817 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -26,6 +26,9 @@ pub enum WalkControl { } /// Ordered sequence of box identifiers from the root to the current box. +/// +/// Path comparisons used by the extraction and rewrite helpers honor [`FourCc::ANY`] as a +/// wildcard segment. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct BoxPath(Vec); @@ -89,6 +92,23 @@ impl Deref for BoxPath { } } +impl fmt::Display for BoxPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.is_empty() { + return f.write_str(""); + } + + for (index, box_type) in self.0.iter().enumerate() { + if index != 0 { + f.write_str("/")?; + } + write!(f, "{box_type}")?; + } + + Ok(()) + } +} + impl From> for BoxPath { fn from(value: Vec) -> Self { Self(value) @@ -409,14 +429,22 @@ where /// Errors raised while walking a box tree. #[derive(Debug)] pub enum WalkError { + /// An I/O operation failed while reading or seeking. Io(io::Error), + /// Box header metadata was invalid or truncated. Header(HeaderError), + /// Payload decode failed while the walker was inspecting or expanding a box. Codec(CodecError), + /// A child box declared a size larger than the remaining bytes in its parent container. TooLargeBoxSize { + /// Concrete box type whose declared size exceeded the available bytes. box_type: FourCc, + /// Declared child box size. size: u64, + /// Remaining bytes available in the parent container. available_size: u64, }, + /// A non-QuickTime container ended before all advertised child bytes were consumed. UnexpectedEof, } diff --git a/src/writer.rs b/src/writer.rs index e3cceee..22a077c 100644 --- a/src/writer.rs +++ b/src/writer.rs @@ -8,6 +8,9 @@ use crate::FourCc; use crate::header::{BoxInfo, HeaderError, SMALL_HEADER_SIZE}; /// Stateful MP4 writer that can backfill container sizes after payload bytes are written. +/// +/// This wrapper is designed for rewrite-style flows that need to stream payload bytes, nest +/// container boxes, and then patch final sizes back into previously written headers. pub struct Writer { writer: W, box_stack: Vec, @@ -43,11 +46,16 @@ where W: Write + Seek, { /// Starts a new box using `box_type` and an empty small-header placeholder. + /// + /// The final size is written later by [`Writer::end_box`]. pub fn start_box_type(&mut self, box_type: FourCc) -> Result { self.start_box(BoxInfo::new(box_type, SMALL_HEADER_SIZE)) } /// Writes `info` as the next box header and pushes it onto the open-box stack. + /// + /// Callers typically pass either a small-header placeholder or a header copied from an + /// existing box when preserving layout details. pub fn start_box(&mut self, info: BoxInfo) -> Result { let written = info.write(&mut self.writer)?; self.box_stack.push(written); @@ -55,6 +63,8 @@ where } /// Rewrites the most recently opened box header with its final size. + /// + /// The returned [`BoxInfo`] reflects the finalized on-disk size after the rewrite completes. pub fn end_box(&mut self) -> Result { let Some(started) = self.box_stack.pop() else { return Err(WriterError::NoOpenBox); @@ -133,21 +143,35 @@ where /// Errors raised while writing or copying MP4 boxes. #[derive(Debug)] pub enum WriterError { + /// An I/O operation failed while reading, seeking, or writing. Io(io::Error), + /// Box header metadata was invalid or could not be encoded. Header(HeaderError), + /// [`Writer::end_box`] was called with no corresponding open box. NoOpenBox, + /// The current writer position moved before the recorded start offset of the open box. InvalidBoxSpan { + /// Concrete box type being closed. box_type: FourCc, + /// Recorded start offset of the open box. offset: u64, + /// Current writer position observed during close. end: u64, }, + /// Re-encoding the finalized header changed its encoded width. HeaderSizeChanged { + /// Concrete box type being closed. box_type: FourCc, + /// Header width used when the box was opened. original_header_size: u64, + /// Header width produced by the finalized size. rewritten_header_size: u64, }, + /// A raw-copy operation ended before all requested bytes were copied. IncompleteCopy { + /// Number of bytes that should have been copied. expected_size: u64, + /// Number of bytes that were actually copied. actual_size: u64, }, } diff --git a/tests/extract.rs b/tests/extract.rs index 907f640..82c919c 100644 --- a/tests/extract.rs +++ b/tests/extract.rs @@ -1,12 +1,14 @@ use std::io::Cursor; use mp4forge::boxes::AnyTypeBox; -use mp4forge::boxes::iso14496_12::{Ftyp, Meta, Moov, Trak, Udta}; +use mp4forge::boxes::iso14496_12::{Ftyp, Meta, Moov, Tkhd, Trak, Udta}; use mp4forge::boxes::metadata::{ DATA_TYPE_STRING_UTF8, Data, Ilst, Key, Keys, NumberedMetadataItem, }; use mp4forge::codec::{CodecBox, marshal}; -use mp4forge::extract::{ExtractError, extract_box, extract_box_with_payload, extract_boxes}; +use mp4forge::extract::{ + ExtractError, extract_box, extract_box_as, extract_box_with_payload, extract_boxes, +}; use mp4forge::stringify::stringify; use mp4forge::walk::BoxPath; use mp4forge::{BoxInfo, FourCc}; @@ -122,6 +124,117 @@ fn extract_box_with_payload_uses_walked_lookup_context() { assert_eq!(numbered.data.data, b"1.0.0"); } +#[test] +fn extract_box_as_returns_typed_payloads() { + let mut tkhd_a = Tkhd::default(); + tkhd_a.track_id = 1; + let mut tkhd_b = Tkhd::default(); + tkhd_b.track_id = 2; + let trak_a = encode_supported_box(&Trak, &encode_supported_box(&tkhd_a, &[])); + let trak_b = encode_supported_box(&Trak, &encode_supported_box(&tkhd_b, &[])); + let moov = encode_supported_box(&Moov, &[trak_a, trak_b].concat()); + + let extracted = extract_box_as::<_, Tkhd>( + &mut Cursor::new(moov), + None, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("tkhd")]), + ) + .unwrap(); + + assert_eq!(extracted.len(), 2); + assert_eq!( + extracted + .iter() + .map(|tkhd| tkhd.track_id) + .collect::>(), + vec![1, 2] + ); +} + +#[test] +fn extract_box_as_uses_walked_lookup_context() { + let qt = fourcc("qt "); + let ftyp = Ftyp { + major_brand: qt, + minor_version: 0x0200, + compatible_brands: vec![qt], + }; + let mut keys = Keys::default(); + keys.entry_count = 1; + keys.entries = vec![Key { + key_size: 9, + key_namespace: fourcc("mdta"), + key_value: vec![b'x'], + }]; + + let mut numbered = NumberedMetadataItem::default(); + numbered.set_box_type(FourCc::from_u32(1)); + numbered.item_name = fourcc("data"); + numbered.data = Data { + data_type: DATA_TYPE_STRING_UTF8, + data_lang: 0, + data: b"1.0.0".to_vec(), + }; + + let keys_box = encode_supported_box(&keys, &[]); + let numbered_box = encode_supported_box(&numbered, &[]); + let ilst_box = encode_supported_box(&Ilst, &numbered_box); + let meta_box = encode_supported_box(&Meta::default(), &[keys_box, ilst_box].concat()); + let moov_box = encode_supported_box(&Moov, &meta_box); + let file = [encode_supported_box(&ftyp, &[]), moov_box].concat(); + + let extracted = extract_box_as::<_, NumberedMetadataItem>( + &mut Cursor::new(file), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("meta"), + fourcc("ilst"), + FourCc::from_u32(1), + ]), + ) + .unwrap(); + + assert_eq!(extracted.len(), 1); + assert_eq!(extracted[0].item_name, fourcc("data")); + assert_eq!(extracted[0].data.data, b"1.0.0"); +} + +#[test] +fn extract_box_as_reports_payload_type_context() { + let mut tkhd = Tkhd::default(); + tkhd.track_id = 7; + let trak = encode_supported_box(&Trak, &encode_supported_box(&tkhd, &[])); + let moov = encode_supported_box(&Moov, &trak); + + let error = extract_box_as::<_, Meta>( + &mut Cursor::new(moov), + None, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("tkhd")]), + ) + .unwrap_err(); + + assert!(matches!( + error, + ExtractError::UnexpectedPayloadType { + ref path, + box_type, + offset, + expected_type + } if path.as_slice() == [fourcc("moov"), fourcc("trak"), fourcc("tkhd")] + && box_type == fourcc("tkhd") + && offset == 16 + && expected_type == std::any::type_name::() + )); + assert_eq!( + error.to_string(), + format!( + "unexpected decoded payload type at moov/trak/tkhd (type=tkhd, offset=16): expected {}", + std::any::type_name::() + ) + ); +} + #[test] fn extract_box_rejects_empty_paths() { let error = diff --git a/tests/malformed_inputs.rs b/tests/malformed_inputs.rs index fd796d3..63f9e48 100644 --- a/tests/malformed_inputs.rs +++ b/tests/malformed_inputs.rs @@ -90,8 +90,15 @@ fn extract_box_with_payload_rejects_truncated_supported_payloads() { assert!(matches!( error, - ExtractError::Codec(CodecError::Io(ref io_error)) - if io_error.kind() == std::io::ErrorKind::UnexpectedEof + ExtractError::PayloadDecode { + path, + box_type, + offset: 0, + source: CodecError::Io(ref io_error) + } + if path.as_slice() == [fourcc("mvhd")] + && box_type == fourcc("mvhd") + && io_error.kind() == std::io::ErrorKind::UnexpectedEof )); } diff --git a/tests/rewrite.rs b/tests/rewrite.rs new file mode 100644 index 0000000..864bdb3 --- /dev/null +++ b/tests/rewrite.rs @@ -0,0 +1,140 @@ +#![allow(clippy::field_reassign_with_default)] + +mod support; + +use std::fs; +use std::io::Cursor; + +use mp4forge::boxes::iso14496_12::{Meta, Moof, Tfdt, Traf}; +use mp4forge::extract::extract_box_as; +use mp4forge::rewrite::{RewriteError, rewrite_box_as}; +use mp4forge::walk::BoxPath; + +use support::{encode_raw_box, encode_supported_box, fixture_path, fourcc}; + +#[test] +fn rewrite_box_as_updates_matching_typed_payloads() { + let input = build_rewrite_input_file(); + let mut reader = Cursor::new(input); + let mut output = Cursor::new(Vec::new()); + + let rewritten = rewrite_box_as::<_, _, Tfdt, _>( + &mut reader, + &mut output, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfdt")]), + |tfdt| { + tfdt.base_media_decode_time_v0 = 12_345; + }, + ) + .unwrap(); + + let tfdt = extract_box_as::<_, Tfdt>( + &mut Cursor::new(output.into_inner()), + None, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfdt")]), + ) + .unwrap(); + + assert_eq!(rewritten, 1); + assert_eq!(tfdt.len(), 1); + assert_eq!(tfdt[0].base_media_decode_time_v0, 12_345); +} + +#[test] +fn rewrite_box_as_returns_zero_and_preserves_bytes_when_nothing_matches() { + let input = fs::read(fixture_path("sample_fragmented.mp4")).unwrap(); + let mut reader = Cursor::new(input.clone()); + let mut output = Cursor::new(Vec::new()); + + let rewritten = rewrite_box_as::<_, _, Tfdt, _>( + &mut reader, + &mut output, + BoxPath::from([fourcc("zzzz")]), + |_| {}, + ) + .unwrap(); + + assert_eq!(rewritten, 0); + assert_eq!(output.into_inner(), input); +} + +#[test] +fn rewrite_box_as_reports_payload_type_context() { + let input = build_rewrite_input_file(); + let mut reader = Cursor::new(input); + let mut output = Cursor::new(Vec::new()); + + let error = rewrite_box_as::<_, _, Meta, _>( + &mut reader, + &mut output, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfdt")]), + |_| {}, + ) + .unwrap_err(); + + assert!(matches!( + error, + RewriteError::UnexpectedPayloadType { + ref path, + box_type, + offset, + expected_type + } if path.as_slice() == [fourcc("moof"), fourcc("traf"), fourcc("tfdt")] + && box_type == fourcc("tfdt") + && offset == 16 + && expected_type == std::any::type_name::() + )); +} + +#[test] +fn rewrite_box_as_reports_payload_decode_context() { + let mut bytes = encode_raw_box(fourcc("tfdt"), &[0x00, 0x00, 0x00, 0x00]); + bytes.truncate(12); + + let mut reader = Cursor::new(bytes); + let mut output = Cursor::new(Vec::new()); + + let error = rewrite_box_as::<_, _, Tfdt, _>( + &mut reader, + &mut output, + BoxPath::from([fourcc("tfdt")]), + |_| {}, + ) + .unwrap_err(); + + assert!(matches!( + error, + RewriteError::PayloadDecode { + path, + box_type, + offset: 0, + source: mp4forge::codec::CodecError::Io(ref io_error) + } if path.as_slice() == [fourcc("tfdt")] + && box_type == fourcc("tfdt") + && io_error.kind() == std::io::ErrorKind::UnexpectedEof + )); +} + +#[test] +fn rewrite_box_as_rejects_empty_paths() { + let mut reader = Cursor::new(Vec::::new()); + let mut output = Cursor::new(Vec::new()); + + let error = + rewrite_box_as::<_, _, Tfdt, _>(&mut reader, &mut output, BoxPath::default(), |_| {}) + .unwrap_err(); + + assert!(matches!(error, RewriteError::EmptyPath)); +} + +fn build_rewrite_input_file() -> Vec { + let mut tfdt = Tfdt::default(); + tfdt.base_media_decode_time_v0 = 9_000; + + let tfdt = encode_supported_box(&tfdt, &[]); + let traf = encode_supported_box(&Traf, &tfdt); + let moof = encode_supported_box(&Moof, &traf); + let mdat = encode_raw_box(fourcc("mdat"), &[0, 1, 2, 3]); + + [moof, mdat].concat() +}