bitstream: decode short Exp-Golomb codes with one peek, write ue(v) in one call (#517)
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, written from the public specifications. No unsafe, no panics on malformed input, builds
for wasm32-unknown-unknown. Dependencies: thiserror (for isobmff), serde (optional).
Codecs go in separate crates that depend on this one: craft-audio for audio (in a follow-up),
and a video codec crate later. 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 |
| (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. 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::ByteSourceandmatroska::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::Trackandmatroska::Trackhavecodec_params(), both 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().
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.
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 and
matroska::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. 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.