Why ffmpeg -map 0 Breaks Your Remux: The Hidden Matrix of Multi-Track MP4 and Matroska
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:
- 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. - 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_subtitleordvd_subtitle) cannot be converted into text atoms and cause identical muxer crashes. - It silently strips font attachments: When remuxing into MP4, attached
.ttfor.otfstyling fonts cause fatal errors:Could not find tag for codec ttf. - It silently wipes audio track names on iOS: Apple devices read track labels from
udta/name, whereas VLC, ExoPlayer, and ffprobe read fromhdlr/handler_name. FFmpeg’s demuxer only readshandler_nameback, 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 Type | MP4 (.mp4) Constraints | Matroska (.mkv) Behavior |
|---|---|---|
| Audio Codecs | Officially 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 Subtitles | Only accepts mov_text (tx3g). ASS, SRT, WebVTT cause immediate muxer fatal aborts. | Copies ASS, SSA, SRT verbatim without re-encoding. |
| Subtitle Styling | mov_text strips fonts, positioning, shadows, and karaoke fades. Only basic bold/italic survives. | Preserves all Aegisub styling, override tags, and positioning. |
| Font Attachments | Refused outright. Could not find tag for codec ttf. | Carried verbatim bit-for-bit via 0:t stream mapping. |
| Timecode / Chapter Data | Holds QuickTime text and tmcd streams natively. | Fatal error if passed raw QuickTime/timecode data tracks. |
| Metadata Preservation | Drops 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:
- 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). - 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.
- For MP4: all non-AAC audio is normalized to AAC with channel-preservation (5.1 stays 5.1); text subtitles are transcoded to
- 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:
- Muxer aborts dropped to 0: Over 14,000 heterogeneous media files processed across my nodes without a single container-level crash.
- Apple compatibility restored: Multi-track files retain their labels across iOS, macOS, Android, and desktop players.
- Preserved styling where possible: Archival jobs retain 100% of ASS styling and embedded fonts in MKV, while distribution jobs gracefully degrade to standard
mov_textfor web playback.
Containers are not dumb envelopes. Treat stream mapping as a first-class architectural boundary.
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.