Back to blog
FILE 0x34·AVFOUNDATION REJECTED MY HLS STREAM OVER ONE 12-BYTE BOX

AVFoundation rejected my HLS stream over one 12-byte box

October 26, 2026 · debugging, video, apple

One file would not play. Everything else in the library did.

My self-hosted media server remuxes files on the fly into HLS (fragmented MP4) for an Apple TV client. No transcoding, just one ffmpeg segment-muxer run per stream:

ffmpeg -i input.mp4 -map 0:v:0 -map 0:a:0 \
  -c:v copy -tag:v hvc1 -c:a copy \
  -f segment -segment_format mp4 \
  -segment_format_options movflags=frag_keyframe+empty_moov+delay_moov+default_base_moof+frag_discont \
  seg_%05d.mp4

Each output segment starts with ftyp+moov. The server peels that off the first one as init.mp4 and serves the moof/mdat remainder of each segment as N.m4s. That works fine for HEVC from both MKV and MP4 sources.

The symptom

One particular x265 8-bit HEVC MP4 release failed instantly. AVPlayer reported AVFoundationErrorDomain -11829 "Cannot Open", with the helpful "This media may be damaged", wrapping CoreMediaErrorDomain -12848. The item's tracks contained only the audio track. The video track wasn't failing to decode. It wasn't there at all.

The media wasn't damaged. If I remuxed the same samples into a single progressive MP4, AVPlayer played it. A single fragmented MP4 (frag_keyframe+empty_moov) also played. Only the HLS version broke.

Stop testing on the TV

Every cycle of "change the muxer, rebuild, pick up the remote, navigate, press play" costs a minute or two, and the TV shows you a spinner, not an error. So I wrote a tiny AVPlayer harness and ran it on a Mac against the playlist URL:

import AVFoundation
let item = AVPlayerItem(url: URL(string: CommandLine.arguments[1])!)
let player = AVPlayer(playerItem: item); player.play()
let start = Date()
while Date().timeIntervalSince(start) < 25 {
    RunLoop.main.run(until: Date().addingTimeInterval(0.5))
    if item.status == .failed { print("FAILED", item.error ?? ""); break }
    if item.status == .readyToPlay && player.currentTime().seconds > 2 { print("PLAYING"); break }
}

swift harness.swift http://.../master.m3u8 gives a pass/fail in a few seconds with the full error. That's the same framework the Apple TV uses, so it's a fair test. Then I started eliminating things.

Things that made no difference:

So the codec config matched exactly and it still failed. The problem had to be somewhere else in the init segment.

The box

I dumped the box trees of the broken init.mp4 and the playable fragmented file side by side. They were close, except that the broken init's stbl contained one extra box: sdtp.

sdtp is the sample dependency type box. Per sample, it says whether a sample depends on others and whether others depend on it. ffmpeg carried it over from the MP4 source. In an empty_moov init segment the sample tables are empty by design, so this sdtp describes zero samples. A full-box header and no entries: 12 bytes.

The progressive and single-file fragmented MP4s tolerate it. AVFoundation's HLS path doesn't. It drops the whole video track, and the error you get back is "damaged."

I deleted that one box from the init by hand and fixed up the parent sizes. The harness printed PLAYING.

The fix

I didn't want to patch ffmpeg over 12 bytes. The server already parses the init to split it off, so it now rebuilds it without the box. The only tricky part is that every container on the path has a size field that needs rewriting:

_CONTAINERS = {b"moov", b"trak", b"mdia", b"minf", b"stbl"}

def strip_boxes(data: bytes, drop: set[bytes], start: int = 0, end: int | None = None) -> bytes:
    end = len(data) if end is None else end
    out = bytearray()
    pos = start
    while pos + 8 <= end:
        size = int.from_bytes(data[pos:pos + 4], "big")
        kind = data[pos + 4:pos + 8]
        if size < 8 or pos + size > end:
            out += data[pos:end]
            break
        if kind in drop:
            pos += size
            continue
        if kind in _CONTAINERS:
            body = strip_boxes(data, drop, pos + 8, pos + size)
            out += (8 + len(body)).to_bytes(4, "big") + kind + body
        else:
            out += data[pos:pos + size]
        pos += size
    return bytes(out)

init = strip_boxes(init, {b"sdtp"})

It only recurses into the containers that can lead to stbl. Anything it doesn't understand (a 64-bit size, a truncated box) gets copied through untouched instead of guessed at. I also bumped the segment cache key so clients wouldn't keep getting the old init from cache.

Bonus: the playlist was too slow anyway

While watching the harness I noticed the playlist itself took about 15 seconds to come back for MP4 sources. AVPlayer gives up before that, so even with a clean init, that file would have failed on first open.

The server builds a keyframe index from the MP4's sample tables to cut segments on keyframes. That needs each keyframe's composition offset from ctts, which is run-length encoded as (count, offset) pairs. The lookup walked the runs from the start for every keyframe: O(runs x keyframes), 13 seconds of CPU on this file.

The fix was to compute each run's starting sample number once, then bisect:

run_starts, acc = [], 1
for j in range(0, len(offsets), 2):   # offsets = flat (count, offset) pairs
    run_starts.append(acc)
    acc += offsets[j]

def ctts_at(sample_no: int) -> int:
    if not offsets or sample_no >= acc:
        return 0
    return offsets[2 * (bisect.bisect_right(run_starts, sample_no) - 1) + 1]

15 seconds down to 2.5, and most of what's left is network reads.

What I'd do differently