* craft-audio: core types, WAV/AIFF read and write, peaks, symphonia The new crate without any codec module: what every later codec plugs into. - Shared types: `AudioBuffer`, `AudioInfo`, `ChannelLayout`/`Speaker`, `AudioError`, `EncodeOptions` (built with `new` and `with_*`), `EncodeFormat`, `BitDepth`, `BwfInfo`, `SampleFormat`. - WAV/BWF/RF64 and AIFF/AIFF-C reading and writing from SoundCraft's `soundcraft-audio-io`, kept over EffectCraft's and FilmCraft's smaller writers (only it has BWF, dither, any speaker mask and streaming without a known length). `encode` and `FileEncoder` behind `pcm-encode`; `lossless-encode` and `encode` turn it on. - `decode`, `probe`, `tracks` and the streaming, seekable `Decoder` (feature `decode-native`). The native path reads WAV, AIFF, and PCM in MP4/MOV and Matroska through craft-media's demuxers, packet by packet, with edit lists and `CodecDelay`/`DiscardPadding` applied. `TrackDecoder` decodes PCM; each codec PR adds its codec to it. - symphonia 0.6 (MPL-2.0, used unmodified) behind the `symphonia` feature, part of `decode`: the fallback for every format with no native decoder yet. Its size fields are checked before it reads, and its panics are caught where they unwind. - `peaks`: the min/max/RMS pyramid for waveform drawing. Workspace: craft-media is a workspace dependency without default features, so an encode-only craft-audio does not build the MP4 demuxer, and `symphonia-bundle-flac` builds without overflow checks in dev profiles (its 32-bit LPC restore wraps on valid input). CI: `audio.yml`, with clippy per feature, rustdoc, WASM, MSRV, and the tests with ffmpeg oracles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * craft-audio: review fixes (CodeRabbit on #441) - Reading: AIFF that declares frames without an SSND chunk is Malformed; a Matroska PCM track's length counts from its first frame; seek targets saturate; an empty FLAC has one track; a failed header read is an Io error; deinterleave allocates fallibly; concealed silence is capped like a decoded packet, and symphonia's uses the damaged packet's own duration when known. - Peaks sum squares in f64, so loud float samples give a finite RMS. - Writing: .aifc maps to AIFF; BWF text is cut at a character boundary; a WAV byte rate past 32 bits is an error; the FileEncoder docs say nothing may follow the sink's position. - README: the Limits list says which codecs go through symphonia and which MP4 edit lists apply. - Tests for each, alias and dither coverage, and a FLAC case in the routing oracle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * craft-audio: review fixes, second round (CodeRabbit on #441) - Every header read is checked: `Native::open`, `native::tracks` and the no-symphonia error read it with `head_checked`. craft-media demuxer errors that are I/O failures (not a file ending early) are returned as `AudioError::Io` instead of sending the file to the other path. - A Matroska track's presented length runs from its first frame plus `CodecDelay` (unclamped, in timestamp ticks with the delay rounded to one) to its end. - symphonia's concealment allocates fallibly, and a damaged first packet is concealed when the demuxer gives its duration. `deinterleave` reserves its channel list fallibly too. - `TrackDecoder::conceal` documents its cap at the decode limit. - Tests: an I/O failure past the first 64 KiB, codec delay with the first block at -20, 0 and 50 ms (checked with `probe`), a seek back after an overshoot, and the loud-sample RMS value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * craft-audio: TrackDecoder::conceal returns a Result Concealment allocates fallibly and fails with TooLarge when the silence would pass the 2^30-sample decode limit, instead of returning less than the packet's length. The native path, the README, the packets example and the doc example pass the error on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * craft-audio: review fixes, third round (CodeRabbit on #441) - Concealed silence is held to the decode limit inside pcm::silence, so symphonia's path checks frames × channels too, and symphonia's decoded audio is copied into fallibly allocated planes. - TrackDecoder::reset forgets the last packet's length. - Without symphonia, a damaged MP4, Matroska or Ogg file (truncated, invalid, no moov) is Malformed instead of Unsupported; a feature the demuxer does not read still names the build. - Tests: an MP4 with no moov in a native-only build, conceal after a reset and past the limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * craft-audio: review fixes, fourth round (CodeRabbit on #441) - A file the native demuxers find damaged is Malformed with symphonia too, when symphonia calls it unsupported; before, an ftyp-only MP4 was Unsupported in symphonia builds. - symphonia's decoded blocks are held to the 2^30-sample decode limit before allocating. - Symph::seek forgets the last packet's length, so a lost packet after a seek is not concealed with the length of one before it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * craft-audio: review fixes, fifth round (CodeRabbit on #441) - The damaged-file test covers Matroska as well as MP4, and fails when either branch of native::damage is removed. - native::damage leaves out Ogg: ogg::open reports no damage (it skips damaged pages and calls a file with none it can read not Ogg), and symphonia reports damaged Ogg as malformed itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * craft-audio: run the ffmpeg oracle tests on ubuntu-26.04 ubuntu-latest's apt ships ffmpeg 6.1.1, which writes MP2 and E-AC-3 in Matroska with ends a few milliseconds early, so the routing tests on the later codec PRs cannot pass on it. ubuntu-26.04 ships ffmpeg 8.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
30 KiB
craft-media
Media containers and bit-level I/O for the Craft apps: an MSB-first bit reader and writer,
clean-room ISO BMFF (MP4), QuickTime (MOV) and Matroska (MKV, WebM) demuxers and muxers, and an Ogg
demuxer, written from the public specifications. No unsafe, no panics on malformed input, builds
for wasm32-unknown-unknown. Dependencies: thiserror (for isobmff), serde (optional).
Codecs are in separate crates that depend on this one: craft-audio for
audio; video codecs will go in a separate crate. What both need lives here: the containers, the
bit I/O, one ByteSource trait, and the shared identifiers
that codec crates use to describe a file and configure a decoder.
Features
| Feature | Module | Enables |
|---|---|---|
bitstream |
craft_media::bitstream |
none |
isobmff |
craft_media::isobmff |
bitstream, thiserror |
matroska |
craft_media::matroska |
none |
ogg |
craft_media::ogg |
none |
| (none) | Container, CodecId, CodecParams, PcmFormat, xiph_lace, ByteSource, ReadSeekSource |
none |
serde |
Serialize/Deserialize on Container, CodecId, CodecParams and PcmFormat |
serde |
default = ["isobmff"], so bitstream and isobmff build. matroska is opt-in: it is about as
much code as isobmff, and most users of MP4/MOV (LightCraft, FigCraft, SoundCraft, EffectCraft)
do not read Matroska. ogg is opt-in as well. The features are independent.
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "<commit>", features = ["matroska"] }
A crate that needs only bit I/O (a codec) uses:
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "<commit>", default-features = false, features = ["bitstream"] }
Boundaries
- In scope: bit-level reading and writing, and container demuxing and muxing (boxes, sample tables, sample entries and codec configuration records) for audio and video tracks.
- Out of scope: codecs. Audio codecs are in
craft-audio; video codecs (H.264, HEVC, ProRes and others) stay in the apps until a video crate exists. Containers only FilmCraft reads (MXF, MPEG-TS/PS, AVI) are not here yet. - Anything both the audio and the video codec crates need belongs here, so the two never depend on each other.
Origin and API
The code comes from FilmCraft's filmcraft-bitstream, filmcraft-isobmff and
filmcraft-matroska crates at
storytold/filmcraft@6c3948a.
Public items keep their names and signatures, so FilmCraft can turn all three crates into
re-exports (pub use craft_media::isobmff::*;), except for the renames below. Our own names use
US spelling; names taken from a specification keep its spelling.
Renamed:
| FilmCraft | craft-media |
|---|---|
matroska::Colour |
matroska::Color |
matroska::VideoInfo::colour |
matroska::VideoInfo::color |
matroska::VideoInfo::colour_space |
matroska::VideoInfo::color_space |
The element IDs element_ids::COLOUR and COLOUR_SPACE and the field
isobmff::VpcConfig::colour_primaries keep the spellings of the Matroska and VP9 specifications.
Output that differs on valid input:
- Matroska lacing applies only to tracks with a
DefaultDuration, and a lace ends at a timestamp gap. Readers space the frames of a lace byDefaultDuration, so FilmCraft's laces moved frames that followed a gap. - The sample budget covers the whole file, so a multi-track file with more than 2²⁶ samples in
total fails with
TooLarge(ISO BMFF: all tracks and fragments; Matroska: all tracks, and at most 2¹⁶ TrackEntries). FilmCraft capped ISO BMFF per track and Matroska not at all. - MOV and MP4 PCM chunks end at 1 MiB, as ffmpeg writes them; FilmCraft wrote each contiguous run of one track as one chunk. The reader splits a PCM chunk above 16 MiB into samples of at most 16 MiB; FilmCraft made it one sample, cut at 4 GiB.
- MOV PCM at 65,536 Hz and above, and unsigned 16-bit PCM, is written as
lpcm(sound description v2), and signed 8-bit PCM astwos. FilmCraft wrote 0 Hz for the former and labeled the signedness wrongly for the latter two.rawunder a sound handler reads as PCM. - Matroska
BlockDurationis rounded to the nearest tick; FilmCraft truncated it. - A Matroska
TimestampScaleabove 2³² is ignored, like a zero scale. matroska::MkvWriterwritesMuxingApp=craft-media, andMuxOptions::default()haswriting_app=craft-media(FilmCraft:filmcraft-matroskaandfilmcraft). Apps setwriting_appto their own name.
Other differences:
bitstream::BitReader::read_ue_plus(from DeckCraft's copy) is new.isobmff::boxesis new: FilmCraft's private box iterator and byte reader, made public (see Box reader).BitReader::peek,BitReader::read_bitsandBitWriter::write_bitsaccept widths above 32 without a debug assertion:peekandread_bitsreturn the low 32 bits of the field (useread_bits_u64for wide fields), andwrite_bitswrites leading zero bits, at most 64 bits in all.bitstream::length_prefixed_nalsreturns the newBitError::InvalidNalLengthSizefor alength_sizeoutside 1 to 4 (as FilmCraft does since filmcraft#2953) and cannot overflow on a hostile length prefix.isobmff::PcmConfig::bytes_per_framesaturates atu32::MAXfor a hostile channel count.matroska: a cluster timestamp abovei64::MAXsaturates ati64::MAX(FilmCraft wraps it to a negative value), and adding a block's relative timestamp saturates instead of overflowing (a debug-build panic in FilmCraft).matroska::Error::TooLargeis new: indexing a file past the limits above returns it.matroska::MuxOptions::max_lace_framesis clamped to 1 to 256; FilmCraft wrote a corrupt frame count above 256.isobmff::Mp4Writer::set_sample_entryreturns an error when the new entry changes the codec, the media type or the PCM frame size, andError::NoSuchTrackfor a bad index. FilmCraft accepted any entry.isobmff::Mp4Writer::write_samplereturns an error when a track would pass 2³² − 1 samples (PCM: frames), the moststszcan count; FilmCraft clamped the count.isobmff::ByteSource,matroska::ByteSourceandogg::ByteSourceare one trait,craft_media::ByteSource(withReadSeekSource, which was Matroska's). A type FilmCraft implements both traits for keeps one of its two impls.isobmff::Track,matroska::Trackandogg::Streamhavecodec_params(), all returning aCodecParams(CodecId::Unknownfor a codec without an identifier). The MP4 one describes the first sample's sample description;codec_params_for(description_index)gives any other, andhas_mixed_descriptions()says whether the samples use more than one.isobmff::CodecConfighascodec_id()andextradata(), andOpusConfig::opus_head().ogg::Codecandogg::Error, and theoggstructs, are#[non_exhaustive].
bitstream
BitReader: MSB-first reader with Exp-Golomb (ue(v),se(v),te(v)), as used by H.264/HEVC/AV1 headers.BitWriter: the matching writer, for encoders and header rewriting.unescape_rbsp/escape_rbsp: remove or insert H.264/HEVC emulation-prevention bytes.annexb_nals/length_prefixed_nals: split Annex-B byte streams and AVCC/HVCC samples into NAL units.crc32: CRC-32 (IEEE), as used by Matroska and PNG.
isobmff
Demuxing
use craft_media::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. A chunk above 16 MiB becomes several samples of at most 16 MiB.
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 |
.mp3/.mp2 |
Mp3: MPEG-1/2 audio of any layer (each frame header names it; Track::codec_params() reports Mp1, Mp2 or Mp3 from the first frame) |
| 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 the whole file (all tracks and
fragments) is capped at 2²⁶ samples.
moov/moof are capped at 1 GiB. Compressed movie headers (cmov) are rejected with
Error::Unsupported.
Muxing
use craft_media::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; a PCM chunk ends at 1 MiB. - 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.
Box reader
isobmff::boxes gives direct access to the box format, for files and boxes the demuxer does not
model: CR3 uuid boxes, HEIF meta items, JPEG 2000 (JP2, JPX, JPM, MJ2) boxes.
use craft_media::isobmff::boxes::{BoxIter, Reader};
for b in BoxIter::new(&bytes) {
let b = b?;
if b.kind == b"uuid" && b.user_type == Some(CANON_UUID) {
for c in BoxIter::within(&bytes, b.payload_range()) { /* CMT1..CMT4, THMB */ }
}
}
BoxIter::new(data)yields the sibling boxes indata;BoxIter::within(data, range)yields those indata[range]with offsets relative todata, so a walker keeps file offsets as it descends. EachBoxRefhaskind,user_type(the 16-byte extended type of auuidbox),offset,header_len,payload,payload_range(),range()andend().- 64-bit
largesizeand size 0 (runs to the end of the range) are handled. A malformed header or a box larger than the range yields oneErrand ends the iteration;allow_truncated()instead yields that last box cut at the end of the range, withtruncatedset. A 32-bit zero terminator (QuickTime) ends the iteration. BoxHeader::parse(buf)reads one header (up toBoxHeader::MAX_LEN= 32 bytes), for scanning a file throughByteSourcewithout loading it.Readeris the bounds-checked big-endian cursor the demuxer uses:u8tou64,i16toi64,f64,u24,fourcc,full_header(version and flags),bytes,skip,restandcount(an entry count checked against the bytes left, before allocating).- The iterator does not recurse. The caller bounds nesting depth and box count.
Limitations
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 color table are not decoded.- Subtitle and text tracks (
tx3g,c608,wvtt) andmp4vshow up asUnknown. - Muxer: one
mdat, no chunk-size or duration limits outside PCM (the caller controls interleaving), nosdtp,sgpd/sbgporcslgoutput, ISO-stylemeta/ilstmetadata is not written, and the fragmented writer has no PCM,sidxormfra.
matroska
A clean-room Matroska (MKV) and WebM demuxer, plus a minimal muxer, written from RFC 8794 (EBML), RFC 9559 (Matroska), the Matroska codec mappings and the WebM container guidelines. No GPL or LGPL code (FFmpeg, libmatroska, mkvtoolnix) was consulted; FFmpeg is a test oracle only.
Demuxing
use craft_media::matroska::{Demuxer, TrackKind, open};
// Sample tables in the same shape as isobmff (any ByteSource: &[u8], Vec<u8>, Arc<[u8]>, File, ...)
let file = std::fs::File::open("clip.mkv")?;
let mkv = open(&file)?;
let v = mkv.track_of_kind(TrackKind::Video).unwrap();
let k = mkv.keyframe_before(v, 2_000_000_000).unwrap(); // sample index
let frame = mkv.read_sample(&file, v, k)?;
// Streaming packets and seeking (from a ByteSource, any Read + Seek, or a byte slice)
let mut d = Demuxer::from_reader(std::io::BufReader::new(std::fs::File::open("clip.webm")?))?;
let sp = d.seek(v, 2_000_000_000)?; // lands on the keyframe with pts <= 2 s
while let Some(p) = d.next_packet()? { // file order, all tracks
// p.track, p.pts (ticks), p.pts_ns, p.duration, p.keyframe, p.data
}
- Open (
open,open_with(src, &OpenOptions { index, verify_crc })): EBML header (DocTypematroskaorwebm), the first Segment, and its level-1 elements: SeekHead (followed for elements after the clusters), Info, Tracks, Cues, Chapters, Tags, Attachments (listed with offset and size; payloads are not read). Withindex: true(the default) every Cluster is scanned, element and block headers only, to build per-trackSampletables (offset,size,pts,duration,keyframe, cluster, block offset, lace index,BlockAdditionallocation) and keyframe lists. Withindex: false,openstops at the first Cluster. - Timestamps: Matroska ticks are
TimestampScalens (usually 1 ms);Track::timebaseis that as a reduced fraction of a second.ptsis in ticks,pts_nsin exact nanoseconds.CodecDelayis subtracted (RFC 9559), as FFmpeg does, so Opus and Vorbis priming gives negative pts. Laced frames getblock ts + i × (BlockDuration or DefaultDuration) / n; when neither is known, later laces repeat the block timestamp and haveduration == 0(Packet::lacetells which). - Keyframes: the SimpleBlock keyframe flag; a BlockGroup is a keyframe when it has no ReferenceBlock.
- Lacing: Xiph, fixed-size and EBML. Unknown sizes: a Segment (live or streamed output) and
Clusters end at the next top-level element. CRC-32: optional verification of level-1
elements and clusters; mismatches go to
MkvFile::crc_errorsand are not fatal. - Damage: an undecodable or oversized element starts a byte scan for the next plausible level-1
element (a Cluster whose first child is Timestamp, or a sized metadata element); a truncated final
block is dropped. Problems are listed in
MkvFile::warnings. - Limits: indexing stops with
Error::TooLargepast 2²⁶ frames in the file, past one frame per file byte (each frame counts its stored size, and at least one byte), or past 2¹⁶ TrackEntries. Level-1 elements read at most the file's length in all, so SeekHead entries that point into one another are skipped with a warning. - Seeking (
Demuxer::seek(track, time_ns)): uses the sample index when present, otherwise Cues (refined by scanning the cued cluster for the exact keyframe), otherwise builds the index by scanning all clusters.Demuxer::keyframe_index(track)returns the keyframe list. - Block additions:
MkvFile::read_sample_additionreturns theBlockAdditionalwithBlockAddID1 of an unlaced block (in WebM, the VP8/VP9 alpha layer). - Tracks: number, UID, type, flags,
DefaultDuration, name, language (and BCP 47), CodecID, CodecPrivate, CodecName,CodecDelay,SeekPreRoll;VideoInfo(pixel and display size, crop, interlacing, stereo, alpha mode,pixel_aspect(),display_rotation()),Color(matrix, range, transfer, primaries, subsampling and siting, MaxCLL/MaxFALL, SMPTE 2086 mastering metadata);AudioInfo(rate, output rate, channels, bit depth);ContentEncodings. - Content encodings: header stripping is undone in every packet and in
read_sample. zlib, bzlib and lzo compression and encryption are reported (Track::frames_readable() == false); packets are then returned as stored andread_samplefails withUnsupported. matroska::ebml(public) has the EBML primitives: variable-length integers, element headers, typed values, CRC-32 and lacing.matroska::element_idslists the element IDs.
Codec mapping (Codec)
| CodecID | Codec |
Payload |
|---|---|---|
V_MPEG4/ISO/AVC |
Avc |
avcC (CodecPrivate) |
V_MPEGH/ISO/HEVC |
Hevc |
hvcC |
V_VP8, V_VP9 |
Vp8, Vp9 |
VP9 CodecPrivate features |
V_AV1 |
Av1 |
av1C |
V_APV |
Apv |
apvC |
V_PRORES |
ProRes |
FourCC; frames get the 8-byte size + icpf header re-added |
V_MJPEG, V_MS/VFW/FOURCC (MJPG) |
Mjpeg |
|
V_MS/VFW/FOURCC (other) |
VfwFourcc |
BITMAPINFOHEADER |
A_AAC, legacy A_AAC/MPEG{2,4}/... |
Aac |
AudioSpecificConfig (synthesized for legacy IDs, SBR included) |
A_OPUS |
Opus |
OpusHead |
A_VORBIS |
Vorbis |
three headers split from the Xiph-laced CodecPrivate |
A_FLAC |
Flac |
fLaC + metadata blocks |
A_PCM/INT/LIT, A_PCM/INT/BIG, A_PCM/FLOAT/IEEE |
Pcm |
bit depth from Audio |
A_AC3, A_EAC3, A_MPEG/L3, A_MPEG/L2, A_MPEG/L1 |
Ac3, Eac3, Mp3, Mp2, Mp1 |
|
S_TEXT/UTF8 |
SubRip |
|
S_TEXT/WEBVTT, D_WEBVTT/* (WebM) |
WebVtt |
WebM blocks keep id\nsettings\npayload |
S_TEXT/ASS, S_TEXT/SSA |
Ass |
script header |
| anything else | Other(id) |
Track::codec_private |
Codec::name() matches FFmpeg's codec_name for these.
Muxing
MkvWriter::new(w: Write + Seek, Vec<TrackSpec>, MuxOptions), then write_frame(track, pts_ns, keyframe, data, duration_ns) per frame, then finish(). It writes the EBML header, Segment,
SeekHead, Info (with Duration), Tracks, Clusters of SimpleBlocks (a new cluster at a video keyframe
after cluster_duration_ns), BlockGroups for frames whose duration differs from the track's
default_duration_ns, optional audio lacing (Xiph, EBML, fixed-size; for tracks with a
default_duration_ns, at most 256 frames, ending at a timestamp gap), and Cues (video keyframes,
or the first frame of each track per cluster). MuxOptions::doc_type = "webm" writes WebM.
CodecPrivate is fixed when the writer is created, so a codec whose configuration arrives with the
first frame (AV1 from rav1e) creates the writer after that frame.
Limitations
- Only the first Segment is read (no linked or chained segments, no ordered-chapter playback).
- zlib, bzlib and lzo compressed and encrypted tracks are detected, but their frames are not
decoded.
EncryptedBlockis ignored. TrackTimestampScale(deprecated) is ignored.- Lace timestamps without any duration information repeat the block timestamp (FFmpeg derives them from codec parsers).
- Muxer: no CRC-32, Chapters, Tags, Attachments, content encodings or block additions.
ogg
A clean-room Ogg demuxer from FilmCraft's filmcraft-ogg (RFC 3533, RFC 7845 Ogg Opus, the Vorbis
I Ogg mapping). open reads every page (CRC-32 checked; damage is skipped by searching for the next
OggS), groups pages by logical stream and assembles packets across pages; codec headers are kept
apart from audio packets. A gap in a stream's page sequence numbers drops the packet the lost page
continued, and a beginning-of-stream page for a serial already seen starts a new stream (the next
link of a chained file), unless it carries on the page sequence of a stream with no end-of-stream
page yet. Damage recovery may read bytes again up to twice the file's length plus 1 MiB, so a file
is read at most about three times over; the packets kept and the header packets of a stream are
bounded too, with a warning when a bound is reached.
OpusTiming gives each Opus packet's start, the pre-skip and the end trimming of the final page.
No muxer.
Containers, codecs and codec parameters
Always built, with no dependencies, so codec crates share them:
Container: the file format (Wav,Aiff,Flac,Mp4,QuickTime,Matroska,WebM,Ogg,Caf,Adts,MpegAudio,Ac3), withname().CodecId: audio and video codecs (Aac,HeAac,Opus,Eac3,Speex, …,H264,Hevc,Av1,ProRes,Dnxhd,Apv,Theora, …,Unknown), withname(),is_audio(),is_video().PcmFormat: the layout of uncompressed samples, built withPcmFormat::int(bits, signed, big_endian)orPcmFormat::float(bits, big_endian).CodecParams: what a decoder needs about a track, whatever its container:codec,extradata,tag(the container's four-character code, such as a ProRes profile), the audio fieldssample_rate,channelsandpcm(PcmFormat), and the video fieldswidthandheight. Built withCodecParams::new(codec)and thewith_*methods, or by the demuxers'codec_params(), which every container returns in the same form.extradatafollows Matroska'sCodecPrivate, as ffmpeg's extradata does: theAudioSpecificConfigfor AAC, theOpusHeadfor Opus (MP4'sdOpsis converted), Xiph-laced headers for Vorbis (xiph_lace,Nonefor more than 256 packets),fLaC+ metadata blocks for FLAC, theavcC/hvcC/av1C/apvCrecord for video.
All are #[non_exhaustive]: new formats and fields are minor changes.
ByteSource
craft_media::ByteSource (len, read_at(offset, buf)) is what every demuxer reads from. It is
always built, so a crate that uses only bitstream can name it too. isobmff::ByteSource,
matroska::ByteSource and ogg::ByteSource are the same trait. It is implemented for [u8],
Vec<u8>, &T, Arc<T>, Box<T> and std::fs::File (not on wasm). ReadSeekSource adapts any
Read + Seek; it is Send + Sync when the reader is Send. Implement the trait once for a
custom source (a web Blob, a network range reader) to use every container with it.
Tests
- Unit tests cover the bit reader and writer, the box reader (64-bit, size-0,
uuid, truncated and hostile sizes), hand-builtstbledge cases, codec configuration round trips and allocation guards. For Matroska: EBML integers, typed values, CRC-32, every lacing mode, codec mapping, a hand-built file (unknown-size Segment and Clusters, header stripping, BlockGroups, block additions, color and mastering metadata, Chapters, Tags, Attachments, CRC-32, Cue-less seek, hostile cluster timestamps) and mux-demux round trips. For Ogg (src/ogg): page parsing, CRC, packets across pages, damage resync and its read budget, lost pages, chained links and stray beginning-of-stream flags, the packet and header caps, Opus timing (end trimming, hostile granules) and the codec parameters of each codec. tests/roundtrip.rs: property tests that write random multi-track files (progressive and fragmented) and read them back.tests/oracle_demux.rsandtests/oracle_mux.rscompare the demuxer and muxer with ffprobe and ffmpeg on generated fixtures intarget/fixtures/isobmff/($CRAFT_MEDIA_FIXTURES_DIRoverrides it). Fixtures are never committed.tests/oracle_matroska.rscompares every packet (stream, size, keyframe flag, pts, duration), codec names, time bases, dimensions and audio parameters with ffprobe on fixtures intarget/fixtures/matroska/: H.264+AAC, VP9+Opus WebM, FLAC, Vorbis, SRT+ASS, WebVTT WebM, Cues at the front, a streamed file without Cues, HEVC with HDR color, ProRes+PCM, MJPEG+AC-3, AV1. Seeks must land on the latest keyframe at or before the target. The muxer's laced output is read back by ffprobe. A damaged file must resync.tests/fuzz.rsandtests/fuzz_matroska.rs: deterministic mutation fuzzing; nothing may panic.fuzz.rsalso walks every box of a mutated file as a container and checks that each payload range stays inside its parent.
ffmpeg and ffprobe are test oracles only, never linked. The helpers are in craft-media-testkit
(an unpublished crate, shared with craft-audio and the video crate to come). They are looked up in
$CRAFT_MEDIA_FFMPEG / $CRAFT_MEDIA_FFPROBE, then PATH, then common install directories. When
a tool is missing, the oracle tests print SKIPPED and pass; CRAFT_MEDIA_REQUIRE_ORACLES=1 turns
a skip into a failure. CI (.github/workflows/media.yml) sets it and installs Ubuntu's ffmpeg.
craft-audio's oracle tests use the same variables. Generate every fixture ahead of time with
cargo test -p craft-media --all-features -- --ignored generate_fixtures.