For some MP4 files, mp4box.js (the JavaScript port of GPAC's MP4Box) loses all samples — neither video nor audio callbacks fire. The exact conditions that trigger this are not fully understood yet, but the bug is demonstrably not in the file format or in the GPAC toolchain: the native MP4Box CLI from the same project extracts all 44 058 audio samples and 34 527 video samples from the same file without any issue.
onReady fires correctly in mp4box.js and reports the correct sample counts / durations, so the bug is somewhere in the sample-delivery path between start() and the onSamples callback — likely a corner case in how the JavaScript port handles the elst edit list or the mp4a.6b codec.
The same file plays back correctly in <video>, VLC, ffprobe, and the native MP4Box CLI.
Environment
mp4box (npm): tested on 2.3.0 and 2.4.1 — both reproduce
gpac (native): 26.02 — extracts the same file successfully
- Runtime: Node 26.0.0 (reproduced identically in headless Chrome 149 via
the browser bundle)
- File: 175 MB MP4 produced by FFmpeg 4.1 (
Lavf58.29.100)
- 1920×1080 H.264 High@4.0, 30 fps, 34 527 frames
- MP3 audio in MP4 container (
mp4a.6b, 44.1 kHz stereo, 44 058 frames)
File metadata from ffprobe
format_name=mov,mp4,m4a,3gp,3g2,mj2
encoder=Lavf58.29.100
duration=1150.900000
size=175552388
Stream #0 (video): codec=avc1.640028, 1920x1080, 34527 frames, duration_ts=17677824
Stream #1 (audio): codec=mp3 (mp4a.6b), 44100 Hz stereo, 44058 frames, duration_ts=50753676
Native MP4Box -info (control)
# Movie Info - 2 tracks - TimeScale 1000
Duration 00:19:10.903 (recomputed 00:19:10.900)
Fragmented: no
# Track 1 Info - ID 1 - TimeScale 15360
Media Duration 00:19:10.900
Track has 1 edits: track duration is 00:19:10.900
Media Samples: 34527 - CFR 30/sec
Media Type: vide:avc1
# Track 2 Info - ID 2 - TimeScale 44100
Media Duration 00:19:10.902
Track has 1 edits: track duration is 00:19:10.877
Media Samples: 44058 - CFR 38.281250/sec
Media Type: soun:mp4a
MPEG-1 Audio - 2 Channel(s) - SampleRate 44100 - Layer 3
RFC6381 Codec Parameters: mp4a.6B
The native CLI sees all 34 527 video samples and 44 058 audio samples, applies the elst edit list, and reports the file as Fragmented: no.
So the file is well-formed and extractable by the reference implementation.
Native MP4Box -raw 2 file.mp4 -out audio.m4a (control)
Exporting MPEG-1 Audio - SampleRate 44100 2 channels 16 bits per sample
Exporting: |====================| (100/100)
This writes a valid 18 MB .m4a file with the full audio stream, confirming that the file is parseable end-to-end by the reference implementation. The same call through mp4box.js returns zero samples.
info.tracks[].edits (from onReady in mp4box.js)
video (id=1): {
segment_duration: 1150900,
media_time: 1024, // <-- non-zero
media_rate_integer: 1,
media_rate_fraction: 0
}
audio (id=2): {
segment_duration: 1150877,
media_time: 1105, // <-- non-zero
media_rate_integer: 1,
media_rate_fraction: 0
}
The media_time != 0 on both tracks looks like a leftover from the FFmpeg 4.1 muxer (modern FFmpeg ≥5 writes media_time == 0 or no
elst at all). Files produced this way are unusual but standards-compliant, and the native MP4Box handles them correctly.
Minimal reproduction (Node 26)
import * as MP4Box from 'mp4box'
import { readFileSync } from 'fs'
const buf = readFileSync('./test.mp4')
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
ab.fileStart = 0
const mp4 = MP4Box.createFile()
let videoId = null, audioId = null
let videoSamples = 0, audioSamples = 0
mp4.onReady = (info) => {
console.log('onReady: videoTracks=', info.videoTracks.length,
'audioTracks=', info.audioTracks.length)
// info.videoTracks[0].nb_samples === 34527 (correct)
// info.audioTracks[0].nb_samples === 44058 (correct)
videoId = info.videoTracks[0]?.id ?? null
audioId = info.audioTracks[0]?.id ?? null
for (const t of info.videoTracks) {
mp4.setExtractionOptions(t.id, null, { nbSamples: 1000, rapAlignement: true })
}
for (const t of info.audioTracks) {
mp4.setExtractionOptions(t.id, null, { nbSamples: 1000, rapAlignement: false })
}
mp4.start()
}
mp4.onSamples = (id, _user, samples) => {
if (id === videoId) videoSamples += samples.length
if (id === audioId) audioSamples += samples.length
}
const CHUNK = 10 * 1024 * 1024
let pos = 0
while (pos < ab.byteLength) {
const slice = ab.slice(pos, Math.min(pos + CHUNK, ab.byteLength))
slice.fileStart = pos
mp4.appendBuffer(slice)
pos += CHUNK
await new Promise(r => setTimeout(r, 0))
}
mp4.flush()
await new Promise(r => setTimeout(r, 3000))
console.log('video samples delivered:', videoSamples) // 0
console.log('audio samples delivered:', audioSamples) // 0
Observed output (mp4box.js)
onReady: videoTracks= 1 audioTracks= 1
all chunks appended, calling flush()
=== SUMMARY ===
video samples delivered: 0
audio samples delivered: 0
What I tried to narrow it down
I generated four files from the same source material, isolating the audio codec as the variable that most correlates with the failure:
| File |
Audio codec |
video elst |
audio elst |
mp4box.js onSamples |
01-control-AAC |
mp4a.40.2 (AAC) |
0 |
2048 |
works |
02-AAC-to-MP3 |
mp4a.6b (MP3) |
0 |
1105 |
broken |
03-MP3-elst-to-AAC |
mp4a.40.2 (AAC) |
1024 |
1024 |
broken |
04-AAC-to-MP3* |
mp4a.6b (MP3) |
0 |
1105 |
broken |
(*04 was generated with -fflags +genpts; otherwise identical to 02.)
Workarounds tried (none worked)
setExtractionOptions(id, null, { nbSamples: 1000, rapAlignement: false }) for the video track as well — onSamples still never fires
- Increasing the post-flush wait from 500 ms to 3 s —
onSamples still never fires
- Different chunk sizes (1 MB, 5 MB, 20 MB) — same result
createFile(true) to keep raw data — same result
- Yielding longer between
appendBuffer calls — same result
- Transcoding the audio to AAC with modern FFmpeg — sometimes helps,
sometimes does not (depends on what ffmpeg does to the elst during
the remux), so it is not a reliable workaround.
What I am not sure about
- Whether the trigger is
mp4a.6b audio, a non-zero elst offset on either track, or some combination. My 4-file matrix did not isolate the two suspected causes.
- Whether this also affects non-MP3-in-MP4 files with non-zero
elst (file 03 suggests yes, but the sample size is too small to be confident).
- Where in the sample-delivery path the failure happens —
onSamples is never called for either track, which suggests the failure is upstream of per-track delivery (maybe in the setExtractionOptions call, or in the start() / flush() sequencing for tracks with non-zero elst).
For some MP4 files, mp4box.js (the JavaScript port of GPAC's MP4Box) loses all samples — neither video nor audio callbacks fire. The exact conditions that trigger this are not fully understood yet, but the bug is demonstrably not in the file format or in the GPAC toolchain: the native
MP4BoxCLI from the same project extracts all 44 058 audio samples and 34 527 video samples from the same file without any issue.onReadyfires correctly in mp4box.js and reports the correct sample counts / durations, so the bug is somewhere in the sample-delivery path betweenstart()and theonSamplescallback — likely a corner case in how the JavaScript port handles theelstedit list or themp4a.6bcodec.The same file plays back correctly in
<video>, VLC,ffprobe, and the nativeMP4BoxCLI.Environment
mp4box(npm): tested on 2.3.0 and 2.4.1 — both reproducegpac(native): 26.02 — extracts the same file successfullythe browser bundle)
Lavf58.29.100)mp4a.6b, 44.1 kHz stereo, 44 058 frames)File metadata from
ffprobeNative
MP4Box -info(control)The native CLI sees all 34 527 video samples and 44 058 audio samples, applies the
elstedit list, and reports the file asFragmented: no.So the file is well-formed and extractable by the reference implementation.
Native
MP4Box -raw 2 file.mp4 -out audio.m4a(control)This writes a valid 18 MB
.m4afile with the full audio stream, confirming that the file is parseable end-to-end by the reference implementation. The same call through mp4box.js returns zero samples.info.tracks[].edits(fromonReadyin mp4box.js)The
media_time != 0on both tracks looks like a leftover from the FFmpeg 4.1 muxer (modern FFmpeg ≥5 writesmedia_time == 0or noelstat all). Files produced this way are unusual but standards-compliant, and the native MP4Box handles them correctly.Minimal reproduction (Node 26)
Observed output (mp4box.js)
What I tried to narrow it down
I generated four files from the same source material, isolating the audio codec as the variable that most correlates with the failure:
elstelstonSamples01-control-AACmp4a.40.2(AAC)0204802-AAC-to-MP3mp4a.6b(MP3)0110503-MP3-elst-to-AACmp4a.40.2(AAC)1024102404-AAC-to-MP3*mp4a.6b(MP3)01105(
*04was generated with-fflags +genpts; otherwise identical to02.)Workarounds tried (none worked)
setExtractionOptions(id, null, { nbSamples: 1000, rapAlignement: false })for the video track as well —onSamplesstill never firesonSamplesstill never firescreateFile(true)to keep raw data — same resultappendBuffercalls — same resultsometimes does not (depends on what
ffmpegdoes to theelstduringthe remux), so it is not a reliable workaround.
What I am not sure about
mp4a.6baudio, a non-zeroelstoffset on either track, or some combination. My 4-file matrix did not isolate the two suspected causes.elst(file03suggests yes, but the sample size is too small to be confident).onSamplesis never called for either track, which suggests the failure is upstream of per-track delivery (maybe in thesetExtractionOptionscall, or in thestart()/flush()sequencing for tracks with non-zeroelst).