`FragmentedMp4Extractor` throws "Top bit not zero" when the `tfdt` baseMediaDecodeTime encodes a negative decode time
@rohitjoins is already working on this.
Since Sep 15, 2026.
- Dominant language
- Java
- Stars
- 3k
- Forks
- 955
- Avg merge
- 12d 14h
- Merged PRs (30d)
- 2
Description
Version
Media3 1.11.1
More version details
Also present on main at 7cbf15eca8cf034f4ca3677e997bb02761a4726b: parseTfdt still reads the version 1 value with readUnsignedLongToLong().
Devices that reproduce the issue
- Android TV (Waydroid, LineageOS 23.0 / Android TV 16, x86_64), in Jellyfin for Android TV, which uses Media3 1.11.1
It does not depend on the device: feeding the attached init.mp4 followed by seg0.mp4 straight into FragmentedMp4Extractor on the JVM throws the same exception.
Devices that do not reproduce the issue
No response
Reproducible in the demo app?
Not tested
Reproduction steps
-
Use the attached stream, or generate it with FFmpeg (12 s, H.264 + Opus):
ffmpeg -f lavfi -i testsrc2=size=320x240:rate=24 \ -f lavfi -i sine=frequency=440:sample_rate=48000 -t 12 \ -c:v libx264 -preset ultrafast -g 48 -c:a libopus -b:a 96k \ -f matroska -y source.mkv ffmpeg -i source.mkv -map 0:0 -map 0:1 -c:v copy -c:a copy \ -copyts -avoid_negative_ts disabled \ -f hls -hls_time 6 -hls_segment_type fmp4 \ -hls_fmp4_init_filename init.mp4 -hls_segment_filename 'seg%d.mp4' \ -hls_playlist_type vod -hls_list_size 0 \ -hls_segment_options movflags=+frag_discont \ stream.m3u8 -
Serve the directory (e.g.
python3 -m http.server 8000) and playhttp://<host>:8000/stream.m3u8. -
Playback fails at the first media segment.
The tfdt boxes can be checked without a player:
xxd -p -c 100000000 seg0.mp4 | grep -o '74666474.\{24\}'
74666474010000000000000000000000 # video: version 1, baseMediaDecodeTime 0
7466647401000000fffffffffffffeb0 # audio: version 1, baseMediaDecodeTime 2^64 - 336
Replacing -c:a libopus with FFmpeg's built-in -c:a aac gives fffffffffffffc10 (2^64 − 1008) and the same failure.
Expected result
The stream plays. The audio track's first decode time is read as −336 in its 48000 Hz timescale (−7 ms, the Opus pre-roll), as FFmpeg, Chromium and Firefox do (see below).
Actual result
androidx.media3.exoplayer.ExoPlaybackException: Source error
at androidx.media3.exoplayer.ExoPlayerImplInternal.handleIoException(ExoPlayerImplInternal.java:1021)
at androidx.media3.exoplayer.ExoPlayerImplInternal.handleMessage(ExoPlayerImplInternal.java:997)
...
Caused by: androidx.media3.exoplayer.upstream.Loader$UnexpectedLoaderException: Unexpected IllegalStateException: Top bit not zero: -336
at androidx.media3.exoplayer.upstream.Loader$LoadTask.run(Loader.java:474)
...
Caused by: java.lang.IllegalStateException: Top bit not zero: -336
at androidx.media3.common.util.ParsableByteArray.readUnsignedLongToLong(ParsableByteArray.java:543)
at androidx.media3.extractor.mp4.FragmentedMp4Extractor.parseTfdt(FragmentedMp4Extractor.java:1333)
at androidx.media3.extractor.mp4.FragmentedMp4Extractor.parseTraf(FragmentedMp4Extractor.java:1125)
at androidx.media3.extractor.mp4.FragmentedMp4Extractor.parseMoof(FragmentedMp4Extractor.java:1099)
at androidx.media3.extractor.mp4.FragmentedMp4Extractor.onMoofContainerAtomRead(FragmentedMp4Extractor.java:926)
at androidx.media3.extractor.mp4.FragmentedMp4Extractor.onContainerAtomRead(FragmentedMp4Extractor.java:794)
at androidx.media3.extractor.mp4.FragmentedMp4Extractor.processAtomEnded(FragmentedMp4Extractor.java:763)
at androidx.media3.extractor.mp4.FragmentedMp4Extractor.readAtomPayload(FragmentedMp4Extractor.java:758)
at androidx.media3.extractor.mp4.FragmentedMp4Extractor.read(FragmentedMp4Extractor.java:591)
at androidx.media3.exoplayer.hls.BundledHlsMediaChunkExtractor.read(BundledHlsMediaChunkExtractor.java:102)
at androidx.media3.exoplayer.hls.HlsMediaChunk.feedDataToExtractor(HlsMediaChunk.java:542)
at androidx.media3.exoplayer.hls.HlsMediaChunk.loadMedia(HlsMediaChunk.java:506)
at androidx.media3.exoplayer.hls.HlsMediaChunk.load(HlsMediaChunk.java:450)
at androidx.media3.exoplayer.upstream.Loader$LoadTask.run(Loader.java:453)
Running FragmentedMp4Extractor directly on the attached init.mp4 + seg0.mp4 throws the same Top bit not zero: -336 from the same frames. With FLAG_WORKAROUND_IGNORE_TFDT_BOX set, both the Opus and the AAC stream parse to the end, so the tfdt read is the only thing that fails.
Cause
parseTfdt reads version 1 through readUnsignedLongToLong(), which throws whenever the most significant bit is set.
FFmpeg's mov muxer writes the first audio packet's DTS into tfdt when movflags=+frag_discont is used with -avoid_negative_ts disabled. With codec pre-roll that DTS is negative, so it lands in the box as 2^64 − N. Jellyfin uses exactly these flags for its HLS fMP4 output and considers the encoding intentional (jellyfin/jellyfin#18049).
Other parsers accept these values
| Parser | Handling of 2^64 − N |
|---|---|
| FFmpeg demuxer | stores it in int64_t, giving −N (mov.c#L6158, #L6177) |
| Chromium (MSE) | reads uint64_t, uses it as int64_t run_start_dts, giving −N (box_definitions.h#652, track_run_iterator.cc#335) |
| Firefox | explicit static_cast<int64_t>, added after Firefox 149 started rejecting these streams (D292537, bug 2026875) |
| hls.js | logs a warning and ignores the value (mp4-tools.ts#L1260-L1266, #5303) |
| Media3 | throws |
Firefox's fix describes the case:
Some encoders store AAC pre-roll delay as 2^64-N (e.g. 2^64-2048), which is valid per ISO 14496-12 but exceeds INT64_MAX. The static_cast gives a small negative value (-N) that the existing edit-list mechanism treats as pre-roll, keeping the presentation timestamps correct.
hls.js is less generous ("Negative track fragment decode times are invalid, but they exist in the wild"), but still does not fail playback.
Possible fix
Read the version 1 value as signed in parseTfdt only:
private static long parseTfdt(ParsableByteArray tfdt) {
tfdt.setPosition(Mp4Box.HEADER_SIZE);
int fullAtom = tfdt.readInt();
int version = BoxParser.parseFullBoxVersion(fullAtom);
- return version == 1 ? tfdt.readUnsignedLongToLong() : tfdt.readUnsignedInt();
+ return version == 1 ? tfdt.readLong() : tfdt.readUnsignedInt();
}
Removing the check from readUnsignedLongToLong() itself would be too broad: FragmentedMp4Extractor alone calls it in ten places, including 64-bit atom sizes (L585), tfhd base data offsets (L1250) and sidx/mfra offsets (L1575-L1576, L2141-L2142), where a set top bit really does mean corrupt data. #1819 was handled the same way, at the call site in BoxParser.parseMdhd (457bc55).
The value then goes through signed arithmetic (L1397: cumulativeTime + sampleCompositionTimeOffset - edtsOffset), which already produces negative presentation times through the edit-list offset. I have not built or tested this change.
Media
Attached media3-negative-tfdt-repro.zip: stream.m3u8, init.mp4, seg0.mp4, seg1.mp4. It is the 12 s H.264 + Opus stream from the reproduction steps, generated with FFmpeg 9.0.1. The audio tfdt in seg0.mp4 is fffffffffffffeb0.
media3-negative-tfdt-repro.zip
Bug Report
- You will email the zip file produced by
adb bugreportto android-media-github@google.com after filing this issue.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.