Recursive AI

Matroska and WebM: reading, and how it is checked

All onemedia docs

How RecursiveAI.OneMedia.Matroska reads these files and how the result is compared with other implementations. Specifications: RFC 8794 (EBML), RFC 9559 (Matroska); section numbers are in the code comments. WebM is Matroska with DocType webm; both go through the same reader.

Layers

LayerTypesNotes
EBML primitivesEbml, EbmlCrc32Variable-length IDs and sizes (RFC 8794 §4), integers, floats (decoded with integer arithmetic), CRC-32.
StructureEbmlDocument, EbmlElement (an IStructureNode)Lazy tree of byte ranges. Children are read on first use; payloads are read on request and never kept.
Format modelMatroskaHeader, MatroskaInfo, MatroskaTrackEntry and friends, MatroskaSeekEntry, MatroskaCuePointDecoded from the element payloads by tolerant in-memory parsers. Chapters, Tags and Attachments are kept as ranges.
Neutral demuxMatroskaDemuxer (seekable and forward-only), MatroskaAsyncDemuxer (ranged), MatroskaFormatOne async-first reader drives both; the synchronous demuxer completes its ValueTasks synchronously.

Reading rules

  • Open reads the EBML header, the Segment header, then the SeekHead (and chained SeekHeads) and follows it to Info, Tracks and Cues. With no SeekHead the reader walks the Segment's children; it stops at the first Cluster once Tracks has been seen. When Tracks only appears after the Clusters and there is no SeekHead, it walks past every Cluster with one 512-byte read of each Cluster header: this is the documented cost of that layout (see "Open cost" below). A forward-only source needs Tracks before the first Cluster (MediaFormatException, kind InvalidOrder).
  • Unknown sizes are allowed for Segment and Cluster only (RFC 8794 §6.2). An unknown-size Cluster ends at the next element that belongs to the Segment level; an unknown-size element anywhere else is a defect and is cut at its parent's end.
  • Time. TimestampScale that divides 10⁹ gives a track time base of 1/(10⁹ / scale); otherwise ticks are nanoseconds (units × scale, exact). Float Duration and SamplingFrequency are converted with integer arithmetic. No floating point reaches a packet time.
  • CodecDelay is subtracted from block timestamps (RFC 9559 §11.2 Opus mapping) and also exposed exactly as the track's priming. A block's DiscardPadding is attached to the packet (positive: the end of the last frame of the block; negative: the start of the first frame).
  • Duration of a packet: BlockDuration, else DefaultDuration (× frames in the lace), else unknown. A lace's total is BlockDuration or n × DefaultDuration; frames are split by cumulative floor so the parts add up to the total.
  • DTS is unknown for video (Matroska stores presentation times only) and equals the PTS for audio, subtitles and data.
  • Keyframe is the SimpleBlock flag, or a BlockGroup without ReferenceBlock. The invisible flag becomes Discard, discardable becomes Disposable.
  • Source range is the frame's bytes in the file; for laced blocks, the frame's own bytes. When header stripping (ContentCompAlgo 3) or zlib (ContentCompAlgo 0) is in use, the packet is delivered from a buffer (the stripped prefix put back, or the frame inflated with the BCL's ZLibStream, never past MaxPacketBytes) and the source range still names the stored bytes. A frame that is not valid zlib is delivered as stored with an InvalidValue warning. Other compression (bzlib, lzo) and several stacked encodings are reported as an Unsupported defect and the frames are delivered as stored. Undoing content compression is container framing, not sample decoding.
  • Encryption (ContentEncryption) marks the track IsProtected; no key handling.
  • Seeking by time goes to the Cues entry at or before (or after, for NextKeyframe) the target, using the first enabled video track that has cues, otherwise the first track with cues. Without Cues the reader walks Cluster headers (one small read per Cluster) from the start; it lands on a Cluster start.
  • Resync after damage. When a header under the Segment or inside a Cluster is not valid EBML, or a size runs past its parent, the reader scans forward for the next Segment-level element whose header and size are sound, records a defect with the offset, and continues. The lazy tree does the same for junk between the Segment's children.

Defects

Every defect carries the byte offset of the element concerned. Kinds used (the message names the element):

KindSeverityWhen
InvalidSizeErrorA header is not valid EBML; an unknown size where not allowed.
SizeExceedsParent / SizeExceedsFileError / WarningAn element is longer than what contains it, or than the file; it is cut there.
TruncatedError / WarningThe data ends inside an element or a block; a partial block is dropped.
UnaccountedBytesErrorBytes that are not an element, skipped to the next sound element; or nothing sound follows.
ChecksumMismatchWarningA CRC-32 element does not match its siblings' bytes.
InvalidValueWarningAn integer or float element that cannot be read; TimestampScale 0; a negative lace size. The default is used.
MissingRequiredError / WarningA TrackEntry without TrackNumber, a CuePoint without CueTime, a BlockGroup without Block, a Segment without Tracks (throws).
DuplicateWarningTwo tracks with one TrackNumber; blocks go to the first.
InconsistentWarningA block for an undeclared track (skipped); a SeekHead entry that points at another element; a lace whose sizes do not add up.
InvalidOrderErrorNo Segment after the header (scanned for); first Cluster before Tracks on a forward-only source (throws).
UnsupportedWarningUnknown DocType, a higher DocTypeReadVersion, frame compression this reader does not undo.

Damaged input never raises anything but MediaException types. Truncation at every byte offset and four byte-value corruptions at every offset of a crafted file are unit tests; a truncation sweep over a 13 312-byte WebM (every cut point) gave 9 586 clean opens and 3 727 typed exceptions, no others.

Differential method

tools/OneMedia.MatroskaDiff (a test tool; not shipped) compares our packets with `ffprobe -show_packets -show_entries packet=stream_index,pts,dts,duration,size,pos,flagsand the element structure and track headers withmkvinfo`. The oracles run as released binaries only (versions in the results table below). tools/matroska/run-matroska-diff.sh runs the whole set; tools/matroska/make-fixtures.sh writes the synthetic fixtures (ffmpeg- and mkvmerge-written files from generated input) and tools/matroska/run-matroska-big.sh the large-file checks.

For every stream the packets are compared in order: size, payload checksum (ffprobe's -show_data_hash adler32 against the Adler-32 of the bytes we deliver), PTS, duration, key flag, and position. Each difference is in an explained class or it is unexplained; the tool exits non-zero on any unexplained difference.

Explained classes

"Follows the spec" says which side matches RFC 9559.

ClassWhat differsFollows the spec
duration-from-codecffprobe fills in a duration the file does not store (an audio packet without BlockDuration or DefaultDuration) by looking at the codec's frame length. Ours stays unknown.Both: the file stores none; ffprobe derives one. Deriving it needs the codec layer.
lace-shared-ptsFrames of a lace with neither BlockDuration nor DefaultDuration: ours all carry the block's timestamp (unknown duration); ffprobe spaces them by codec frame length.Ours (§10.2: a lace has one timestamp).
inexact-timebaseTimestampScale does not divide 10⁹: ours are exact nanosecond ticks, ffprobe rounds to an integer time base, so values are within one tick.Ours (exact).
last-block-duration-differsThe last block's BlockDuration is shorter than the codec's frame; ffprobe reports the frame length, ours the stored value.Ours (the file's value).
realvideo-timestampsRealVideo (V_REAL/RV40): ffprobe takes frame times from the RealVideo stream (blocks at 80 and 81 ms are reported at 40 and 80); ours is the Block time.Ours (the container states it; the stream's own times are codec work).
realaudio-deinterleaveRealAudio (cook, ra) blocks hold interleaved sub-packets; ffprobe de-interleaves them into the codec frames (280 bytes where the block is 1400). Ours delivers the stored block.Ours (the bytes in the file); de-interleaving is codec work.
webvtt-block-repackedThe WebVTT-in-Matroska block (BlockAdditions with settings and the cue text): ffprobe repacks it as a webvtt packet. Ours is the block's bytes.Ours (the bytes in the file).
pos-queuepos for packets ffprobe holds back when it interleaves audio with video: it reports an earlier position than the block's own. For video, pos equals the Block's start, which is the frame's SourceRange.Offset minus the 4-byte block header (one-byte track number).The position of the block in the file is ours; ffprobe's value is a queue artefact.

mkvinfo

mkvinfo (-a -P -z -v -v) lists elements with offset, header size and data size. Every element mkvinfo lists is found in our tree at the same offset with the same sizes (differing=0); the tree may list more (it also lists children that mkvinfo hides at the verbosity used). On the damaged published files the lists differ in one way: test7 contains an element at 451451 whose size runs past its cluster. The tree cuts it at the cluster's end and keeps its bytes as a range; mkvinfo descends into the cut-off payload and lists the elements it sees there, which we do not. Track headers (number, UID, type, codec, language, flags, default duration, video/audio fields) are compared field by field.

Open cost (I/O)

LayoutReads at open
SeekHead + Cues (any writer that wrote them)The first window of the file (32 KiB); Cues are read on the first seek.
Live (unknown sizes, no SeekHead, Tracks before Clusters)The first window.
No SeekHead, Tracks after the ClustersOne 512-byte read per Cluster header until Tracks is found: bytes = clusters × 512. Documented cost; there is no way to find a trailing Tracks without walking.

Allocations and memory

TryReadPacket allocates nothing in Release builds: payloads are read into a reused buffer (initial 1 MiB, doubled only when a larger block arrives, bounded by MediaLimits.MaxPacketBytes). Debug builds' async state machines allocate, so the zero-allocation test is skipped there.

Open-time ToArray() copies are of CodecPrivate, the SegmentUUID, compression settings and the header-stripping prefix. Each is a slice of a master element (Info, Tracks, SeekHead, Cues) that was itself checked against MediaLimits.MaxAllocationBytes before it was read, so the copies are bounded by that limit and by the number of tracks: the memory taken at open does not grow with the size of the file.