📌 Part of Blueprint: Production Self-Hosted Infrastructure → L1: Compute, Storage & Hybrid Network

Why ffmpeg -map 0 Breaks Your Remux: The Hidden Matrix of Multi-Track MP4 and Matroska

· 6 min read · Engineering ffmpegcontainersmp4matroskasubtitlesmedia-pipeline

The naive -map 0 that breaks in production

Every developer automating FFmpeg reaches for the same shortcut when preserving secondary tracks during a transcode:

ffmpeg -i source.mkv -map 0 -c:v libx265 -c:a copy output.mp4

The logic seems bulletproof: map all streams (-map 0), transcode the video stream to save bits, and copy every accompanying audio track, subtitle, and chapter without losing data.

In production across a large, diverse video archive, this command fails catastrophically in four distinct ways:

  1. It hard-crashes on Matroska output: If an MP4 source contains Apple QuickTime text chapters (bin_data) or iPhone timecode tracks (tmcd), FFmpeg aborts instantly: Only audio, video, and subtitles are supported for Matroska.
  2. It hard-crashes on MP4 output: In standard stream copy mode (-c:s copy), any ASS, SSA, SRT, or graphical Bitmap (PGS) subtitle triggers fatal aborts: Could not find tag for codec ass in stream #2, codec not currently supported in container. Even when enabling subtitle transcoding to -c:s mov_text, graphical bitmap subtitles (hdmv_pgs_subtitle or dvd_subtitle) cannot be converted into text atoms and cause identical muxer crashes.
  3. It silently strips font attachments: When remuxing into MP4, attached .ttf or .otf styling fonts cause fatal errors: Could not find tag for codec ttf.
  4. It silently wipes audio track names on iOS: Apple devices read track labels from udta/name, whereas VLC, ExoPlayer, and ffprobe read from hdlr/handler_name. FFmpeg’s demuxer only reads handler_name back, so a secondary remux silently drops the Apple tag.

Building an automated, crash-proof media pipeline means never treating containers as transparent envelopes. It requires an explicit, policy-driven stream planner.


The hidden matrix of container behavior

Through automated test fixtures and packet-level MD5 hashing across thousands of source files, I mapped the exact boundaries between MP4 and Matroska (.mkv):

Feature / Track TypeMP4 (.mp4) ConstraintsMatroska (.mkv) Behavior
Audio CodecsOfficially supports AAC. AC-3/E-AC-3 work on Apple, but Android player matrices break.Accepts anything bit-exact (FLAC, DTS-HD, TrueHD, Opus, AAC).
Text SubtitlesOnly accepts mov_text (tx3g). ASS, SRT, WebVTT cause immediate muxer fatal aborts.Copies ASS, SSA, SRT verbatim without re-encoding.
Subtitle Stylingmov_text strips fonts, positioning, shadows, and karaoke fades. Only basic bold/italic survives.Preserves all Aegisub styling, override tags, and positioning.
Font AttachmentsRefused outright. Could not find tag for codec ttf.Carried verbatim bit-for-bit via 0:t stream mapping.
Timecode / Chapter DataHolds QuickTime text and tmcd streams natively.Fatal error if passed raw QuickTime/timecode data tracks.
Metadata PreservationDrops creation_time without -map_metadata 0; drops custom tags without -movflags +use_metadata_tags.Preserves arbitrary key-value tags natively.

Notice the asymmetry: MP4 is an end-user distribution container with strict parser gates, while Matroska is a high-fidelity archival container. Treating them identically with -map 0 guarantees pipeline crashes.

Why not standardize exclusively on MKV? Because container selection is dictated by downstream client decoding contracts. Native Apple playback engines (AVPlayer on iOS/macOS and WebKit <video>) strictly require ISOBMFF/MP4 containers, hardware-decode only tx3g (mov_text) closed-caption tracks, and read track labels exclusively from the udta/name atom. Conversely, dedicated desktop and custom media clients (mpv, VLC, or libass-backed players) natively parse Matroska attachments, rendering vector ASS subtitles with embedded TTF/OTF fonts bit-for-bit. A production remuxing pipeline cannot force users to change players; it must deterministically adapt container internals to the target runtime.


Architecting a deterministic tracks planner

Instead of handing raw streams to FFmpeg’s default muxer heuristics, I designed a pure, testable stream-planning module (tracks.py).

