Phones record portrait video as landscape pictures plus a rotation in the container: the MP4/MOV tkhd matrix (ISO/IEC 14496-12 §8.3.2) or the Matroska Projection pose roll (RFC 9559 §5.1.4.1.28.46). The matrix was parsed but never used, so every portrait iPhone clip imported sideways at its landscape size. - isobmff: Track::display_rotation() reads 0/90/180/270° from the matrix signs (mirrored / sheared matrices are left alone). - matroska: parse Projection (type, yaw, pitch, roll); VideoInfo::display_rotation() for rectangular projections. - frame: VideoFrame::rotated() turns every plane at its own resolution (4:2:2 chroma is widened to 4:4:4 for quarter turns) and swaps the PAR. - codecs: Mp4Source / MkvSource report the displayed size and PAR, and GopCache rotates decoded frames before caching them. - web: the WebCodecs source configures the decoder with the coded size and rotates its output the same way. Tests: a one-frame ProRes MOV built in the test with 0/90/180/270° matrices (reported size + which half ends up on top), plane rotation for RGBA / 4:2:0 / 4:2:2, and a synthetic Matroska file with a -90° roll. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| src | ||
| tests | ||
| Cargo.toml | ||
| README.md | ||
README.md
filmcraft-isobmff
A clean-room ISO Base Media File Format (MP4, ISO/IEC 14496-12/14/15) and QuickTime (MOV) demuxer and muxer,
written from the public specifications. Layer L0: it depends only on filmcraft-bitstream and thiserror,
has no unsafe, and builds for wasm32-unknown-unknown.
Demuxing
use filmcraft_isobmff::{open, TrackKind};
let file = std::fs::File::open("clip.mov")?;
let mp4 = open(&file)?; // any ByteSource: &[u8], Vec<u8>, Arc<[u8]>, File, ...
let v = mp4.track_of_kind(TrackKind::Video).unwrap();
let track = &mp4.tracks[v];
let i = track.sample_at_presentation_time(1000).unwrap(); // track timescale, edit-adjusted
let key = track.sync_sample_before(i); // decode from here
let bytes = mp4.read_sample(&file, v, key)?; // raw sample (AVCC/HVCC length-prefixed NALs, ...)
ByteSource(len,read_at(offset, buf)) is the input abstraction. Implementations exist for[u8],Vec<u8>,&T,Arc<T>,Box<T>, andstd::fs::Fileon non-wasm targets.openreads only top-level box headers,ftyp,moovand everymoof. It never readsmdat. The one exception is the first sample of atmcdtrack, which it reads (4 bytes) to get the timecode start frame.Mp4File: brands,is_quicktime, movietimescale/duration,tracks,metadata,fragmented,fragment_count.Track:id,kind(Video/Audio/Timecode/Subtitle/Other),handler,timescale,duration,language,tkhdfields (size, matrix, volume, enabled),entries: Vec<SampleEntry>,samples,edits,edit_offset,references(tref),sdtp,sample_groups(sbgp+sgpd, raw),pcm_chunked. Helpers:codec(),video(),audio(),presentation_pts(i),sample_at_pts(pts),sample_at_presentation_time(t),sync_sample_before(i),sync_samples().Sample:offset,size,dts,pts(dts + ctts),duration,is_sync(all samples are sync whenstssis absent),description_index. All times are raw media time in the track timescale.- Edit lists:
editsholds theelstentries unchanged.edit_offsetisleading empty edits (rescaled to media timescale) − first non-empty edit's media_time, sopresentation time = pts + edit_offset. This matches ffprobe's timestamps for the common cases (B-frame delay, AAC priming, empty-edit offsets). Multi-segment edits, dwells and rate changes are exposed raw for the caller to interpret. - Uncompressed PCM (
lpcm,sowt,twos,in24,in32,fl32,fl64,raw,ipcm,fpcm) is merged to oneSampleper chunk (pcm_chunked = true), withdurationin frames. This avoids one table entry per audio frame.
Parsed boxes
ftyp, moov/mvhd, trak/tkhd, edts/elst (v0/v1), tref, mdia/mdhd (v0/v1, ISO and Mac language
codes), hdlr (ISO and Pascal names), minf/stbl: stsd, stts, ctts (v0/v1, read as signed), stss,
stsc, stsz/stz2 (4/8/16-bit), stco/co64, sdtp, sbgp/sgpd. Also mvex/trex, moof/traf/tfhd/tfdt/trun
(every tfhd base-offset mode and multiple moofs), and udta metadata (QuickTime ©xxx atoms,
meta/ilst, and meta/keys (mdta)). uuid, free, skip, wide, sidx and mfra are skipped.
Boxes with 64-bit sizes and with size 0 (runs to the end of the file) are supported.
Sample entries → CodecConfig
| Format | Config |
|---|---|
avc1/avc3 + avcC |
Avc(AvcConfig): profile, level, length size, SPS/PPS, extension bytes |
hvc1/hev1 + hvcC |
Hevc(HevcConfig): all header fields plus NAL arrays (vps()/sps()/pps()) |
av01 + av1C |
Av1(Av1Config) |
vp09 + vpcC |
Vp9(VpcConfig) |
apch apcn apcs apco ap4h ap4x |
ProRes { fourcc } |
jpeg mjpa mjpb |
Jpeg { fourcc } |
AVdn AVdh |
Dnx { fourcc } |
mp4a + esds (in wave too) |
Aac(AacConfig): ASC bytes + decoded object type/rate/channels; Mp3 for OTI 0x69/0x6B |
| PCM fourccs | Pcm(PcmConfig): bits, float, endianness (enda, lpcm flags, pcmC), channels, rate |
alac |
Alac { cookie } |
Opus + dOps |
Opus(OpusConfig) |
fLaC + dfLa |
Flac(FlacConfig) (STREAMINFO decoded) |
ac-3/ec-3 |
Ac3 { dac3 } / Eac3 { dec3 } |
tmcd |
Timecode(TimecodeConfig): flags (drop frame), timescale, frame duration, fps, start frame, reel name; format_frame() |
| anything else | Unknown { fourcc, raw } |
Video entries also parse colr (nclx/nclc/ICC), pasp, clap, fiel, gama and btrt into
VideoParams. Audio entries parse QuickTime sound description v0/v1/v2 into AudioParams.
Robustness
The parser does not panic on malformed input; every error comes back as an Error. Table counts are
checked against the bytes in their box before anything is allocated. A constant-size stsz count is
clamped to what fits in the file. trun counts are checked, and each track is capped at 2²⁶ samples.
moov/moof are capped at 1 GiB. Compressed movie headers (cmov) are rejected with
Error::Unsupported.
Muxing
use filmcraft_isobmff::*;
let mut w = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4))?;
let v = w.add_track(TrackConfig::new(SampleEntry::avc(avcc, 1920, 1080), 90_000))?;
let mut acfg = TrackConfig::new(SampleEntry::aac(asc, 2, 48_000), 48_000);
acfg.media_start = Some(1024); // AAC encoder delay → edit list
let a = w.add_track(acfg)?;
w.write_sample(v, WriteSample { data: &au, duration: 3000, composition_offset: 6000, is_sync: true })?;
// ... interleave samples in decode order ...
let bytes = w.finish_faststart()?.into_inner(); // or finish() for moov-at-end
Mp4Writer<W: Write + Seek>streams samples into a singlemdat.finish()appendsmoov.finish_faststart()(needsW: Read + Write + Seek) moves the media data forward in place and writesmoovbefore it. Ifmdatgoes past 4 GiB the header becomes a large-size header, and chunk offsets switch toco64when needed.- Brands:
Brand::Mp4writesisomwithisom iso2 avc1 mp41 mp42.Brand::Movwritesqt, uses QuickTimehdlr/Pascal names, and setswideas the placeholder box. - Tables written:
stts,ctts(v1 when an offset is negative),stss(omitted when every sample is sync),stsc,stsz(constant or table),stco/co64. Consecutive samples of one track share a chunk. - Sample entries come from
SampleEntry(avc,hevc,prores,jpeg,aac,pcm, or any demuxed entry). Remuxing copies codec config,colr,pasp,clap,fiel,gamaandbtrt. PCM is written assowt/twos(16-bit) orraw(8-bit) or sound description v2lpcmin MOV, and asipcm/fpcm+pcmCin MP4. A PCMwrite_samplecall can hold any whole number of frames, and the track timescale must equal the sample rate. - Edit lists: set
TrackConfig::editsexplicitly, or usemedia_startto get a single edit. - Timecode:
add_timecode_track(video, TimecodeConfig, start_frame)adds atmcdtrack (withgmhd) and atref/tmcdlink from the video track. WriterOptions::metadatawrites©xxxtext atoms toudta.FragmentedWriter<W: Write>writes fMP4. The init segment isftyp+moovwithmvex/trex. Eachflush_fragment()then writesmoof(mfhd,trafwith default-base-is-mooftfhd,tfdtv1,trunwith per-sample duration, size, flags and composition offset) followed bymdat.
Tests
- Unit tests cover the box reader and hand-built
stbledge cases (stz2, co64, ctts v1, empty edits, 64-bit and size-0 boxes, uuid/wide/skip), codec config round-trips, and allocation guards. - Property tests (
tests/roundtrip.rs) write random multi-track files, both progressive (MP4/MOV, faststart, PCM, edits) and fragmented, then read them back and compare sample tables and bytes. - ffprobe oracle tests (
tests/oracle_demux.rs) run on ffmpeg-generated fixtures intarget/fixtures/isobmff/: H.264 with B-frames (MP4 and MOV), HEVC, ProRes 422/4444, MJPEG, DNxHR, PCM s16le/s16be/s24le/f32le, AAC, ALAC, FLAC, Opus, AC-3, VP9, AV1, fragmented (with and without default-base-is-moof), faststart, an edit-list cut,tmcdat 29.97 DF and 23.976, and colr/pasp. They compare packet offsets, sizes, pts/dts, durations and key flags, plus stream parameters. Tests skip with a message when ffmpeg or an encoder is missing. - Muxer oracle tests (
tests/oracle_mux.rs) remux fixtures with our writers. They check thatffmpeg -v error -f nullreports nothing, that ffprobe packets and stream parameters match the source, and that timecode, the AAC priming edit, ipcm and fMP4 output are correct. - Mutation fuzzing (
tests/fuzz.rs) uses a deterministic seed to mutate and truncate synthetic and ffmpeg files, and checks that nothing panics.
Limitations / not yet supported
presentation time = pts + edit_offsetonly applies the leading empty edits and the first media edit. Multi-segment edit lists, dwell edits and non-unity rates are exposed raw. ffmpeg handles an edit that starts mid-frame differently: it snaps to the frame.- Other data references (external
alis/urlfiles), encrypted entries (encv/enca,sinf,senc),cmov, and QuickTime reference movies are not supported. sidx/mfraare ignored, because the demuxer scans every top-level box.stsdentries with a QuickTime colour table are not decoded.- Subtitle and text tracks (
tx3g,c608,wvtt) andmp4vshow up asUnknown. - Muxer: one
mdat, no chunk-size or duration limits (the caller controls interleaving), nosdtp,sgpd/sbgporcslgoutput, ISO-stylemeta/ilstmetadata is not written, and the fragmented writer has no PCM,sidxormfra.