From 2d97a7b6d3f0d1510aa65e28372150aa770a34e0 Mon Sep 17 00:00:00 2001 From: Alfred Gutierrez Date: Sat, 4 Jul 2026 13:45:10 +0900 Subject: [PATCH 1/3] feat: add non-destructive box editing (edit module, mp4edit CLI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the PR #5 splice-based prototype with an extent-tree design whose two correctness gaps (stale ancestor sizes, heuristic chunk-offset fixup) are impossible by construction. Design: - edit/tree.rs: the file parses into a tree where unmodified boxes are byte-range references into the source, not copies. Container prefix bytes (stsd entry_count, sample-entry fixed headers), padding, and original header forms (compact/largesize/to-EOF) are preserved. Serialization streams extents in chunks (mdat never held in memory) and recomputes box sizes bottom-up, so every ancestor of an edited box is correct by construction. Invariant: serializing an unedited tree reproduces the source byte for byte. - edit/fixup.rs: layout produces an exact old->new extent map; each stco/co64 entry is remapped through the extent containing it. No heuristics: unmoved data keeps its offsets, moved data shifts by exactly its extent's delta. Entries pointing at removed data are left unchanged and reported. - edit/fields.rs: Set patches named fields (mvhd/tkhd/mdhd timestamps, timescale, duration, rate, volume, track_id, layer, width/height, language) in place with version-aware offsets; matrices, reserved regions, and unknown bytes are untouched — no re-encoding. - edit/mod.rs + tags.rs: Editor with Remove/RemoveAll/Insert/Replace/ Set/SetTag commands; slash paths with [n] indexing and © fourccs; SetTag creates the moov/udta/meta/ilst chain (ffmpeg-compatible hdlr) when missing. Fragmented and HEIF files (moof/sidx/iloc) are refused with a clear error since their internal offsets are not yet covered by fixup; no-op pass-through still works. Packaging: "edit" cargo feature (on by default; disable for a parse-only build), mp4edit binary (--remove/--remove-all/--insert/ --replace/--set/--tag), and examples/edit.rs. Tests (tests/edit_roundtrip.rs): byte-identical zero-edit round-trips on synthetic and real files; removal before mdat shifts stco entries by exactly the removed size while removal after mdat leaves them untouched; samples still resolve to identical media bytes after every edit; ancestor sizes re-tile the file with no gaps; Set changes only the addressed bytes; tag create/replace; fragmented-file guard. Verified on a real file with ffprobe/ffmpeg: tags readable, all 2336 chunk offsets adjusted, zero decode errors. --- examples/edit.rs | 35 +++ src/bin/mp4edit.rs | 128 ++++++++++ src/edit/fields.rs | 155 ++++++++++++ src/edit/fixup.rs | 136 +++++++++++ src/edit/mod.rs | 403 +++++++++++++++++++++++++++++++ src/edit/tags.rs | 148 ++++++++++++ src/edit/tree.rs | 448 +++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + tests/edit_roundtrip.rs | 511 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1966 insertions(+) create mode 100644 examples/edit.rs create mode 100644 src/bin/mp4edit.rs create mode 100644 src/edit/fields.rs create mode 100644 src/edit/fixup.rs create mode 100644 src/edit/mod.rs create mode 100644 src/edit/tags.rs create mode 100644 src/edit/tree.rs create mode 100644 tests/edit_roundtrip.rs diff --git a/examples/edit.rs b/examples/edit.rs new file mode 100644 index 0000000..89d0691 --- /dev/null +++ b/examples/edit.rs @@ -0,0 +1,35 @@ +use mp4box::edit::Editor; +use std::env; + +// This example demonstrates the edit API: it copies an MP4 while setting +// iTunes tags (creating moov/udta/meta/ilst if needed) and zeroing the +// movie timestamps. Box sizes and stco/co64 chunk offsets are fixed up +// automatically; the input file is never modified. +// +// Requires the "edit" feature (enabled by default): +// cargo run --example edit -- in.mp4 out.mp4 +fn main() -> anyhow::Result<()> { + let args: Vec = env::args().collect(); + if args.len() != 3 { + eprintln!("Usage: {} ", args[0]); + std::process::exit(1); + } + + let mut editor = Editor::new(); + editor.set_tag("title", "Edited with mp4box")?; + editor.set_tag("encoder", "mp4box edit example")?; + editor.set_field("moov/mvhd", "creation_time", "0"); + editor.set_field("moov/mvhd", "modification_time", "0"); + + let stats = editor.process_file(&args[1], &args[2])?; + + println!("wrote {} bytes to {}", stats.bytes_written, args[2]); + println!("chunk offsets adjusted: {}", stats.chunk_offsets_adjusted); + if stats.chunk_offsets_unmapped > 0 { + eprintln!( + "warning: {} chunk offsets point at removed data", + stats.chunk_offsets_unmapped + ); + } + Ok(()) +} diff --git a/src/bin/mp4edit.rs b/src/bin/mp4edit.rs new file mode 100644 index 0000000..b3dc952 --- /dev/null +++ b/src/bin/mp4edit.rs @@ -0,0 +1,128 @@ +use clap::Parser; +use mp4box::edit::{Command, Editor}; + +/// mp4edit — non-destructive MP4/ISOBMFF box editor. +/// +/// Commands apply in order to a single parsed tree; the output is written as +/// a new file with all box sizes and chunk offsets fixed up automatically. +/// Box paths are slash-delimited fourccs with optional indices for repeated +/// boxes, e.g. `moov/trak[1]/mdia/mdhd`. +#[derive(Parser, Debug)] +#[command(version, about = "Non-destructive MP4/ISOBMFF box editor")] +struct Args { + /// Remove a box: --remove moov/udta + #[arg(long = "remove", value_name = "PATH")] + remove: Vec, + + /// Remove every box with this fourcc, anywhere: --remove-all free + #[arg(long = "remove-all", value_name = "FOURCC")] + remove_all: Vec, + + /// Insert a raw box file (header + payload) as a child. + /// Format: PARENT_PATH:FILE[:POSITION] + #[arg(long = "insert", value_name = "PATH:FILE[:POS]")] + insert: Vec, + + /// Replace a box wholesale with a raw box file. Format: PATH:FILE + #[arg(long = "replace", value_name = "PATH:FILE")] + replace: Vec, + + /// Set a field of a known box (mvhd/tkhd/mdhd) in place. + /// Format: PATH.FIELD=VALUE, e.g. --set moov/mvhd.creation_time=0 + #[arg(long = "set", value_name = "PATH.FIELD=VALUE")] + set: Vec, + + /// Set an iTunes tag (creates moov/udta/meta/ilst as needed). + /// Format: NAME=VALUE with a friendly name (title, artist, album, ...) + /// or a raw fourcc, e.g. --tag title="My Movie" + #[arg(long = "tag", value_name = "NAME=VALUE")] + tag: Vec, + + /// Input MP4/ISOBMFF file (never modified) + input: String, + + /// Output file path + output: String, +} + +fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let mut editor = Editor::new(); + + for path in &args.remove { + editor.add_command(Command::Remove { path: path.clone() }); + } + + for fourcc in &args.remove_all { + editor.add_command(Command::RemoveAll { + fourcc: fourcc.clone(), + }); + } + + for spec in &args.insert { + let parts: Vec<&str> = spec.splitn(3, ':').collect(); + anyhow::ensure!( + parts.len() >= 2, + "--insert format is PATH:FILE or PATH:FILE:POSITION, got '{}'", + spec + ); + let position = + match parts.get(2) { + Some(p) => Some(p.parse::().map_err(|_| { + anyhow::anyhow!("--insert position must be a number, got '{}'", p) + })?), + None => None, + }; + editor.add_command(Command::Insert { + parent: parts[0].to_string(), + bytes: std::fs::read(parts[1])?, + position, + }); + } + + for spec in &args.replace { + let (path, file) = spec + .split_once(':') + .ok_or_else(|| anyhow::anyhow!("--replace format is PATH:FILE, got '{}'", spec))?; + editor.add_command(Command::Replace { + path: path.to_string(), + bytes: std::fs::read(file)?, + }); + } + + for spec in &args.set { + // PATH.FIELD=VALUE — the field name never contains '.' or '='. + let (lhs, value) = spec + .split_once('=') + .ok_or_else(|| anyhow::anyhow!("--set format is PATH.FIELD=VALUE, got '{}'", spec))?; + let (path, field) = lhs + .rsplit_once('.') + .ok_or_else(|| anyhow::anyhow!("--set format is PATH.FIELD=VALUE, got '{}'", spec))?; + editor.add_command(Command::Set { + path: path.to_string(), + field: field.to_string(), + value: value.to_string(), + }); + } + + for spec in &args.tag { + let (name, value) = spec + .split_once('=') + .ok_or_else(|| anyhow::anyhow!("--tag format is NAME=VALUE, got '{}'", spec))?; + editor.set_tag(name, value)?; + } + + let stats = editor.process_file(&args.input, &args.output)?; + println!( + "wrote {} ({} bytes, {} chunk offsets adjusted{})", + args.output, + stats.bytes_written, + stats.chunk_offsets_adjusted, + if stats.chunk_offsets_unmapped > 0 { + format!(", {} unmapped!", stats.chunk_offsets_unmapped) + } else { + String::new() + } + ); + Ok(()) +} diff --git a/src/edit/fields.rs b/src/edit/fields.rs new file mode 100644 index 0000000..d973940 --- /dev/null +++ b/src/edit/fields.rs @@ -0,0 +1,155 @@ +//! `Set` support: version-aware, in-place patching of named fields inside +//! known boxes. Only the addressed bytes change — matrices, reserved +//! regions, and unknown trailing data are preserved verbatim, and no +//! re-encoding is involved. + +/// Wire format of a patchable field. +#[derive(Debug, Clone, Copy)] +pub enum FieldKind { + U32, + U64, + I16, + /// 16.16 fixed point, set from a decimal value (e.g. width, rate) + Fixed1616, + /// 8.8 fixed point (volume) + Fixed88, + /// ISO-639-2/T packed 3-letter language code + Lang, +} + +impl FieldKind { + pub fn width(&self) -> usize { + match self { + FieldKind::I16 | FieldKind::Fixed88 | FieldKind::Lang => 2, + FieldKind::U32 | FieldKind::Fixed1616 => 4, + FieldKind::U64 => 8, + } + } +} + +/// Byte offset (relative to the start of the box payload, i.e. the version +/// byte) and kind of `field` in a `fourcc` box of the given version. +/// Returns `None` for unknown box/field combinations. +pub fn field_spec(fourcc: &[u8; 4], version: u8, field: &str) -> Option<(usize, FieldKind)> { + use FieldKind::*; + // All offsets are 4 + . + let spec = match (fourcc, version, field) { + // mvhd v0: creation(4) modification(4) timescale(4) duration(4) + // rate(4) volume(2) ... + (b"mvhd", 0, "creation_time") => (4, U32), + (b"mvhd", 0, "modification_time") => (8, U32), + (b"mvhd", 0, "timescale") => (12, U32), + (b"mvhd", 0, "duration") => (16, U32), + (b"mvhd", 0, "rate") => (20, Fixed1616), + (b"mvhd", 0, "volume") => (24, Fixed88), + // reserved(10) matrix(36) pre_defined(24) => next_track_id + (b"mvhd", 0, "next_track_id") => (24 + 2 + 10 + 36 + 24, U32), + + // mvhd v1: creation(8) modification(8) timescale(4) duration(8) ... + (b"mvhd", 1, "creation_time") => (4, U64), + (b"mvhd", 1, "modification_time") => (12, U64), + (b"mvhd", 1, "timescale") => (20, U32), + (b"mvhd", 1, "duration") => (24, U64), + (b"mvhd", 1, "rate") => (32, Fixed1616), + (b"mvhd", 1, "volume") => (36, Fixed88), + (b"mvhd", 1, "next_track_id") => (36 + 2 + 10 + 36 + 24, U32), + + // tkhd v0: creation(4) modification(4) track_id(4) reserved(4) + // duration(4) reserved(8) layer(2) alternate_group(2) + // volume(2) reserved(2) matrix(36) width(4) height(4) + (b"tkhd", 0, "creation_time") => (4, U32), + (b"tkhd", 0, "modification_time") => (8, U32), + (b"tkhd", 0, "track_id") => (12, U32), + (b"tkhd", 0, "duration") => (20, U32), + (b"tkhd", 0, "layer") => (32, I16), + (b"tkhd", 0, "alternate_group") => (34, I16), + (b"tkhd", 0, "volume") => (36, Fixed88), + (b"tkhd", 0, "width") => (40 + 36, Fixed1616), + (b"tkhd", 0, "height") => (40 + 36 + 4, Fixed1616), + + // tkhd v1: creation(8) modification(8) track_id(4) reserved(4) + // duration(8) ... + (b"tkhd", 1, "creation_time") => (4, U64), + (b"tkhd", 1, "modification_time") => (12, U64), + (b"tkhd", 1, "track_id") => (20, U32), + (b"tkhd", 1, "duration") => (28, U64), + (b"tkhd", 1, "layer") => (44, I16), + (b"tkhd", 1, "alternate_group") => (46, I16), + (b"tkhd", 1, "volume") => (48, Fixed88), + (b"tkhd", 1, "width") => (52 + 36, Fixed1616), + (b"tkhd", 1, "height") => (52 + 36 + 4, Fixed1616), + + // mdhd v0: creation(4) modification(4) timescale(4) duration(4) + // language(2) + (b"mdhd", 0, "creation_time") => (4, U32), + (b"mdhd", 0, "modification_time") => (8, U32), + (b"mdhd", 0, "timescale") => (12, U32), + (b"mdhd", 0, "duration") => (16, U32), + (b"mdhd", 0, "language") => (20, Lang), + + // mdhd v1: creation(8) modification(8) timescale(4) duration(8) + // language(2) + (b"mdhd", 1, "creation_time") => (4, U64), + (b"mdhd", 1, "modification_time") => (12, U64), + (b"mdhd", 1, "timescale") => (20, U32), + (b"mdhd", 1, "duration") => (24, U64), + (b"mdhd", 1, "language") => (32, Lang), + + _ => return None, + }; + Some(spec) +} + +/// Parse `value` per the field kind and patch it into `payload` at `offset`. +pub fn patch_field( + payload: &mut [u8], + offset: usize, + kind: FieldKind, + value: &str, +) -> anyhow::Result<()> { + let width = kind.width(); + anyhow::ensure!( + offset + width <= payload.len(), + "field at offset {} does not fit in a {}-byte payload", + offset, + payload.len() + ); + let dst = &mut payload[offset..offset + width]; + + match kind { + FieldKind::U32 => dst.copy_from_slice(&value.parse::()?.to_be_bytes()), + FieldKind::U64 => dst.copy_from_slice(&value.parse::()?.to_be_bytes()), + FieldKind::I16 => dst.copy_from_slice(&value.parse::()?.to_be_bytes()), + FieldKind::Fixed1616 => { + let v = value.parse::()?; + anyhow::ensure!( + (0.0..65536.0).contains(&v), + "value {} out of range for 16.16 fixed point", + v + ); + dst.copy_from_slice(&((v * 65536.0).round() as u32).to_be_bytes()); + } + FieldKind::Fixed88 => { + let v = value.parse::()?; + anyhow::ensure!( + (0.0..256.0).contains(&v), + "value {} out of range for 8.8 fixed point", + v + ); + dst.copy_from_slice(&((v * 256.0).round() as u16).to_be_bytes()); + } + FieldKind::Lang => { + let b = value.as_bytes(); + anyhow::ensure!( + b.len() == 3 && b.iter().all(|c| c.is_ascii_lowercase()), + "language must be a 3-letter lowercase ISO-639-2 code, got {:?}", + value + ); + let packed: u16 = (((b[0] - 0x60) as u16) << 10) + | (((b[1] - 0x60) as u16) << 5) + | ((b[2] - 0x60) as u16); + dst.copy_from_slice(&packed.to_be_bytes()); + } + } + Ok(()) +} diff --git a/src/edit/fixup.rs b/src/edit/fixup.rs new file mode 100644 index 0000000..d3fce2f --- /dev/null +++ b/src/edit/fixup.rs @@ -0,0 +1,136 @@ +//! Chunk-offset fixup: after layout, every `stco`/`co64` entry is remapped +//! through the extent map — the exact record of where each source byte range +//! lands in the output. No heuristics: offsets into data that didn't move are +//! unchanged, offsets into moved data shift by exactly that extent's delta. + +use super::tree::{EditNode, EditTree, Extent, ExtentMapping, Payload}; +use std::io::{Read, Seek, SeekFrom}; + +#[derive(Debug, Default)] +pub struct FixupStats { + /// Chunk-offset entries rewritten to a new value. + pub entries_adjusted: usize, + /// Entries pointing at bytes that no longer exist in the output + /// (e.g. data inside a removed box); left unchanged. + pub entries_unmapped: usize, +} + +/// Remap all stco/co64 entries in the tree. Nodes whose entries change are +/// converted from extents to in-memory bytes; payload sizes are unchanged, +/// so the layout (and the extent map itself) stays valid. +pub fn fix_chunk_offsets( + src: &mut R, + tree: &mut EditTree, + map: &[ExtentMapping], +) -> anyhow::Result { + // Sort by old offset for binary search. Extents are disjoint. + let mut sorted: Vec = map.to_vec(); + sorted.sort_by_key(|m| m.old_offset); + + let mut stats = FixupStats::default(); + for node in &mut tree.roots { + fix_node(src, node, &sorted, &mut stats)?; + } + Ok(stats) +} + +fn fix_node( + src: &mut R, + node: &mut EditNode, + map: &[ExtentMapping], + stats: &mut FixupStats, +) -> anyhow::Result<()> { + let is_stco = &node.typ.0 == b"stco"; + let is_co64 = &node.typ.0 == b"co64"; + + if is_stco || is_co64 { + // Only extent payloads need attention: Bytes payloads were produced + // by this edit session and carry no stale offsets... unless a caller + // replaced them, in which case they're responsible for the values. + if let Payload::Extent(e) = &node.payload { + let bytes = read_extent(src, e)?; + if let Some(patched) = remap_entries(bytes, is_co64, map, stats) { + node.payload = Payload::Bytes(patched); + } + } + return Ok(()); + } + + if let Payload::Container { children, .. } = &mut node.payload { + for c in children { + fix_node(src, c, map, stats)?; + } + } + Ok(()) +} + +fn read_extent(src: &mut R, e: &Extent) -> anyhow::Result> { + let mut buf = vec![0u8; e.len as usize]; + src.seek(SeekFrom::Start(e.offset))?; + src.read_exact(&mut buf)?; + Ok(buf) +} + +/// Payload layout (after the box header): version(1) flags(3) entry_count(4) +/// then 4-byte (stco) or 8-byte (co64) entries. Returns the patched payload, +/// or `None` when nothing changed. +fn remap_entries( + mut payload: Vec, + is_co64: bool, + map: &[ExtentMapping], + stats: &mut FixupStats, +) -> Option> { + if payload.len() < 8 { + return None; + } + let entry_count = u32::from_be_bytes(payload[4..8].try_into().unwrap()) as usize; + let width = if is_co64 { 8 } else { 4 }; + + let mut changed = false; + for i in 0..entry_count { + let at = 8 + i * width; + if at + width > payload.len() { + break; + } + let old = if is_co64 { + u64::from_be_bytes(payload[at..at + 8].try_into().unwrap()) + } else { + u32::from_be_bytes(payload[at..at + 4].try_into().unwrap()) as u64 + }; + + let Some(new) = remap_offset(old, map) else { + stats.entries_unmapped += 1; + continue; + }; + if new == old { + continue; + } + + if is_co64 { + payload[at..at + 8].copy_from_slice(&new.to_be_bytes()); + } else { + let Ok(new32) = u32::try_from(new) else { + stats.entries_unmapped += 1; + continue; // would need stco→co64 conversion; out of scope + }; + payload[at..at + 4].copy_from_slice(&new32.to_be_bytes()); + } + stats.entries_adjusted += 1; + changed = true; + } + + changed.then_some(payload) +} + +/// Map a source-file offset to its output offset via the extent that +/// contains it. +fn remap_offset(old: u64, sorted: &[ExtentMapping]) -> Option { + // Last extent whose old_offset <= old + let idx = sorted.partition_point(|m| m.old_offset <= old); + let m = sorted.get(idx.checked_sub(1)?)?; + if old < m.old_offset + m.len { + Some(m.new_offset + (old - m.old_offset)) + } else { + None + } +} diff --git a/src/edit/mod.rs b/src/edit/mod.rs new file mode 100644 index 0000000..4bd812d --- /dev/null +++ b/src/edit/mod.rs @@ -0,0 +1,403 @@ +//! Non-destructive MP4/ISOBMFF box editing. +//! +//! Editing works on a tree where unmodified boxes are *references* into the +//! source file, so serialization streams untouched bytes through verbatim +//! (an `mdat` is never loaded into memory) and re-serializing an unedited +//! tree reproduces the source byte for byte. Box sizes are recomputed +//! bottom-up during layout, so every ancestor of an edited box is correct by +//! construction, and `stco`/`co64` chunk offsets are remapped through the +//! exact old-offset → new-offset extent map — data that didn't move keeps +//! its offsets, data that moved shifts by exactly the right amount. +//! +//! ```no_run +//! use mp4box::edit::{Command, Editor}; +//! use std::fs::File; +//! +//! let mut editor = Editor::new(); +//! editor.add_command(Command::Remove { path: "moov/udta".into() }); +//! editor.set_tag("title", "My Movie")?; +//! +//! let mut src = File::open("in.mp4")?; +//! let mut dst = File::create("out.mp4")?; +//! let stats = editor.process(&mut src, &mut dst)?; +//! println!("wrote {} bytes, {} chunk offsets adjusted", +//! stats.bytes_written, stats.chunk_offsets_adjusted); +//! # Ok::<(), anyhow::Error>(()) +//! ``` +//! +//! Fragmented (`moof`/`sidx`) and HEIF (`iloc`) files are refused: their +//! internal offsets are not covered by the fixup pass yet, and editing them +//! would corrupt the output. + +mod fields; +mod fixup; +mod tags; +mod tree; + +pub use fixup::FixupStats; +pub use tree::{EditNode, EditTree, HeaderForm, Payload}; + +use crate::boxes::FourCC; +use crate::parser::parse_boxes; +use std::io::{Read, Seek, SeekFrom, Write}; + +/// A single edit operation. Paths are slash-delimited fourcc segments with +/// optional indices for repeated boxes: `"moov/trak[1]/mdia/mdhd"`. +/// An omitted index means the first match. +pub enum Command { + /// Delete the box at `path`. + Remove { path: String }, + /// Delete every box with this fourcc, anywhere in the tree. + RemoveAll { fourcc: String }, + /// Insert a complete raw box (header + payload) as a child of `parent`. + /// `position: None` appends; `Some(n)` inserts before the nth child. + Insert { + parent: String, + bytes: Vec, + position: Option, + }, + /// Replace the box at `path` with a complete raw box. + Replace { path: String, bytes: Vec }, + /// Set a named field of a known box in place (mvhd/tkhd/mdhd), e.g. + /// `path: "moov/mvhd", field: "creation_time", value: "0"`. All other + /// bytes of the box are preserved. + Set { + path: String, + field: String, + value: String, + }, + /// Set an iTunes metadata tag, creating `moov/udta/meta/ilst` as needed. + /// `tag` is a friendly name (`"title"`, `"artist"`, ...) or a raw fourcc. + SetTag { tag: String, value: String }, +} + +/// Statistics from a completed edit. +#[derive(Debug, Default)] +pub struct EditStats { + pub bytes_written: u64, + pub chunk_offsets_adjusted: usize, + /// Chunk offsets pointing at data that no longer exists (left unchanged). + pub chunk_offsets_unmapped: usize, +} + +/// Applies a batch of [`Command`]s to an MP4 source and writes the result. +#[derive(Default)] +pub struct Editor { + commands: Vec, +} + +impl Editor { + pub fn new() -> Self { + Self::default() + } + + pub fn add_command(&mut self, cmd: Command) -> &mut Self { + self.commands.push(cmd); + self + } + + /// Convenience for [`Command::Remove`]. + pub fn remove(&mut self, path: impl Into) -> &mut Self { + self.add_command(Command::Remove { path: path.into() }) + } + + /// Convenience for [`Command::RemoveAll`]. + pub fn remove_all(&mut self, fourcc: impl Into) -> &mut Self { + self.add_command(Command::RemoveAll { + fourcc: fourcc.into(), + }) + } + + /// Convenience for [`Command::Set`]. + pub fn set_field( + &mut self, + path: impl Into, + field: impl Into, + value: impl Into, + ) -> &mut Self { + self.add_command(Command::Set { + path: path.into(), + field: field.into(), + value: value.into(), + }) + } + + /// Convenience for [`Command::SetTag`]. Validates the tag name eagerly. + pub fn set_tag( + &mut self, + tag: impl Into, + value: impl Into, + ) -> anyhow::Result<&mut Self> { + let tag = tag.into(); + tags::tag_fourcc(&tag)?; // fail fast on unknown names + self.add_command(Command::SetTag { + tag, + value: value.into(), + }); + Ok(self) + } + + /// Parse `src`, apply all commands in order, and write the edited file + /// to `dst`. `src` is only read; the output is always a new file. + pub fn process( + &self, + src: &mut R, + dst: &mut W, + ) -> anyhow::Result { + let file_len = src.seek(SeekFrom::End(0))?; + let boxes = parse_boxes(src, 0, file_len)?; + let mut edit_tree = tree::build_tree(src, &boxes, file_len)?; + + if !self.commands.is_empty() { + guard_unsupported(&edit_tree)?; + } + + for cmd in &self.commands { + apply_command(src, &mut edit_tree, cmd)?; + } + + // Layout: compute where every unmodified extent lands in the output. + let map = tree::layout(&edit_tree); + + // Remap chunk offsets when any data moved. + let moved = map.iter().any(|m| m.new_offset != m.old_offset); + let fixup_stats = if moved { + fixup::fix_chunk_offsets(src, &mut edit_tree, &map)? + } else { + FixupStats::default() + }; + + let bytes_written = tree::write_tree(src, &edit_tree, dst)?; + + Ok(EditStats { + bytes_written, + chunk_offsets_adjusted: fixup_stats.entries_adjusted, + chunk_offsets_unmapped: fixup_stats.entries_unmapped, + }) + } + + /// Convenience wrapper over [`Editor::process`] for file paths. + /// Refuses `output == input`; the source is never modified. + pub fn process_file( + &self, + input: impl AsRef, + output: impl AsRef, + ) -> anyhow::Result { + let input = input.as_ref(); + let output = output.as_ref(); + anyhow::ensure!( + input != output, + "in-place editing is not supported; choose a different output path" + ); + let mut src = std::fs::File::open(input)?; + let mut dst = std::io::BufWriter::new(std::fs::File::create(output)?); + let stats = self.process(&mut src, &mut dst)?; + std::io::Write::flush(&mut dst)?; + Ok(stats) + } +} + +/// Refuse file kinds whose internal offsets the fixup pass does not cover. +fn guard_unsupported(tree: &EditTree) -> anyhow::Result<()> { + fn scan(nodes: &[EditNode]) -> Option<&'static str> { + for n in nodes { + match &n.typ.0 { + b"moof" => return Some("fragmented MP4 (moof)"), + b"sidx" => return Some("indexed segments (sidx)"), + b"iloc" => return Some("HEIF item locations (iloc)"), + _ => {} + } + if let Some(kids) = n.children() + && let Some(hit) = scan(kids) + { + return Some(hit); + } + } + None + } + if let Some(kind) = scan(&tree.roots) { + anyhow::bail!( + "editing is not supported for {} yet: byte offsets inside these \ + structures are not fixed up, and editing would corrupt the file", + kind + ); + } + Ok(()) +} + +// ---------- command application ---------- + +fn apply_command( + src: &mut R, + tree: &mut EditTree, + cmd: &Command, +) -> anyhow::Result<()> { + match cmd { + Command::Remove { path } => { + let (siblings, idx) = resolve_parent_mut(&mut tree.roots, path)?; + siblings.remove(idx); + } + + Command::RemoveAll { fourcc } => { + let cc = seg_fourcc(fourcc)?; + remove_all(&mut tree.roots, &cc); + } + + Command::Insert { + parent, + bytes, + position, + } => { + let node = EditNode::from_raw(bytes)?; + let target = resolve_node_mut(&mut tree.roots, parent)?; + let children = target + .children_mut() + .ok_or_else(|| anyhow::anyhow!("'{}' is not a container", parent))?; + let at = position.unwrap_or(children.len()).min(children.len()); + children.insert(at, node); + } + + Command::Replace { path, bytes } => { + let node = EditNode::from_raw(bytes)?; + let (siblings, idx) = resolve_parent_mut(&mut tree.roots, path)?; + siblings[idx] = node; + } + + Command::Set { path, field, value } => { + let node = resolve_node_mut(&mut tree.roots, path)?; + set_field_on_node(src, node, field, value)?; + } + + Command::SetTag { tag, value } => { + let cc = tags::tag_fourcc(tag)?; + let moov = tree + .roots + .iter_mut() + .find(|n| &n.typ.0 == b"moov") + .ok_or_else(|| anyhow::anyhow!("no moov box: cannot set tags"))?; + tags::set_tag_in_moov(moov, &cc, value)?; + } + } + Ok(()) +} + +fn set_field_on_node( + src: &mut R, + node: &mut EditNode, + field: &str, + value: &str, +) -> anyhow::Result<()> { + // Materialize the payload (version/flags + body) so it can be patched. + let mut payload = match &node.payload { + Payload::Bytes(b) => b.clone(), + Payload::Extent(e) => { + let mut buf = vec![0u8; e.len as usize]; + src.seek(SeekFrom::Start(e.offset))?; + src.read_exact(&mut buf)?; + buf + } + Payload::Container { .. } => { + anyhow::bail!("'{}' is a container; --set applies to leaf boxes", node.typ) + } + }; + anyhow::ensure!(!payload.is_empty(), "'{}' has an empty payload", node.typ); + + let version = payload[0]; + let (offset, kind) = fields::field_spec(&node.typ.0, version, field).ok_or_else(|| { + anyhow::anyhow!( + "no known field '{}' in '{}' (version {})", + field, + node.typ, + version + ) + })?; + fields::patch_field(&mut payload, offset, kind, value)?; + node.payload = Payload::Bytes(payload); + Ok(()) +} + +fn remove_all(nodes: &mut Vec, cc: &FourCC) { + nodes.retain(|n| &n.typ != cc); + for n in nodes { + if let Some(kids) = n.children_mut() { + remove_all(kids, cc); + } + } +} + +// ---------- path resolution ---------- + +/// Parse one path segment: `"trak[1]"` → (`trak`, 1); `"trak"` → (`trak`, 0). +fn parse_segment(seg: &str) -> anyhow::Result<(FourCC, usize)> { + if let Some(open) = seg.find('[') { + let close = seg + .rfind(']') + .ok_or_else(|| anyhow::anyhow!("unclosed '[' in path segment '{}'", seg))?; + let idx: usize = seg[open + 1..close] + .parse() + .map_err(|_| anyhow::anyhow!("bad index in path segment '{}'", seg))?; + Ok((seg_fourcc(&seg[..open])?, idx)) + } else { + Ok((seg_fourcc(seg)?, 0)) + } +} + +/// Convert a path segment to a fourcc; '©' (2 bytes in UTF-8) maps to the +/// single 0xA9 byte used by iTunes atoms. +fn seg_fourcc(seg: &str) -> anyhow::Result { + let mut bytes = Vec::with_capacity(4); + for ch in seg.chars() { + if ch == '©' { + bytes.push(0xA9); + } else { + anyhow::ensure!(ch.is_ascii(), "invalid character in fourcc '{}'", seg); + bytes.push(ch as u8); + } + } + anyhow::ensure!(bytes.len() == 4, "'{}' is not a 4-character box type", seg); + Ok(FourCC(bytes.try_into().unwrap())) +} + +fn find_child_idx(nodes: &[EditNode], cc: &FourCC, nth: usize) -> Option { + nodes + .iter() + .enumerate() + .filter(|(_, n)| &n.typ == cc) + .map(|(i, _)| i) + .nth(nth) +} + +/// Resolve `path` to a mutable node reference. +fn resolve_node_mut<'a>( + roots: &'a mut Vec, + path: &str, +) -> anyhow::Result<&'a mut EditNode> { + let (siblings, idx) = resolve_parent_mut(roots, path)?; + Ok(&mut siblings[idx]) +} + +/// Resolve `path` to its parent's child list and the index within it, so the +/// caller can remove or replace the node. +fn resolve_parent_mut<'a>( + roots: &'a mut Vec, + path: &str, +) -> anyhow::Result<(&'a mut Vec, usize)> { + anyhow::ensure!(!path.is_empty(), "empty box path"); + let segments: Vec<&str> = path.split('/').collect(); + + let mut current: &'a mut Vec = roots; + for (depth, seg) in segments.iter().enumerate() { + let (cc, nth) = parse_segment(seg)?; + let idx = find_child_idx(current, &cc, nth) + .ok_or_else(|| anyhow::anyhow!("box '{}' not found (in path '{}')", seg, path))?; + + if depth == segments.len() - 1 { + return Ok((current, idx)); + } + + current = current[idx] + .children_mut() + .ok_or_else(|| anyhow::anyhow!("'{}' has no children (in path '{}')", seg, path))?; + } + unreachable!("loop always returns on the last segment"); +} diff --git a/src/edit/tags.rs b/src/edit/tags.rs new file mode 100644 index 0000000..fefab27 --- /dev/null +++ b/src/edit/tags.rs @@ -0,0 +1,148 @@ +//! Builders for iTunes metadata boxes and the `SetTag` command's +//! find-or-create logic for the `moov/udta/meta/ilst` chain. + +use super::tree::{EditNode, HeaderForm, Payload}; +use crate::boxes::FourCC; + +/// Map a friendly tag name (as returned by `get_itunes_tags`) to its +/// iTunes atom fourcc. Raw 4-byte names (including `©xxx`) pass through. +pub fn tag_fourcc(name: &str) -> anyhow::Result<[u8; 4]> { + let cc: &[u8] = match name { + "title" => b"\xa9nam", + "artist" => b"\xa9ART", + "album" => b"\xa9alb", + "year" => b"\xa9day", + "genre" => b"\xa9gen", + "comment" => b"\xa9cmt", + "description" => b"desc", + "copyright" => b"cprt", + "album_artist" => b"aART", + "encoder" => b"\xa9too", + "composer" => b"\xa9wrt", + "lyrics" => b"\xa9lyr", + "grouping" => b"\xa9grp", + other => { + // Accept a raw fourcc; '©' is 2 bytes in UTF-8, so re-encode it + // as the single 0xA9 byte iTunes uses. + let mut bytes = Vec::with_capacity(4); + for ch in other.chars() { + if ch == '©' { + bytes.push(0xA9); + } else { + anyhow::ensure!( + ch.is_ascii(), + "tag {:?} is not a known name or fourcc", + other + ); + bytes.push(ch as u8); + } + } + anyhow::ensure!( + bytes.len() == 4, + "tag {:?} is not a known name or 4-character code", + other + ); + return Ok(bytes.try_into().unwrap()); + } + }; + Ok(cc.try_into().unwrap()) +} + +fn plain_box(typ: &[u8; 4], payload: &[u8]) -> Vec { + let mut v = Vec::with_capacity(8 + payload.len()); + v.extend_from_slice(&((8 + payload.len()) as u32).to_be_bytes()); + v.extend_from_slice(typ); + v.extend_from_slice(payload); + v +} + +/// Raw tag atom: `` containing a `data` atom with a UTF-8 value. +pub fn build_tag_atom(fourcc: &[u8; 4], value: &str) -> Vec { + let mut data_payload = Vec::with_capacity(8 + value.len()); + data_payload.extend_from_slice(&1u32.to_be_bytes()); // type indicator: UTF-8 + data_payload.extend_from_slice(&0u32.to_be_bytes()); // locale + data_payload.extend_from_slice(value.as_bytes()); + plain_box(fourcc, &plain_box(b"data", &data_payload)) +} + +/// A `hdlr` box marking an ISO `meta` as iTunes metadata (matches what +/// ffmpeg writes: empty name, single NUL). +fn build_mdir_hdlr() -> Vec { + let mut p = Vec::with_capacity(25); + p.extend_from_slice(&[0, 0, 0, 0]); // version/flags + p.extend_from_slice(&0u32.to_be_bytes()); // pre_defined + p.extend_from_slice(b"mdir"); + p.extend_from_slice(&[0u8; 12]); // reserved + p.push(0); // empty name + plain_box(b"hdlr", &p) +} + +fn empty_container(typ: &[u8; 4], version_flags: Option<(u8, u32)>) -> EditNode { + EditNode { + typ: FourCC(*typ), + uuid: None, + header_form: HeaderForm::Compact, + payload: Payload::Container { + version_flags, + prefix: Vec::new(), + children: Vec::new(), + suffix: Vec::new(), + }, + } +} + +/// Find or create `udta/meta/ilst` under the given `moov` node and set the +/// tag atom (replace if present, append otherwise). +pub fn set_tag_in_moov(moov: &mut EditNode, fourcc: &[u8; 4], value: &str) -> anyhow::Result<()> { + let moov_children = moov + .children_mut() + .ok_or_else(|| anyhow::anyhow!("moov is not a container"))?; + + // udta + if !moov_children.iter().any(|c| &c.typ.0 == b"udta") { + moov_children.push(empty_container(b"udta", None)); + } + let udta = moov_children + .iter_mut() + .find(|c| &c.typ.0 == b"udta") + .unwrap(); + let udta_children = udta + .children_mut() + .ok_or_else(|| anyhow::anyhow!("udta is not a container"))?; + + // meta (ISO FullBox flavor, with the iTunes hdlr) + if !udta_children.iter().any(|c| &c.typ.0 == b"meta") { + let mut meta = empty_container(b"meta", Some((0, 0))); + if let Some(kids) = meta.children_mut() { + kids.push(EditNode::from_raw(&build_mdir_hdlr())?); + } + udta_children.push(meta); + } + let meta = udta_children + .iter_mut() + .find(|c| &c.typ.0 == b"meta") + .unwrap(); + let meta_children = meta + .children_mut() + .ok_or_else(|| anyhow::anyhow!("meta is not a container"))?; + + // ilst + if !meta_children.iter().any(|c| &c.typ.0 == b"ilst") { + meta_children.push(empty_container(b"ilst", None)); + } + let ilst = meta_children + .iter_mut() + .find(|c| &c.typ.0 == b"ilst") + .unwrap(); + let ilst_children = ilst + .children_mut() + .ok_or_else(|| anyhow::anyhow!("ilst is not a container"))?; + + // the tag atom itself + let atom = EditNode::from_raw(&build_tag_atom(fourcc, value))?; + match ilst_children.iter_mut().find(|c| &c.typ.0 == fourcc) { + Some(existing) => *existing = atom, + None => ilst_children.push(atom), + } + Ok(()) +} diff --git a/src/edit/tree.rs b/src/edit/tree.rs new file mode 100644 index 0000000..a63b646 --- /dev/null +++ b/src/edit/tree.rs @@ -0,0 +1,448 @@ +//! The edit tree: a mutable representation of an MP4 file where unmodified +//! payloads are *references into the source file* (extents) rather than +//! copies. Serializing an unmodified tree reproduces the source byte for +//! byte; mutations replace nodes with in-memory bytes and every enclosing +//! box size is recomputed during layout, so ancestor sizes are correct by +//! construction. + +use crate::boxes::{BoxRef, FourCC, NodeKind}; +use std::io::{Read, Seek, SeekFrom, Write}; + +/// How the original box header was written, preserved on round-trip. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HeaderForm { + /// 32-bit size + Compact, + /// size32 == 1 with a 64-bit largesize + Large, + /// size32 == 0: box extends to the end of the file + ToEof, +} + +/// A byte range in the source file. +#[derive(Debug, Clone, Copy)] +pub struct Extent { + pub offset: u64, + pub len: u64, +} + +/// Payload of an edit node. +#[derive(Debug)] +pub enum Payload { + /// Unmodified bytes living in the source file (everything after the box + /// header, including version/flags for FullBox leaves). + Extent(Extent), + /// Replaced payload bytes (everything after the box header). + Bytes(Vec), + /// A container of child boxes. + Container { + /// version/flags for FullBox containers (`meta`, `stsd`, ...) + version_flags: Option<(u8, u32)>, + /// Non-box bytes between the header (or version/flags) and the first + /// child: stsd's entry_count, a sample entry's fixed fields, ... + prefix: Vec, + children: Vec, + /// Trailing non-box bytes inside the box after the last child + /// (padding smaller than a box header), preserved verbatim. + suffix: Vec, + }, +} + +/// One box in the edit tree. +#[derive(Debug)] +pub struct EditNode { + pub typ: FourCC, + pub uuid: Option<[u8; 16]>, + pub header_form: HeaderForm, + pub payload: Payload, +} + +impl EditNode { + /// Create a node from a complete raw box (header + payload), e.g. for + /// inserted boxes. The interior is kept opaque. + pub fn from_raw(bytes: &[u8]) -> anyhow::Result { + anyhow::ensure!(bytes.len() >= 8, "raw box shorter than a box header"); + let size32 = u32::from_be_bytes(bytes[0..4].try_into().unwrap()); + let typ = FourCC([bytes[4], bytes[5], bytes[6], bytes[7]]); + + let (header_form, declared, mut header_len) = if size32 == 1 { + anyhow::ensure!(bytes.len() >= 16, "largesize box shorter than 16 bytes"); + let large = u64::from_be_bytes(bytes[8..16].try_into().unwrap()); + (HeaderForm::Large, large, 16usize) + } else if size32 == 0 { + (HeaderForm::ToEof, bytes.len() as u64, 8usize) + } else { + (HeaderForm::Compact, size32 as u64, 8usize) + }; + anyhow::ensure!( + declared == bytes.len() as u64, + "raw box declares size {} but {} bytes were provided", + declared, + bytes.len() + ); + + let mut uuid = None; + if &typ.0 == b"uuid" { + anyhow::ensure!(bytes.len() >= header_len + 16, "uuid box too short"); + let mut u = [0u8; 16]; + u.copy_from_slice(&bytes[header_len..header_len + 16]); + uuid = Some(u); + header_len += 16; + } + + Ok(EditNode { + typ, + uuid, + header_form: if header_form == HeaderForm::ToEof { + HeaderForm::Compact // re-emit with a real size + } else { + header_form + }, + payload: Payload::Bytes(bytes[header_len..].to_vec()), + }) + } + + /// The children of this node, if it is a container. + pub fn children(&self) -> Option<&Vec> { + match &self.payload { + Payload::Container { children, .. } => Some(children), + _ => None, + } + } + + pub fn children_mut(&mut self) -> Option<&mut Vec> { + match &mut self.payload { + Payload::Container { children, .. } => Some(children), + _ => None, + } + } +} + +/// The whole file: top-level boxes plus any trailing bytes after the last +/// box (shorter than a box header). +pub struct EditTree { + pub roots: Vec, + pub trailing: Vec, +} + +// ---------- building ---------- + +/// Build an edit tree from a parsed box tree, reading small non-box regions +/// (version/flags, prefixes, padding) from the source. +pub fn build_tree( + r: &mut R, + boxes: &[BoxRef], + file_len: u64, +) -> anyhow::Result { + let roots = boxes + .iter() + .map(|b| build_node(r, b, file_len)) + .collect::>>()?; + + // Preserve trailing bytes after the last top-level box. + let last_end = boxes + .iter() + .last() + .map(|b| box_end(b, file_len)) + .unwrap_or(0); + let trailing = read_range(r, last_end, file_len.saturating_sub(last_end))?; + + Ok(EditTree { roots, trailing }) +} + +fn box_end(b: &BoxRef, parent_end: u64) -> u64 { + if b.hdr.size == 0 { + parent_end + } else { + (b.hdr.start + b.hdr.size).min(parent_end) + } +} + +fn read_range(r: &mut R, offset: u64, len: u64) -> anyhow::Result> { + let mut buf = vec![0u8; len as usize]; + r.seek(SeekFrom::Start(offset))?; + r.read_exact(&mut buf)?; + Ok(buf) +} + +fn header_form_of(b: &BoxRef) -> HeaderForm { + if b.hdr.size == 0 { + HeaderForm::ToEof + } else { + // header_size: 8/24 compact, 16/32 large (uuid adds 16) + let base = b.hdr.header_size - if b.hdr.uuid.is_some() { 16 } else { 0 }; + if base == 16 { + HeaderForm::Large + } else { + HeaderForm::Compact + } + } +} + +fn build_node(r: &mut R, b: &BoxRef, parent_end: u64) -> anyhow::Result { + let end = box_end(b, parent_end); + let content_start = b.hdr.start + b.hdr.header_size; + + let payload = match &b.kind { + NodeKind::Leaf { + data_offset: _, + data_len: _, + } + | NodeKind::Unknown { .. } + | NodeKind::FullBox { .. } => { + // Everything after the header, including version/flags. + Payload::Extent(Extent { + offset: content_start, + len: end.saturating_sub(content_start), + }) + } + NodeKind::Container(kids) => { + container_payload(r, None, content_start, end, kids, parent_end_for(kids, end))? + } + NodeKind::FullContainer { + version, + flags, + data_offset, + children, + .. + } => container_payload( + r, + Some((*version, *flags)), + *data_offset, + end, + children, + parent_end_for(children, end), + )?, + }; + + Ok(EditNode { + typ: b.hdr.typ, + uuid: b.hdr.uuid, + header_form: header_form_of(b), + payload, + }) +} + +fn parent_end_for(_kids: &[BoxRef], end: u64) -> u64 { + end +} + +fn container_payload( + r: &mut R, + version_flags: Option<(u8, u32)>, + content_start: u64, + end: u64, + kids: &[BoxRef], + child_parent_end: u64, +) -> anyhow::Result { + // prefix: bytes between content start and the first child (stsd + // entry_count, sample entry fixed fields, ...) + let first_child_start = kids.first().map(|k| k.hdr.start).unwrap_or(end); + let prefix = read_range(r, content_start, first_child_start - content_start)?; + + let mut children = Vec::with_capacity(kids.len()); + for k in kids { + children.push(build_node(r, k, child_parent_end)?); + } + + // suffix: padding after the last child, inside this box + let last_child_end = kids.last().map(|k| box_end(k, end)).unwrap_or(end); + let suffix = read_range(r, last_child_end, end.saturating_sub(last_child_end))?; + + Ok(Payload::Container { + version_flags, + prefix, + children, + suffix, + }) +} + +// ---------- layout ---------- + +/// Where a source extent lands in the output. +#[derive(Debug, Clone, Copy)] +pub struct ExtentMapping { + pub old_offset: u64, + pub len: u64, + pub new_offset: u64, +} + +/// Size of a node's header for its (possibly upgraded) form. +fn header_len(node: &EditNode, payload_len: u64) -> u64 { + let uuid_len = if node.uuid.is_some() { 16 } else { 0 }; + match node.header_form { + HeaderForm::Large => 16 + uuid_len, + HeaderForm::ToEof | HeaderForm::Compact => { + // Upgrade to largesize if the compact form can't express it. + if node.header_form == HeaderForm::Compact + && 8 + uuid_len + payload_len > u32::MAX as u64 + { + 16 + uuid_len + } else { + 8 + uuid_len + } + } + } +} + +pub fn payload_len(node: &EditNode) -> u64 { + match &node.payload { + Payload::Extent(e) => e.len, + Payload::Bytes(b) => b.len() as u64, + Payload::Container { + version_flags, + prefix, + children, + suffix, + } => { + let vf = if version_flags.is_some() { 4 } else { 0 }; + vf + prefix.len() as u64 + + children.iter().map(node_size).sum::() + + suffix.len() as u64 + } + } +} + +/// Total serialized size of a node (header + payload). +pub fn node_size(node: &EditNode) -> u64 { + let p = payload_len(node); + header_len(node, p) + p +} + +/// Compute the extent map for the current tree layout: every source extent +/// paired with the offset it will occupy in the output. +pub fn layout(tree: &EditTree) -> Vec { + let mut map = Vec::new(); + let mut pos = 0u64; + for node in &tree.roots { + layout_node(node, &mut pos, &mut map); + } + map +} + +fn layout_node(node: &EditNode, pos: &mut u64, map: &mut Vec) { + let p = payload_len(node); + *pos += header_len(node, p); + match &node.payload { + Payload::Extent(e) => { + map.push(ExtentMapping { + old_offset: e.offset, + len: e.len, + new_offset: *pos, + }); + *pos += e.len; + } + Payload::Bytes(b) => { + *pos += b.len() as u64; + } + Payload::Container { + version_flags, + prefix, + children, + suffix, + } => { + if version_flags.is_some() { + *pos += 4; + } + *pos += prefix.len() as u64; + for c in children { + layout_node(c, pos, map); + } + *pos += suffix.len() as u64; + } + } +} + +// ---------- writing ---------- + +/// Serialize the tree to `dst`, streaming unmodified extents from `src`. +pub fn write_tree( + src: &mut R, + tree: &EditTree, + dst: &mut W, +) -> anyhow::Result { + let mut written = 0u64; + for node in &tree.roots { + written += write_node(src, node, dst)?; + } + dst.write_all(&tree.trailing)?; + written += tree.trailing.len() as u64; + Ok(written) +} + +fn write_header(node: &EditNode, p: u64, dst: &mut W) -> anyhow::Result { + let hlen = header_len(node, p); + let total = hlen + p; + let large = matches!(hlen - if node.uuid.is_some() { 16 } else { 0 }, 16); + + if node.header_form == HeaderForm::ToEof { + dst.write_all(&0u32.to_be_bytes())?; + dst.write_all(&node.typ.0)?; + } else if large { + dst.write_all(&1u32.to_be_bytes())?; + dst.write_all(&node.typ.0)?; + dst.write_all(&total.to_be_bytes())?; + } else { + dst.write_all(&(total as u32).to_be_bytes())?; + dst.write_all(&node.typ.0)?; + } + if let Some(u) = &node.uuid { + dst.write_all(u)?; + } + Ok(hlen) +} + +fn write_node( + src: &mut R, + node: &EditNode, + dst: &mut W, +) -> anyhow::Result { + let p = payload_len(node); + let hlen = write_header(node, p, dst)?; + + match &node.payload { + Payload::Extent(e) => copy_extent(src, e, dst)?, + Payload::Bytes(b) => dst.write_all(b)?, + Payload::Container { + version_flags, + prefix, + children, + suffix, + } => { + if let Some((version, flags)) = version_flags { + dst.write_all(&[ + *version, + ((flags >> 16) & 0xFF) as u8, + ((flags >> 8) & 0xFF) as u8, + (flags & 0xFF) as u8, + ])?; + } + dst.write_all(prefix)?; + for c in children { + write_node(src, c, dst)?; + } + dst.write_all(suffix)?; + } + } + + Ok(hlen + p) +} + +/// Copy `extent` from the source to the output in chunks (an mdat is never +/// held in memory). +fn copy_extent( + src: &mut R, + extent: &Extent, + dst: &mut W, +) -> anyhow::Result<()> { + src.seek(SeekFrom::Start(extent.offset))?; + let mut remaining = extent.len; + let mut buf = vec![0u8; 1 << 20]; // 1 MiB chunks + while remaining > 0 { + let n = remaining.min(buf.len() as u64) as usize; + src.read_exact(&mut buf[..n])?; + dst.write_all(&buf[..n])?; + remaining -= n as u64; + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 495d316..7148b64 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,6 +79,8 @@ pub mod api; pub mod boxes; +#[cfg(feature = "edit")] +pub mod edit; pub mod known_boxes; pub mod parser; pub mod registry; diff --git a/tests/edit_roundtrip.rs b/tests/edit_roundtrip.rs new file mode 100644 index 0000000..f17b66d --- /dev/null +++ b/tests/edit_roundtrip.rs @@ -0,0 +1,511 @@ +//! Tests for the edit module. +//! +//! The load-bearing invariant: serializing an *unedited* tree reproduces the +//! source byte for byte. On top of that, structural edits must keep every +//! ancestor size correct and remap chunk offsets so samples still point at +//! the same media bytes. + +#![cfg(feature = "edit")] + +use mp4box::edit::{Command, Editor}; +use mp4box::registry::StructuredData; +use mp4box::{get_boxes, get_itunes_tags, track_samples_from_reader}; +use std::io::Cursor; + +// ---------- fixture: a tiny but complete progressive MP4 ---------- + +fn plain_box(typ: &[u8; 4], payload: &[u8]) -> Vec { + let mut v = Vec::with_capacity(8 + payload.len()); + v.extend_from_slice(&((8 + payload.len()) as u32).to_be_bytes()); + v.extend_from_slice(typ); + v.extend_from_slice(payload); + v +} + +fn full_box(typ: &[u8; 4], version: u8, flags: u32, payload: &[u8]) -> Vec { + let mut inner = Vec::with_capacity(4 + payload.len()); + inner.push(version); + inner.extend_from_slice(&flags.to_be_bytes()[1..4]); + inner.extend_from_slice(payload); + plain_box(typ, &inner) +} + +fn concat(parts: &[Vec]) -> Vec { + parts.iter().flatten().copied().collect() +} + +/// Sample media payloads, distinct so tests can verify content by offset. +const SAMPLES: [&[u8]; 3] = [b"AAAAAAAAAA", b"BBBBBBBBBBBBBBB", b"CCCCC"]; + +/// Build `ftyp | free | mdat | moov` with a real sample table: 3 samples in +/// one chunk whose stco entry points into the mdat payload. +/// +/// Returns (file_bytes, mdat_payload_offset). +fn build_progressive_file() -> (Vec, u64) { + let ftyp = plain_box(b"ftyp", &concat(&[b"isom".to_vec(), vec![0u8; 4]])); + let free = plain_box(b"free", &[0u8; 16]); + + let media: Vec = SAMPLES.concat(); + let mdat = plain_box(b"mdat", &media); + let mdat_payload_offset = (ftyp.len() + free.len() + 8) as u64; + + // stbl tables describing the 3 samples + let stsd = { + let mut p = Vec::new(); + p.extend_from_slice(&1u32.to_be_bytes()); // entry_count + // Minimal mp4a audio sample entry (no codec children needed) + let mut e = Vec::new(); + e.extend_from_slice(&[0u8; 6]); + e.extend_from_slice(&1u16.to_be_bytes()); // data_reference_index + e.extend_from_slice(&[0u8; 8]); + e.extend_from_slice(&1u16.to_be_bytes()); // channels + e.extend_from_slice(&16u16.to_be_bytes()); // sample size + e.extend_from_slice(&[0u8; 4]); + e.extend_from_slice(&(48000u32 << 16).to_be_bytes()); + p.extend_from_slice(&plain_box(b"mp4a", &e)); + full_box(b"stsd", 0, 0, &p) + }; + let stts = { + let mut p = Vec::new(); + p.extend_from_slice(&1u32.to_be_bytes()); + p.extend_from_slice(&3u32.to_be_bytes()); // 3 samples + p.extend_from_slice(&100u32.to_be_bytes()); // delta 100 + full_box(b"stts", 0, 0, &p) + }; + let stsc = { + let mut p = Vec::new(); + p.extend_from_slice(&1u32.to_be_bytes()); + p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk + p.extend_from_slice(&3u32.to_be_bytes()); // samples_per_chunk + p.extend_from_slice(&1u32.to_be_bytes()); // sdi + full_box(b"stsc", 0, 0, &p) + }; + let stsz = { + let mut p = Vec::new(); + p.extend_from_slice(&0u32.to_be_bytes()); // per-sample sizes + p.extend_from_slice(&3u32.to_be_bytes()); + for s in SAMPLES { + p.extend_from_slice(&(s.len() as u32).to_be_bytes()); + } + full_box(b"stsz", 0, 0, &p) + }; + let stco = { + let mut p = Vec::new(); + p.extend_from_slice(&1u32.to_be_bytes()); + p.extend_from_slice(&(mdat_payload_offset as u32).to_be_bytes()); + full_box(b"stco", 0, 0, &p) + }; + let stbl = plain_box(b"stbl", &concat(&[stsd, stts, stsc, stsz, stco])); + let minf = plain_box(b"minf", &stbl); + + let mdhd = { + let mut p = Vec::new(); + p.extend_from_slice(&[0u8; 8]); + p.extend_from_slice(&1000u32.to_be_bytes()); // timescale + p.extend_from_slice(&300u32.to_be_bytes()); // duration + p.extend_from_slice(&0x55C4u16.to_be_bytes()); // "und" + p.extend_from_slice(&[0u8; 2]); + full_box(b"mdhd", 0, 0, &p) + }; + let hdlr = { + let mut p = Vec::new(); + p.extend_from_slice(&[0u8; 4]); + p.extend_from_slice(b"soun"); + p.extend_from_slice(&[0u8; 12]); + p.push(0); + full_box(b"hdlr", 0, 0, &p) + }; + let mdia = plain_box(b"mdia", &concat(&[mdhd, hdlr, minf])); + + let tkhd = { + let mut p = Vec::new(); + p.extend_from_slice(&[0u8; 8]); + p.extend_from_slice(&1u32.to_be_bytes()); // track_id + p.extend_from_slice(&[0u8; 4]); + p.extend_from_slice(&300u32.to_be_bytes()); // duration + p.extend_from_slice(&[0u8; 8 + 8 + 36 + 8]); + full_box(b"tkhd", 0, 3, &p) + }; + let trak = plain_box(b"trak", &concat(&[tkhd, mdia])); + + let mvhd = { + let mut p = Vec::new(); + p.extend_from_slice(&[0u8; 8]); + p.extend_from_slice(&1000u32.to_be_bytes()); // timescale + p.extend_from_slice(&300u32.to_be_bytes()); // duration + p.extend_from_slice(&0x0001_0000u32.to_be_bytes()); // rate + p.extend_from_slice(&0x0100u16.to_be_bytes()); // volume + p.extend_from_slice(&[0u8; 10]); + // a deliberately non-identity matrix, to catch encoders that reset it + let mut matrix = [0u8; 36]; + matrix[0] = 0xDE; + matrix[35] = 0xAD; + p.extend_from_slice(&matrix); + p.extend_from_slice(&[0u8; 24]); + p.extend_from_slice(&2u32.to_be_bytes()); // next_track_id + full_box(b"mvhd", 0, 0, &p) + }; + let udta = plain_box(b"udta", &plain_box(b"cust", b"custom-user-data")); + + let moov = plain_box(b"moov", &concat(&[mvhd, trak, udta])); + + (concat(&[ftyp, free, mdat, moov]), mdat_payload_offset) +} + +fn run_editor(editor: &Editor, input: &[u8]) -> Vec { + let mut src = Cursor::new(input.to_vec()); + let mut dst = Vec::new(); + editor.process(&mut src, &mut dst).expect("edit failed"); + dst +} + +/// Read back the stco entry and sample bytes of the (single-track) file. +fn first_chunk_offset(data: &[u8]) -> u64 { + let len = data.len() as u64; + let mut cur = Cursor::new(data); + let boxes = get_boxes(&mut cur, len, true).unwrap(); + fn find_stco(boxes: &[mp4box::Box]) -> Option { + for b in boxes { + if let Some(StructuredData::ChunkOffset(d)) = &b.structured_data { + return d.chunk_offsets.first().map(|&v| v as u64); + } + if let Some(kids) = &b.children + && let Some(v) = find_stco(kids) + { + return Some(v); + } + } + None + } + find_stco(&boxes).expect("no stco found") +} + +/// Assert all samples in `data` still resolve to the expected media bytes. +fn assert_samples_intact(data: &[u8]) { + let tracks = track_samples_from_reader(Cursor::new(data.to_vec())).unwrap(); + assert_eq!(tracks.len(), 1); + assert_eq!(tracks[0].samples.len(), SAMPLES.len()); + for (s, expected) in tracks[0].samples.iter().zip(SAMPLES) { + let start = s.file_offset as usize; + let end = start + s.size as usize; + assert_eq!( + &data[start..end], + expected, + "sample {} does not point at its media bytes", + s.index + ); + } +} + +// ---------- identity ---------- + +#[test] +fn identity_roundtrip_is_byte_exact() { + let (input, _) = build_progressive_file(); + let output = run_editor(&Editor::new(), &input); + assert_eq!(input, output, "unedited round-trip must be byte-identical"); +} + +#[test] +fn identity_roundtrip_with_trailing_padding() { + let (mut input, _) = build_progressive_file(); + input.extend_from_slice(&[0x00, 0x01, 0x02]); // < 8 bytes of junk at EOF + let output = run_editor(&Editor::new(), &input); + assert_eq!(input, output); +} + +#[test] +fn identity_roundtrip_real_files() { + // Byte-identical round-trip on any local media files present. + for path in [ + "video_counter_10min_unfragmented_avc.mp4", + "BigBuckBunny.mp4", + "tears-of-steel-360p_encoded_1773818279309.mp4", + ] { + if !std::path::Path::new(path).exists() { + eprintln!("skipping: {path} not present"); + continue; + } + let input = std::fs::read(path).unwrap(); + let mut src = Cursor::new(&input); + let mut dst = Vec::with_capacity(input.len()); + Editor::new().process(&mut src, &mut dst).unwrap(); + assert_eq!(input.len(), dst.len(), "{path}: length changed"); + assert_eq!(input, dst, "{path}: bytes changed"); + } +} + +// ---------- structural edits ---------- + +#[test] +fn remove_before_mdat_shifts_chunk_offsets() { + let (input, old_offset) = build_progressive_file(); + assert_eq!(first_chunk_offset(&input), old_offset); + + // Removing the 24-byte `free` box moves mdat 24 bytes earlier. + let mut editor = Editor::new(); + editor.remove("free"); + let output = run_editor(&editor, &input); + + assert_eq!(output.len(), input.len() - 24); + assert_eq!(first_chunk_offset(&output), old_offset - 24); + assert_samples_intact(&output); +} + +#[test] +fn remove_after_mdat_keeps_chunk_offsets() { + let (input, old_offset) = build_progressive_file(); + + // udta lives inside moov, after mdat: nothing moves for the media data. + let mut editor = Editor::new(); + editor.remove("moov/udta"); + let output = run_editor(&editor, &input); + + assert!(output.len() < input.len()); + assert_eq!( + first_chunk_offset(&output), + old_offset, + "chunk offsets must be untouched when mdat did not move" + ); + assert_samples_intact(&output); + + // moov (the ancestor) must have shrunk by exactly the udta size and the + // tree must reparse cleanly with no udta. + let len = output.len() as u64; + let mut cur = Cursor::new(&output); + let boxes = get_boxes(&mut cur, len, false).unwrap(); + let moov = boxes.iter().find(|b| b.typ == "moov").unwrap(); + assert!( + moov.children + .as_ref() + .unwrap() + .iter() + .all(|c| c.typ != "udta") + ); + // No gaps: boxes must tile the file exactly. + assert_eq!(boxes.iter().map(|b| b.size).sum::(), len); +} + +#[test] +fn grow_box_before_mdat_shifts_chunk_offsets() { + let (input, old_offset) = build_progressive_file(); + + // Replace the 24-byte `free` box with a 48-byte one: mdat moves +24. + let mut editor = Editor::new(); + editor.add_command(Command::Replace { + path: "free".into(), + bytes: plain_box(b"free", &[0u8; 40]), + }); + let output = run_editor(&editor, &input); + + assert_eq!(output.len(), input.len() + 24); + assert_eq!(first_chunk_offset(&output), old_offset + 24); + assert_samples_intact(&output); +} + +#[test] +fn insert_into_container_updates_ancestors() { + let (input, _) = build_progressive_file(); + + let extra = plain_box(b"cprt", b"\x00\x00\x00\x00(c) test"); + let mut editor = Editor::new(); + editor.add_command(Command::Insert { + parent: "moov/udta".into(), + bytes: extra.clone(), + position: None, + }); + let output = run_editor(&editor, &input); + + let len = output.len() as u64; + let mut cur = Cursor::new(&output); + let boxes = get_boxes(&mut cur, len, false).unwrap(); + let moov = boxes.iter().find(|b| b.typ == "moov").unwrap(); + let udta = moov + .children + .as_ref() + .unwrap() + .iter() + .find(|b| b.typ == "udta") + .unwrap(); + let kids = udta.children.as_ref().unwrap(); + assert_eq!(kids.len(), 2); + assert_eq!(kids[1].typ, "cprt"); + assert_eq!(boxes.iter().map(|b| b.size).sum::(), len); + assert_samples_intact(&output); +} + +#[test] +fn remove_all_strips_every_match() { + let (input, _) = build_progressive_file(); + let mut editor = Editor::new(); + editor.remove_all("free"); + editor.remove_all("cust"); + let output = run_editor(&editor, &input); + + let len = output.len() as u64; + let mut cur = Cursor::new(&output); + let boxes = get_boxes(&mut cur, len, false).unwrap(); + fn count(boxes: &[mp4box::Box], typ: &str) -> usize { + boxes + .iter() + .map(|b| { + (b.typ == typ) as usize + b.children.as_deref().map(|k| count(k, typ)).unwrap_or(0) + }) + .sum() + } + assert_eq!(count(&boxes, "free"), 0); + assert_eq!(count(&boxes, "cust"), 0); + assert_samples_intact(&output); +} + +// ---------- field patching ---------- + +#[test] +fn set_mvhd_field_preserves_all_other_bytes() { + let (input, _) = build_progressive_file(); + + let mut editor = Editor::new(); + editor.set_field("moov/mvhd", "creation_time", "3600"); + editor.set_field("moov/mvhd", "modification_time", "7200"); + let output = run_editor(&editor, &input); + + // Same length; only the timestamp bytes differ. Both fields were zero; + // 3600 = 0x00000E10 and 7200 = 0x00001C20 change 2 bytes each. + assert_eq!(input.len(), output.len()); + let diff: Vec = (0..input.len()) + .filter(|&i| input[i] != output[i]) + .collect(); + assert_eq!(diff.len(), 4, "expected only timestamp bytes to change"); + + let len = output.len() as u64; + let mut cur = Cursor::new(&output); + let boxes = get_boxes(&mut cur, len, true).unwrap(); + let moov = boxes.iter().find(|b| b.typ == "moov").unwrap(); + let mvhd = moov + .children + .as_ref() + .unwrap() + .iter() + .find(|b| b.typ == "mvhd") + .unwrap(); + let Some(StructuredData::MovieHeader(d)) = &mvhd.structured_data else { + panic!("expected structured mvhd"); + }; + assert_eq!(d.creation_time, 3600); + assert_eq!(d.modification_time, 7200); + assert_eq!(d.timescale, 1000, "timescale must be untouched"); + assert_eq!(d.next_track_id, 2, "next_track_id must be untouched"); +} + +#[test] +fn set_mdhd_language() { + let (input, _) = build_progressive_file(); + let mut editor = Editor::new(); + editor.set_field("moov/trak/mdia/mdhd", "language", "eng"); + let output = run_editor(&editor, &input); + + let mut cur = Cursor::new(output.clone()); + let tracks = track_samples_from_reader(&mut cur).unwrap(); + assert_eq!(tracks.len(), 1); + let len = output.len() as u64; + let mut cur = Cursor::new(&output); + let boxes = get_boxes(&mut cur, len, true).unwrap(); + fn find_mdhd_lang(boxes: &[mp4box::Box]) -> Option { + for b in boxes { + if let Some(StructuredData::MediaHeader(d)) = &b.structured_data { + return Some(d.language.clone()); + } + if let Some(kids) = &b.children + && let Some(l) = find_mdhd_lang(kids) + { + return Some(l); + } + } + None + } + assert_eq!(find_mdhd_lang(&boxes).as_deref(), Some("eng")); +} + +#[test] +fn set_unknown_field_errors() { + let (input, _) = build_progressive_file(); + let mut editor = Editor::new(); + editor.set_field("moov/mvhd", "bogus_field", "1"); + let mut src = Cursor::new(input); + let mut dst = Vec::new(); + let err = editor.process(&mut src, &mut dst).unwrap_err(); + assert!(err.to_string().contains("bogus_field"), "err: {err}"); +} + +// ---------- tags ---------- + +#[test] +fn set_tag_creates_udta_meta_ilst_chain() { + let (input, _) = build_progressive_file(); + + let mut editor = Editor::new(); + editor.set_tag("title", "Test Movie").unwrap(); + editor.set_tag("artist", "Test Artist").unwrap(); + let output = run_editor(&editor, &input); + + let len = output.len() as u64; + let mut cur = Cursor::new(&output); + let tags = get_itunes_tags(&mut cur, len).unwrap(); + assert_eq!(tags.get("title").map(String::as_str), Some("Test Movie")); + assert_eq!(tags.get("artist").map(String::as_str), Some("Test Artist")); + assert_samples_intact(&output); +} + +#[test] +fn set_tag_replaces_existing_value() { + let (input, _) = build_progressive_file(); + + let mut editor = Editor::new(); + editor.set_tag("title", "First").unwrap(); + let output1 = run_editor(&editor, &input); + + let mut editor = Editor::new(); + editor.set_tag("title", "Second").unwrap(); + let output2 = run_editor(&editor, &output1); + + let len = output2.len() as u64; + let mut cur = Cursor::new(&output2); + let tags = get_itunes_tags(&mut cur, len).unwrap(); + assert_eq!(tags.get("title").map(String::as_str), Some("Second")); + // Replacing must not grow the file by another atom: the two outputs + // differ only in the value ("First" vs "Second" = +1 byte). + assert_eq!(output2.len(), output1.len() + 1); + assert_samples_intact(&output2); +} + +// ---------- guards ---------- + +#[test] +fn fragmented_files_are_refused() { + // Minimal file with a moof present. + let ftyp = plain_box(b"ftyp", &concat(&[b"iso5".to_vec(), vec![0u8; 4]])); + let moof = plain_box(b"moof", &full_box(b"mfhd", 0, 0, &1u32.to_be_bytes())); + let input = concat(&[ftyp, moof]); + + let mut editor = Editor::new(); + editor.remove("ftyp"); + let mut src = Cursor::new(input.clone()); + let mut dst = Vec::new(); + let err = editor.process(&mut src, &mut dst).unwrap_err(); + assert!(err.to_string().contains("fragmented"), "err: {err}"); + + // But a no-op pass-through still works (nothing moves). + let output = run_editor(&Editor::new(), &input); + assert_eq!(input, output); +} + +#[test] +fn missing_path_errors_cleanly() { + let (input, _) = build_progressive_file(); + let mut editor = Editor::new(); + editor.remove("moov/trak[3]"); + let mut src = Cursor::new(input); + let mut dst = Vec::new(); + let err = editor.process(&mut src, &mut dst).unwrap_err(); + assert!(err.to_string().contains("not found"), "err: {err}"); +} From 3fb872b4e1ec264a66bae35b4263f80530e4e2ee Mon Sep 17 00:00:00 2001 From: Alfred Gutierrez Date: Sat, 4 Jul 2026 13:55:05 +0900 Subject: [PATCH 2/3] docs: make custom decoder support discoverable The extension point already existed (get_boxes_with_registry + Registry::with_decoder) but was invisible enough that PR #3 was opened to add what was already there. No new functionality: - add a worked doc example on get_boxes_with_registry showing the intended pattern: extend the default registry with a custom decoder (default_registry().with_decoder(...)) rather than replace it - re-export default_registry at the crate root - add an integration test proving a custom decoder fires through get_boxes_with_registry alongside the built-in decoders Supersedes PR #3. --- src/api.rs | 36 ++++++++++++++++++++++++++++++ src/lib.rs | 1 + tests/registry_ftyp.rs | 50 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/src/api.rs b/src/api.rs index 55c7820..405ec75 100644 --- a/src/api.rs +++ b/src/api.rs @@ -73,6 +73,42 @@ pub fn get_boxes(r: &mut R, size: u64, decode: bool) -> anyhow:: /// Like [`get_boxes`], but decodes with a caller-supplied [`Registry`] /// instead of the default one. +/// +/// To *extend* the default registry with custom decoders (rather than +/// replace it), build on [`default_registry`]: +/// +/// ```no_run +/// use mp4box::{BoxKey, FourCC, get_boxes_with_registry}; +/// use mp4box::registry::{BoxDecoder, BoxValue, default_registry}; +/// use std::fs::File; +/// use std::io::Read; +/// +/// struct MyDecoder; +/// impl BoxDecoder for MyDecoder { +/// fn decode( +/// &self, +/// r: &mut dyn Read, +/// _hdr: &mp4box::BoxHeader, +/// _version: Option, +/// _flags: Option, +/// ) -> anyhow::Result { +/// let mut buf = Vec::new(); +/// r.read_to_end(&mut buf)?; +/// Ok(BoxValue::Text(format!("{} payload bytes", buf.len()))) +/// } +/// } +/// +/// let reg = default_registry().with_decoder( +/// BoxKey::FourCC(FourCC(*b"xyz ")), +/// "xyz ", +/// Box::new(MyDecoder), +/// ); +/// +/// let mut file = File::open("video.mp4")?; +/// let size = file.metadata()?.len(); +/// let boxes = get_boxes_with_registry(&mut file, size, true, ®)?; +/// # Ok::<(), anyhow::Error>(()) +/// ``` pub fn get_boxes_with_registry( r: &mut R, size: u64, diff --git a/src/lib.rs b/src/lib.rs index 7148b64..8b7ccd3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -92,6 +92,7 @@ pub use parser::{parse_boxes, parse_children, read_box_header}; pub use registry::{ BoxValue, Co64Data, CttsData, CttsEntry, HdlrData, MdhdData, Registry, SampleEntry, StcoData, StructuredData, StscData, StscEntry, StsdData, StssData, StszData, SttsData, SttsEntry, + default_registry, }; // High-level API diff --git a/tests/registry_ftyp.rs b/tests/registry_ftyp.rs index 8dcedd2..2ede142 100644 --- a/tests/registry_ftyp.rs +++ b/tests/registry_ftyp.rs @@ -51,3 +51,53 @@ fn registry_invokes_decoder() { _ => panic!("expected bytes"), } } + +/// A custom decoder added on top of the default registry must fire through +/// the high-level get_boxes_with_registry API, alongside the built-ins. +#[test] +fn custom_decoder_extends_default_registry_in_get_boxes() { + use mp4box::get_boxes_with_registry; + use mp4box::registry::default_registry; + use std::io::Cursor; + + struct XyzDecoder; + impl BoxDecoder for XyzDecoder { + fn decode( + &self, + r: &mut dyn Read, + _hdr: &BoxHeader, + _version: Option, + _flags: Option, + ) -> anyhow::Result { + let mut buf = Vec::new(); + r.read_to_end(&mut buf)?; + Ok(BoxValue::Text(format!("xyz payload={} bytes", buf.len()))) + } + } + + let reg = default_registry().with_decoder( + BoxKey::FourCC(FourCC(*b"xyz ")), + "xyz ", + Box::new(XyzDecoder), + ); + + // ftyp (decoded by the default registry) followed by a custom "xyz " box. + let mut data = Vec::new(); + data.extend_from_slice(&16u32.to_be_bytes()); + data.extend_from_slice(b"ftypisom"); + data.extend_from_slice(&0u32.to_be_bytes()); + data.extend_from_slice(&12u32.to_be_bytes()); + data.extend_from_slice(b"xyz ...."); + + let len = data.len() as u64; + let mut cur = Cursor::new(data); + let boxes = get_boxes_with_registry(&mut cur, len, true, ®).unwrap(); + + let ftyp = boxes.iter().find(|b| b.typ == "ftyp").unwrap(); + assert!( + ftyp.decoded.as_deref().unwrap_or("").contains("major=isom"), + "built-in ftyp decoder must still fire" + ); + let xyz = boxes.iter().find(|b| b.typ == "xyz ").unwrap(); + assert_eq!(xyz.decoded.as_deref(), Some("xyz payload=4 bytes")); +} From d1564d995eb28adc342d1e3b76b9d12b8d431123 Mon Sep 17 00:00:00 2001 From: Alfred Gutierrez Date: Sat, 4 Jul 2026 14:01:36 +0900 Subject: [PATCH 3/3] feat: update cargo with features and new bin/examples. --- Cargo.toml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 9c293b1..90c37d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,12 @@ description = "Minimal MP4/ISOBMFF parser with JSON output, box decoding, and UU license = "MIT" repository = "https://github.com/alfg/mp4box" +[features] +default = ["edit"] +# Non-destructive box editing (Editor, mp4edit). No extra dependencies; +# opt out with default-features = false for a parse-only build. +edit = [] + [lib] name = "mp4box" path = "src/lib.rs" @@ -22,6 +28,11 @@ path = "src/bin/mp4info.rs" name = "mp4samples" path = "src/bin/mp4samples.rs" +[[bin]] +name = "mp4edit" +path = "src/bin/mp4edit.rs" +required-features = ["edit"] + [[example]] name = "simple" path = "examples/simple.rs" @@ -30,6 +41,11 @@ path = "examples/simple.rs" name = "boxes" path = "examples/boxes.rs" +[[example]] +name = "edit" +path = "examples/edit.rs" +required-features = ["edit"] + [dependencies] anyhow = "1.0" byteorder = "1.5"