The design follows three rules:

  1. Never use -map 0: Every video, audio, and subtitle stream is mapped by its explicit ordinal index (-map 0:v:0, -map 0:a:0, -map 0:s:0).
  2. Explicit codec gating:
    • For MP4: all non-AAC audio is normalized to AAC with channel-preservation (5.1 stays 5.1); text subtitles are transcoded to mov_text; bitmap subtitles and font attachments are explicitly filtered with clear logs.
    • For MKV: every track is copied bit-for-bit (-c copy), and font attachments are mapped via -map 0:t.
  3. Dual metadata synchronization: Whenever a track has a human-readable title, write both keys: -metadata:s:a:0 title="Commentary" and -metadata:s:a:0 handler_name="Commentary".
🔍 View the deterministic stream planner — tracks.py plan() and build_ffmpeg_args() (61 lines)
TEXT_SUB = {"subrip", "srt", "ass", "ssa", "webvtt", "mov_text", "text"}
BITMAP_SUB = {"hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle"}

def plan(js, container="mp4"):
    """Takes ffprobe JSON and generates an explicit, safe mapping plan."""
    container = container.lower()
    audio = []
    for n, st in enumerate(js.get("streams", [])):
        if st.get("codec_type") != "audio":
            continue
        codec = (st.get("codec_name") or "").lower()
        target_codec = "copy" if (container == "mkv" or codec == "aac") else "aac"
        audio.append({
            "sel": f"0:a:{len(audio)}",
            "codec": target_codec,
            "channels": int(st.get("channels") or 2),
            "title": (st.get("tags", {}).get("title") or "").strip()
        })

    subtitles = []
    for n, st in enumerate(js.get("streams", [])):
        if st.get("codec_type") != "subtitle":
            continue
        codec = (st.get("codec_name") or "").lower()
        if container == "mp4":
            if codec in TEXT_SUB:
                subtitles.append({"sel": f"0:s:{len(subtitles)}", "codec": "mov_text"})
            # Bitmap subtitles (PGS) cannot enter MP4 without fatal muxer error - skip them
        else: # MKV accepts all subtitles bit-exact
            subtitles.append({"sel": f"0:s:{len(subtitles)}", "codec": "copy"})

    return {"container": container, "audio": audio, "subtitles": subtitles}

def build_ffmpeg_args(plan):
    args = ["-map", "0:v:0"]
    for a in plan["audio"]:
        args += ["-map", a["sel"]]
    for s in plan["subtitles"]:
        args += ["-map", s["sel"]]
    
    # In MKV, carry font attachments; never use generic -map 0
    if plan["container"] == "mkv":
        args += ["-map", "0:t?"]

    args += ["-map_metadata", "0", "-map_chapters", "0"]

    # Codecs & dual-tagging
    for i, a in enumerate(plan["audio"]):
        args += [f"-c:a:{i}", a["codec"]]
        if a["title"]:
            args += [f"-metadata:s:a:{i}", f"title={a['title']}"]
            if plan["container"] == "mp4":
                args += [f"-metadata:s:a:{i}", f"handler_name={a['title']}"]

    for i, s in enumerate(plan["subtitles"]):
        args += [f"-c:s:{i}", s["codec"]]

    if plan["container"] == "mp4":
        args += ["-movflags", "+faststart+use_metadata_tags"]

    return args

Verifying the output at packet level

A plan is only as good as its verification gate. After remuxing, the pipeline still has to prove that an audio stream copied with -c:a copy was genuinely untouched.

Standard duration checks are insufficient because timestamps can drift across containers. My validation gate uses packet payload MD5 hashing:

def stream_signature(path, stream_selector):
    """Computes exact MD5 of raw concatenated packet payloads via -f data."""
    cmd = ["ffmpeg", "-v", "error", "-i", path, "-map", f"0:{stream_selector}",
           "-c", "copy", "-f", "data", "-"]
    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
    h = hashlib.md5()
    while chunk := proc.stdout.read(1 << 20):
        h.update(chunk)
    return h.hexdigest()

By streaming -f data -, FFmpeg outputs only the raw decoded/demuxed elementary stream packets, stripping away container headers, timebases, and cluster IDs. The same audio stream demuxed from MKV and MP4 hashes to the exact same MD5 digest.


The result

By replacing -map 0 with an explicit container-aware tracks planner:

Containers are not dumb envelopes. Treat stream mapping as a first-class architectural boundary.

🛡️ Production Infrastructure & SRE

Dealing with flaky containers, cloud cost bloat, or unexplained outages?

Real production reliability comes from understanding the kernel and the protocol layers, not just restarting containers. I manage 162 containers across 3 hosts running 24/7 client workloads.

Lee Teong Hoe

Lee Teong Hoe · Mr Hoelee

Full-stack developer & DevOps engineer based in Malaysia. I run my own production infrastructure and build web systems for small businesses — this blog is the living record.

Hiring? Profile & stack · Malaysian SME? Website design & development ↗ · All posts