Skip to main content

mp4box/edit/
mod.rs

1//! Non-destructive MP4/ISOBMFF box editing.
2//!
3//! Editing works on a tree where unmodified boxes are *references* into the
4//! source file, so serialization streams untouched bytes through verbatim
5//! (an `mdat` is never loaded into memory) and re-serializing an unedited
6//! tree reproduces the source byte for byte. Box sizes are recomputed
7//! bottom-up during layout, so every ancestor of an edited box is correct by
8//! construction, and `stco`/`co64` chunk offsets are remapped through the
9//! exact old-offset → new-offset extent map — data that didn't move keeps
10//! its offsets, data that moved shifts by exactly the right amount.
11//!
12//! ```no_run
13//! use mp4box::edit::{Command, Editor};
14//! use std::fs::File;
15//!
16//! let mut editor = Editor::new();
17//! editor.add_command(Command::Remove { path: "moov/udta".into() });
18//! editor.set_tag("title", "My Movie")?;
19//!
20//! let mut src = File::open("in.mp4")?;
21//! let mut dst = File::create("out.mp4")?;
22//! let stats = editor.process(&mut src, &mut dst)?;
23//! println!("wrote {} bytes, {} chunk offsets adjusted",
24//!     stats.bytes_written, stats.chunk_offsets_adjusted);
25//! # Ok::<(), anyhow::Error>(())
26//! ```
27//!
28//! Fragmented (`moof`/`sidx`) and HEIF (`iloc`) files are refused: their
29//! internal offsets are not covered by the fixup pass yet, and editing them
30//! would corrupt the output.
31
32mod fields;
33mod fixup;
34mod tags;
35mod tree;
36
37pub use fixup::FixupStats;
38pub use tree::{EditNode, EditTree, HeaderForm, Payload};
39
40use crate::boxes::FourCC;
41use crate::parser::parse_boxes;
42use std::io::{Read, Seek, SeekFrom, Write};
43
44/// A single edit operation. Paths are slash-delimited fourcc segments with
45/// optional indices for repeated boxes: `"moov/trak[1]/mdia/mdhd"`.
46/// An omitted index means the first match.
47pub enum Command {
48    /// Delete the box at `path`.
49    Remove { path: String },
50    /// Delete every box with this fourcc, anywhere in the tree.
51    RemoveAll { fourcc: String },
52    /// Insert a complete raw box (header + payload) as a child of `parent`.
53    /// `position: None` appends; `Some(n)` inserts before the nth child.
54    Insert {
55        parent: String,
56        bytes: Vec<u8>,
57        position: Option<usize>,
58    },
59    /// Replace the box at `path` with a complete raw box.
60    Replace { path: String, bytes: Vec<u8> },
61    /// Set a named field of a known box in place (mvhd/tkhd/mdhd), e.g.
62    /// `path: "moov/mvhd", field: "creation_time", value: "0"`. All other
63    /// bytes of the box are preserved.
64    Set {
65        path: String,
66        field: String,
67        value: String,
68    },
69    /// Set an iTunes metadata tag, creating `moov/udta/meta/ilst` as needed.
70    /// `tag` is a friendly name (`"title"`, `"artist"`, ...) or a raw fourcc.
71    SetTag { tag: String, value: String },
72    /// Move `moov` before the first `mdat` ("faststart") so players can
73    /// begin progressive playback without downloading the whole file.
74    /// Chunk offsets are remapped automatically. No-op when the file is
75    /// already faststart-ordered or has no `mdat`.
76    ///
77    /// Note: on >4 GiB files using 32-bit `stco`, offsets pushed past
78    /// `u32::MAX` cannot be represented and are reported via
79    /// [`EditStats::chunk_offsets_unmapped`] (stco→co64 conversion is not
80    /// implemented yet).
81    Faststart,
82}
83
84/// Statistics from a completed edit.
85#[derive(Debug, Default)]
86pub struct EditStats {
87    pub bytes_written: u64,
88    pub chunk_offsets_adjusted: usize,
89    /// Chunk offsets pointing at data that no longer exists (left unchanged).
90    pub chunk_offsets_unmapped: usize,
91}
92
93/// Applies a batch of [`Command`]s to an MP4 source and writes the result.
94#[derive(Default)]
95pub struct Editor {
96    commands: Vec<Command>,
97}
98
99impl Editor {
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    pub fn add_command(&mut self, cmd: Command) -> &mut Self {
105        self.commands.push(cmd);
106        self
107    }
108
109    /// Convenience for [`Command::Remove`].
110    pub fn remove(&mut self, path: impl Into<String>) -> &mut Self {
111        self.add_command(Command::Remove { path: path.into() })
112    }
113
114    /// Convenience for [`Command::RemoveAll`].
115    pub fn remove_all(&mut self, fourcc: impl Into<String>) -> &mut Self {
116        self.add_command(Command::RemoveAll {
117            fourcc: fourcc.into(),
118        })
119    }
120
121    /// Convenience for [`Command::Set`].
122    pub fn set_field(
123        &mut self,
124        path: impl Into<String>,
125        field: impl Into<String>,
126        value: impl Into<String>,
127    ) -> &mut Self {
128        self.add_command(Command::Set {
129            path: path.into(),
130            field: field.into(),
131            value: value.into(),
132        })
133    }
134
135    /// Convenience for [`Command::Faststart`].
136    pub fn faststart(&mut self) -> &mut Self {
137        self.add_command(Command::Faststart)
138    }
139
140    /// Convenience for [`Command::SetTag`]. Validates the tag name eagerly.
141    pub fn set_tag(
142        &mut self,
143        tag: impl Into<String>,
144        value: impl Into<String>,
145    ) -> anyhow::Result<&mut Self> {
146        let tag = tag.into();
147        tags::tag_fourcc(&tag)?; // fail fast on unknown names
148        self.add_command(Command::SetTag {
149            tag,
150            value: value.into(),
151        });
152        Ok(self)
153    }
154
155    /// Parse `src`, apply all commands in order, and write the edited file
156    /// to `dst`. `src` is only read; the output is always a new file.
157    pub fn process<R: Read + Seek, W: Write>(
158        &self,
159        src: &mut R,
160        dst: &mut W,
161    ) -> anyhow::Result<EditStats> {
162        let file_len = src.seek(SeekFrom::End(0))?;
163        let boxes = parse_boxes(src, 0, file_len)?;
164        let mut edit_tree = tree::build_tree(src, &boxes, file_len)?;
165
166        if !self.commands.is_empty() {
167            guard_unsupported(&edit_tree)?;
168        }
169
170        for cmd in &self.commands {
171            apply_command(src, &mut edit_tree, cmd)?;
172        }
173
174        // Layout: compute where every unmodified extent lands in the output.
175        let map = tree::layout(&edit_tree);
176
177        // Remap chunk offsets when any data moved.
178        let moved = map.iter().any(|m| m.new_offset != m.old_offset);
179        let fixup_stats = if moved {
180            fixup::fix_chunk_offsets(src, &mut edit_tree, &map)?
181        } else {
182            FixupStats::default()
183        };
184
185        let bytes_written = tree::write_tree(src, &edit_tree, dst)?;
186
187        Ok(EditStats {
188            bytes_written,
189            chunk_offsets_adjusted: fixup_stats.entries_adjusted,
190            chunk_offsets_unmapped: fixup_stats.entries_unmapped,
191        })
192    }
193
194    /// Convenience wrapper over [`Editor::process`] for file paths.
195    /// Refuses `output == input`; the source is never modified.
196    pub fn process_file(
197        &self,
198        input: impl AsRef<std::path::Path>,
199        output: impl AsRef<std::path::Path>,
200    ) -> anyhow::Result<EditStats> {
201        let input = input.as_ref();
202        let output = output.as_ref();
203        anyhow::ensure!(
204            input != output,
205            "in-place editing is not supported; choose a different output path"
206        );
207        let mut src = std::fs::File::open(input)?;
208        let mut dst = std::io::BufWriter::new(std::fs::File::create(output)?);
209        let stats = self.process(&mut src, &mut dst)?;
210        std::io::Write::flush(&mut dst)?;
211        Ok(stats)
212    }
213}
214
215/// Refuse file kinds whose internal offsets the fixup pass does not cover.
216fn guard_unsupported(tree: &EditTree) -> anyhow::Result<()> {
217    fn scan(nodes: &[EditNode]) -> Option<&'static str> {
218        for n in nodes {
219            match &n.typ.0 {
220                b"moof" => return Some("fragmented MP4 (moof)"),
221                b"sidx" => return Some("indexed segments (sidx)"),
222                b"iloc" => return Some("HEIF item locations (iloc)"),
223                _ => {}
224            }
225            if let Some(kids) = n.children()
226                && let Some(hit) = scan(kids)
227            {
228                return Some(hit);
229            }
230        }
231        None
232    }
233    if let Some(kind) = scan(&tree.roots) {
234        anyhow::bail!(
235            "editing is not supported for {} yet: byte offsets inside these \
236             structures are not fixed up, and editing would corrupt the file",
237            kind
238        );
239    }
240    Ok(())
241}
242
243// ---------- command application ----------
244
245fn apply_command<R: Read + Seek>(
246    src: &mut R,
247    tree: &mut EditTree,
248    cmd: &Command,
249) -> anyhow::Result<()> {
250    match cmd {
251        Command::Remove { path } => {
252            let (siblings, idx) = resolve_parent_mut(&mut tree.roots, path)?;
253            siblings.remove(idx);
254        }
255
256        Command::RemoveAll { fourcc } => {
257            let cc = seg_fourcc(fourcc)?;
258            remove_all(&mut tree.roots, &cc);
259        }
260
261        Command::Insert {
262            parent,
263            bytes,
264            position,
265        } => {
266            let node = EditNode::from_raw(bytes)?;
267            let target = resolve_node_mut(&mut tree.roots, parent)?;
268            let children = target
269                .children_mut()
270                .ok_or_else(|| anyhow::anyhow!("'{}' is not a container", parent))?;
271            let at = position.unwrap_or(children.len()).min(children.len());
272            children.insert(at, node);
273        }
274
275        Command::Replace { path, bytes } => {
276            let node = EditNode::from_raw(bytes)?;
277            let (siblings, idx) = resolve_parent_mut(&mut tree.roots, path)?;
278            siblings[idx] = node;
279        }
280
281        Command::Set { path, field, value } => {
282            let node = resolve_node_mut(&mut tree.roots, path)?;
283            set_field_on_node(src, node, field, value)?;
284        }
285
286        Command::SetTag { tag, value } => {
287            let cc = tags::tag_fourcc(tag)?;
288            let moov = tree
289                .roots
290                .iter_mut()
291                .find(|n| &n.typ.0 == b"moov")
292                .ok_or_else(|| anyhow::anyhow!("no moov box: cannot set tags"))?;
293            tags::set_tag_in_moov(moov, &cc, value)?;
294        }
295
296        Command::Faststart => {
297            let moov_idx = tree
298                .roots
299                .iter()
300                .position(|n| &n.typ.0 == b"moov")
301                .ok_or_else(|| anyhow::anyhow!("no moov box: cannot faststart"))?;
302            let Some(mdat_idx) = tree.roots.iter().position(|n| &n.typ.0 == b"mdat") else {
303                return Ok(()); // no media data: nothing to optimize
304            };
305            if moov_idx < mdat_idx {
306                return Ok(()); // already faststart-ordered
307            }
308            let moov = tree.roots.remove(moov_idx);
309            // Place moov right after a leading ftyp (which must stay first),
310            // otherwise at the very front.
311            let at = usize::from(tree.roots.first().is_some_and(|n| &n.typ.0 == b"ftyp"));
312            tree.roots.insert(at, moov);
313        }
314    }
315    Ok(())
316}
317
318fn set_field_on_node<R: Read + Seek>(
319    src: &mut R,
320    node: &mut EditNode,
321    field: &str,
322    value: &str,
323) -> anyhow::Result<()> {
324    // Materialize the payload (version/flags + body) so it can be patched.
325    let mut payload = match &node.payload {
326        Payload::Bytes(b) => b.clone(),
327        Payload::Extent(e) => {
328            let mut buf = vec![0u8; e.len as usize];
329            src.seek(SeekFrom::Start(e.offset))?;
330            src.read_exact(&mut buf)?;
331            buf
332        }
333        Payload::Container { .. } => {
334            anyhow::bail!("'{}' is a container; --set applies to leaf boxes", node.typ)
335        }
336    };
337    anyhow::ensure!(!payload.is_empty(), "'{}' has an empty payload", node.typ);
338
339    let version = payload[0];
340    let (offset, kind) = fields::field_spec(&node.typ.0, version, field).ok_or_else(|| {
341        anyhow::anyhow!(
342            "no known field '{}' in '{}' (version {})",
343            field,
344            node.typ,
345            version
346        )
347    })?;
348    fields::patch_field(&mut payload, offset, kind, value)?;
349    node.payload = Payload::Bytes(payload);
350    Ok(())
351}
352
353fn remove_all(nodes: &mut Vec<EditNode>, cc: &FourCC) {
354    nodes.retain(|n| &n.typ != cc);
355    for n in nodes {
356        if let Some(kids) = n.children_mut() {
357            remove_all(kids, cc);
358        }
359    }
360}
361
362// ---------- path resolution ----------
363
364/// Parse one path segment: `"trak[1]"` → (`trak`, 1); `"trak"` → (`trak`, 0).
365fn parse_segment(seg: &str) -> anyhow::Result<(FourCC, usize)> {
366    if let Some(open) = seg.find('[') {
367        let close = seg
368            .rfind(']')
369            .ok_or_else(|| anyhow::anyhow!("unclosed '[' in path segment '{}'", seg))?;
370        let idx: usize = seg[open + 1..close]
371            .parse()
372            .map_err(|_| anyhow::anyhow!("bad index in path segment '{}'", seg))?;
373        Ok((seg_fourcc(&seg[..open])?, idx))
374    } else {
375        Ok((seg_fourcc(seg)?, 0))
376    }
377}
378
379/// Convert a path segment to a fourcc; '©' (2 bytes in UTF-8) maps to the
380/// single 0xA9 byte used by iTunes atoms.
381fn seg_fourcc(seg: &str) -> anyhow::Result<FourCC> {
382    let mut bytes = Vec::with_capacity(4);
383    for ch in seg.chars() {
384        if ch == '©' {
385            bytes.push(0xA9);
386        } else {
387            anyhow::ensure!(ch.is_ascii(), "invalid character in fourcc '{}'", seg);
388            bytes.push(ch as u8);
389        }
390    }
391    anyhow::ensure!(bytes.len() == 4, "'{}' is not a 4-character box type", seg);
392    Ok(FourCC(bytes.try_into().unwrap()))
393}
394
395fn find_child_idx(nodes: &[EditNode], cc: &FourCC, nth: usize) -> Option<usize> {
396    nodes
397        .iter()
398        .enumerate()
399        .filter(|(_, n)| &n.typ == cc)
400        .map(|(i, _)| i)
401        .nth(nth)
402}
403
404/// Resolve `path` to a mutable node reference.
405fn resolve_node_mut<'a>(
406    roots: &'a mut Vec<EditNode>,
407    path: &str,
408) -> anyhow::Result<&'a mut EditNode> {
409    let (siblings, idx) = resolve_parent_mut(roots, path)?;
410    Ok(&mut siblings[idx])
411}
412
413/// Resolve `path` to its parent's child list and the index within it, so the
414/// caller can remove or replace the node.
415fn resolve_parent_mut<'a>(
416    roots: &'a mut Vec<EditNode>,
417    path: &str,
418) -> anyhow::Result<(&'a mut Vec<EditNode>, usize)> {
419    anyhow::ensure!(!path.is_empty(), "empty box path");
420    let segments: Vec<&str> = path.split('/').collect();
421
422    let mut current: &'a mut Vec<EditNode> = roots;
423    for (depth, seg) in segments.iter().enumerate() {
424        let (cc, nth) = parse_segment(seg)?;
425        let idx = find_child_idx(current, &cc, nth)
426            .ok_or_else(|| anyhow::anyhow!("box '{}' not found (in path '{}')", seg, path))?;
427
428        if depth == segments.len() - 1 {
429            return Ok((current, idx));
430        }
431
432        current = current[idx]
433            .children_mut()
434            .ok_or_else(|| anyhow::anyhow!("'{}' has no children (in path '{}')", seg, path))?;
435    }
436    unreachable!("loop always returns on the last segment");
437}