Skip to content

onSamples is never called for some MP4 files, while the native MP4Box (GPAC) extracts the same file just fine #563

Description

@dan1els

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions