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};
pub enum Command {
Remove { path: String },
RemoveAll { fourcc: String },
Insert {
parent: String,
bytes: Vec<u8>,
position: Option<usize>,
},
Replace { path: String, bytes: Vec<u8> },
Set {
path: String,
field: String,
value: String,
},
SetTag { tag: String, value: String },
Faststart,
}
#[derive(Debug, Default)]
pub struct EditStats {
pub bytes_written: u64,
pub chunk_offsets_adjusted: usize,
pub chunk_offsets_unmapped: usize,
}
#[derive(Default)]
pub struct Editor {
commands: Vec<Command>,
}
impl Editor {
pub fn new() -> Self {
Self::default()
}
pub fn add_command(&mut self, cmd: Command) -> &mut Self {
self.commands.push(cmd);
self
}
pub fn remove(&mut self, path: impl Into<String>) -> &mut Self {
self.add_command(Command::Remove { path: path.into() })
}
pub fn remove_all(&mut self, fourcc: impl Into<String>) -> &mut Self {
self.add_command(Command::RemoveAll {
fourcc: fourcc.into(),
})
}
pub fn set_field(
&mut self,
path: impl Into<String>,
field: impl Into<String>,
value: impl Into<String>,
) -> &mut Self {
self.add_command(Command::Set {
path: path.into(),
field: field.into(),
value: value.into(),
})
}
pub fn faststart(&mut self) -> &mut Self {
self.add_command(Command::Faststart)
}
pub fn set_tag(
&mut self,
tag: impl Into<String>,
value: impl Into<String>,
) -> anyhow::Result<&mut Self> {
let tag = tag.into();
tags::tag_fourcc(&tag)?; self.add_command(Command::SetTag {
tag,
value: value.into(),
});
Ok(self)
}
pub fn process<R: Read + Seek, W: Write>(
&self,
src: &mut R,
dst: &mut W,
) -> anyhow::Result<EditStats> {
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)?;
}
let map = tree::layout(&edit_tree);
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,
})
}
pub fn process_file(
&self,
input: impl AsRef<std::path::Path>,
output: impl AsRef<std::path::Path>,
) -> anyhow::Result<EditStats> {
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)
}
}
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(())
}
fn apply_command<R: Read + Seek>(
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)?;
}
Command::Faststart => {
let moov_idx = tree
.roots
.iter()
.position(|n| &n.typ.0 == b"moov")
.ok_or_else(|| anyhow::anyhow!("no moov box: cannot faststart"))?;
let Some(mdat_idx) = tree.roots.iter().position(|n| &n.typ.0 == b"mdat") else {
return Ok(()); };
if moov_idx < mdat_idx {
return Ok(()); }
let moov = tree.roots.remove(moov_idx);
let at = usize::from(tree.roots.first().is_some_and(|n| &n.typ.0 == b"ftyp"));
tree.roots.insert(at, moov);
}
}
Ok(())
}
fn set_field_on_node<R: Read + Seek>(
src: &mut R,
node: &mut EditNode,
field: &str,
value: &str,
) -> anyhow::Result<()> {
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<EditNode>, cc: &FourCC) {
nodes.retain(|n| &n.typ != cc);
for n in nodes {
if let Some(kids) = n.children_mut() {
remove_all(kids, cc);
}
}
}
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))
}
}
fn seg_fourcc(seg: &str) -> anyhow::Result<FourCC> {
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<usize> {
nodes
.iter()
.enumerate()
.filter(|(_, n)| &n.typ == cc)
.map(|(i, _)| i)
.nth(nth)
}
fn resolve_node_mut<'a>(
roots: &'a mut Vec<EditNode>,
path: &str,
) -> anyhow::Result<&'a mut EditNode> {
let (siblings, idx) = resolve_parent_mut(roots, path)?;
Ok(&mut siblings[idx])
}
fn resolve_parent_mut<'a>(
roots: &'a mut Vec<EditNode>,
path: &str,
) -> anyhow::Result<(&'a mut Vec<EditNode>, usize)> {
anyhow::ensure!(!path.is_empty(), "empty box path");
let segments: Vec<&str> = path.split('/').collect();
let mut current: &'a mut Vec<EditNode> = 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");
}