androidx / androidx/media

`FragmentedMp4Extractor` throws "Top bit not zero" when the `tfdt` baseMediaDecodeTime encodes a negative decode time

Open
#3,412 0 comments 0 reactions 1 assignee View on GitHub

@rohitjoins is already working on this.

Since Sep 15, 2026.

bug needs triage
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
  1. 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
    
  2. Serve the directory (e.g. python3 -m http.server 8000) and play http://<host>:8000/stream.m3u8.

  3. 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 bugreport to android-media-github@google.com after filing this issue.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.