Fragmented MP4 explained: how moof and trun replace the sample tables
In the ISOBMFF box structure post (opens in new tab) we ended on a cliffhanger: a classic MP4 cannot be finished until the very last sample is written, because the whole index lives in one moov box. Live streams cannot wait for the end, so they use "a fragmented variant that interleaves many small index-and-data pairs". This is the post about that variant. It is the machinery inside every DASH and HLS media segment you will ever open, and if you have ever wondered what exactly sits inside a moof, or how a trun tells a player where each frame lives, this is the field-by-field walkthrough.
We will use a real file throughout: a 6-second test clip (H.264 video at 25 fps plus AAC audio) written by ffmpeg as a fragmented MP4, cut into three 2-second fragments. Every number below is a real value from its boxes, as decoded by the VTCLab Media Analyzer (opens in new tab).
From one big index to many small ones
A classic MP4 splits cleanly in two: moov describes, mdat holds. Fragmented MP4 (fMP4 for short) keeps that principle but repeats it: after a stripped-down moov, the file is a sequence of fragments, each a moof mini index followed by its own mdat of media.
Here is our demo file at the top level, box by box:
Widths in that strip are schematic, because the honest proportion would be unreadable: each moof index is about a kilobyte against an mdat of roughly 200 KB, so the per-fragment index is about half a percent of the media it points into. The three chunks come out near-equal because the clip was cut into even two-second slices, and everything that is not media (the ftyp, the stripped moov, and the trailing mfra) adds up to about 1.4 KB across the whole file.
The moov is still there, and it still holds the trak tree, the timescale, and the codec configuration (for H.264, the SPS and PPS inside avcC). What is gone is the payload of the sample tables: every stbl table (stsz, stts, stco, and friends) is present but has zero entries. Two things replace them:
mvex(Movie Extends Box), a new child ofmoov, warns the reader that the movie does not end here: samples will arrive in fragments. Without it, a player would read the empty tables and conclude the movie has no samples at all. Inside, onetrexper track can set movie-wide default sample values; our file sets them all to zero and defers everything to the fragments.moofboxes carry the actual sample metadata, a fragment at a time.
You can see the whole handoff in one screen of the analyzer's packet list: the tail of the empty sample tables (stsz, stco), then mvex with a trex whose defaults are all zero, then the moov ends and the first moof begins at offset 0x4eb, carrying the tfhd, tfdt, and trun for each track, and finally the mdat whose samples now resolve into real AVC slices:
This layout is why fMP4 exists. An encoder can write fragment 1 the moment its two seconds are captured, then fragment 2, and so on: no waiting for the end, nothing to go back and rewrite. If the recording dies mid-stream, everything up to the last complete fragment still plays. And because each fragment is self-contained, a server can hand fragments to a player as they are produced, which is precisely what live streaming needs.
Inside a moof
Open any moof and the structure is always the same: one mfhd header, then one traf (Track Fragment Box) per track that has samples in this fragment.
mfhd (Movie Fragment Header) holds a single field, sequence_number: 1 for our first fragment, 2 for the second, 3 for the third. Fragments count up, so a reader can notice a missing or reordered one.
The interesting boxes live inside the traf. There are three of them, and they answer the same questions stbl used to answer, just scoped to one fragment.
tfhd: the fragment's defaults
Here is the video track's tfhd (Track Fragment Header) from fragment 1, exactly as the analyzer decodes it:
The track_ID links back to the trak defined in moov. The rest are defaults for every sample in this fragment: each one lasts 512 ticks (the track timescale is 12800 ticks per second, so 512 ticks is exactly 1/25 s, one frame), a sample is 8939 bytes unless stated otherwise, and its flags are 0x1010000, which decodes to "depends on other frames, not a keyframe". A perfectly ordinary P or B frame, in other words.
The flags field of tfhd itself deserves a look: it is a menu of which optional fields follow. Note default-base-is-moof = 1: it declares that byte offsets in this fragment count from the first byte of the moof. Early fMP4 files instead stored base_data_offset, an absolute position in the file, which broke as soon as a segment was moved or concatenated with others; relative-to-moof addressing is what makes a fragment relocatable, and CMAF requires it.
tfdt: where the fragment sits in time
tfdt states the decode time of the fragment's first sample, in the track timescale. Fragment 1 starts at 0. Here is fragment 2, decoded: its mfhd has counted up to sequence_number = 2, and its tfdt reads 25600, which is exactly 50 samples times 512 ticks, or 2.0 seconds.
The value looks redundant (you could sum the durations of every earlier fragment) until you remember that a player may never see the earlier fragments. Join a live stream in the middle, or seek across a DASH timeline, and the tfdt is what places the fragment you just fetched at the right spot on the clock, with no other fragment in hand.
trun: the sample table, reborn
trun (Track Run Box) is the star of the show: the actual per-sample table. Here is fragment 1's video trun, with the first of its fifty sample rows:
Like tfhd, the flags are a column selector: this table stores a size and a composition time offset per sample, and nothing else. There is no duration column (every sample inherits 512 from tfhd) and no flags column (every sample inherits 0x1010000, except the first, which gets the explicit first_sample_flags override).
Everything a player needs falls out of small arithmetic:
- Where are the bytes? The
moofstarts at offset 1259 anddefault-base-is-moofis set, so the first sample begins at 1259 + 1260 = byte 2519, which is precisely the first payload byte of themdat. Each next sample starts where the previous one ended: sample 1 at 2519 + 8939 = 11458, sample 2 at 11458 + 4474 = 15932. Nostco, nostsc; onedata_offsetplus a list of sizes replaces both. - When do they play? Decode times accumulate from the
tfdt: 0, 512, 1024, ... The composition offsets then shift display times exactly likecttsdid: sample 0 is shown at tick 1024, sample 1 at 512 + 1536 = 2048, sample 2 at 1024 + 512 = 1536. Display order 0, 2, 1: the B-frame reordering, visible right there in the table. - Which is the keyframe?
first_sample_flags = 0x2000000decodes to "depends on nothing", a sync sample. One keyframe opening the fragment, 49 dependent frames behind it: the classic closed-GOP fragment, and the reason a player can start decoding at any fragment boundary.
The sizes tell the same story: sample 0 is 8939 bytes (the I frame), everything after is 3 to 4 KB of P and B frames. And this is also where default_sample_size = 8939 in the tfhd came from: ffmpeg simply used the first sample's size as the default, then overrode every sample explicitly anyway. Encoders fill these boxes with whatever redundancy is convenient; readers just follow the rules.
The defaults ladder
By now you have seen the pattern: the same sample property can be stated in up to four places.
A reader takes the most specific value available and falls back one level for anything missing. That is the entire trick behind fMP4's compactness: a trun only stores the columns that actually vary. Our video trun describes 50 samples in 424 bytes. The audio track needs even less: AAC frames are all sync samples of similar size, so its tfhd sets default_sample_flags = 0x2000000 once and its trun is little more than a size list.
Seeking without an index: mfra
One thing did get lost in the move from stbl: the global stss keyframe table, and with it cheap seeking. In a pure live stream there is nothing to be done (the future does not exist yet), but for a finished fragmented file the spec offers mfra (Movie Fragment Random Access), an optional box at the very end. Inside, a tfra per track lists sync samples and, crucially, the file offset of the moof that contains each one. Our file's video tfra has three entries:
Three keyframes, three fragments, and the presentation time of each (note 1024, 26624, 52224: the composition offset of 1024 ticks is included). A player seeking to second 4 jumps straight to the moof at 428212. Without mfra, seeking a fragmented file means walking moof to moof, which is exactly what streaming players avoid by getting the segment map from the manifest instead.
From fragments to segments
Everything above is what makes DASH and HLS possible. Slice the file at fragment boundaries and each piece stands alone:
ftyp+ the emptymoovbecome the init segment: all codec config, no samples.- Each
moof+mdatpair becomes a media segment, usually prefixed with a smallstyp(segment type) box and optionally asidxindex. Thetfdtplaces it on the timeline,default-base-is-moofkeeps its offsets valid wherever it lands, and themfhdsequence number keeps order.
That split of responsibilities is exactly why a lone .m4s file parses as "Unknown Stream" and why the analyzer's Advanced tab wants the init segment in front. Low-latency streaming pushes the same idea further: CMAF chunked encoding simply makes the moof/mdat pairs smaller (down to one frame each), so a segment can be delivered while it is still being encoded.
Make one and look inside
You can reproduce the exact file from this post with ffmpeg:
ffmpeg -f lavfi -i testsrc2=size=640x360:rate=25:duration=6 \
-f lavfi -i sine=frequency=440:duration=6 \
-c:v libx264 -g 50 -bf 2 -c:a aac \
-movflags +frag_keyframe+empty_moov+default_base_moof \
frag-demo.mp4Drop it into the analyzer (opens in new tab) and walk the boxes: the empty stbl tables in moov, the trex full of zeros, then each moof with its tfhd defaults, its tfdt stepping forward 25600 ticks at a time, and its trun table. Check the flags columns against the values above, and try the byte math yourself: moof offset plus data_offset should always land on the first sample in the mdat. Once you have done that walk once, no .m4s segment, CMAF chunk, or live recording will look mysterious again.
Comments