Embedding Podcast Chapters with mp4chaps
This page sits beneath Chapter and Metadata Embedding within the Media Delivery & Publishing Automation layer, and it solves one concrete task: you have a list of chapter start times and titles, and you need them written into an M4A or MP4 podcast file so a listener’s player shows tappable chapter navigation. The tool for the job is mp4chaps, shipped with the mp4v2 library.
Problem Framing
The chaptering stage hands you data that looks like this — start times in milliseconds and a title per chapter, typically the output of mapping transcript timestamps to chapter markers:
[
{"start_ms": 0, "title": "Cold open"},
{"start_ms": 73000, "title": "Interview: scaling Kafka consumers"},
{"start_ms": 418500, "title": "Listener questions"},
{"start_ms": 902250, "title": "Wrap-up and credits"}
]
FFmpeg can write chapters from an ffmetadata document, but for MP4/M4A there is a subtlety it does not fully solve on its own: podcast players expect the QuickTime chapter text track, and several older players additionally look for the Nero-style chpl atom. If you write only one, a slice of your audience sees no chapters and you will not notice unless you test on that specific app. The symptom is maddeningly quiet:
INFO deliver ep204.m4a uploaded, 4 chapters in ffmetadata
WARN qa-app Overcast shows chapters; Apple Podcasts shows none
WARN qa-app Pocket Casts: chapter list empty on iOS, present on web
mp4chaps --import writes both the QuickTime text track and the chpl atom from a single source file, which is why it is the pragmatic default for podcast M4As. This page shows the exact file format it reads, how to generate it, how to import into an existing file, and how to prove the atoms landed.
Solution Architecture
The flow is deliberately small: serialize the marker list into mp4chaps’ line-oriented timecode format, drop that .chapters.txt next to the media file, run one import command, and verify. mp4chaps edits the container in place, so the pipeline always operates on a temp copy and promotes it only after verification passes.
Implementation
1. Understand the mp4chaps timecode format
An mp4chaps chapters file is plain UTF-8 text, one chapter per line. Each line is a timecode, one or more spaces, then the title. The timecode is HH:MM:SS.mmm — hours, minutes, seconds, and a three-digit millisecond fraction, all zero-padded. The first chapter is almost always 00:00:00.000. There is no explicit end time; each chapter runs until the next one starts, and the last runs to the end of the file.
00:00:00.000 Cold open
00:01:13.000 Interview: scaling Kafka consumers
00:06:58.500 Listener questions
00:15:02.250 Wrap-up and credits
The .mmm fraction is optional in the format, but always emit it — dropping it rounds every marker to a whole second, which visibly misaligns chapters against speech that starts mid-second. Titles may contain spaces and Unicode; keep them under about 128 characters so mobile players do not truncate them mid-word.
2. Serialize markers into a .chapters.txt
Convert the millisecond markers into the timecode format. mp4chaps looks for a file named exactly like the media file but with the extension replaced by .chapters.txt (so ep204.m4a pairs with ep204.chapters.txt), so write it to that sibling path.
# python 3.10+ (no third-party deps for the serializer)
import pathlib
def ms_to_timecode(ms: int) -> str:
h, rem = divmod(ms, 3_600_000)
m, rem = divmod(rem, 60_000)
s, frac = divmod(rem, 1000)
return f"{h:02d}:{m:02d}:{s:02d}.{frac:03d}"
def write_chapters_txt(media_path: str, markers: list[dict]) -> str:
if not markers or markers[0]["start_ms"] != 0:
raise ValueError("first chapter must start at 00:00:00.000")
lines = [f'{ms_to_timecode(m["start_ms"])} {m["title"].strip()}'
for m in markers]
out = pathlib.Path(media_path).with_suffix(".chapters.txt")
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
return str(out)
The guard on markers[0] matters: a chapter list that does not start at zero produces a leading gap that some players render as an untitled “chapter 0”, and others reject outright.
3. Import the chapters with mp4chaps
With the sibling .chapters.txt in place, a single command imports it. Pass --chapter-qt behavior implicitly by using the default import, which writes both chapter representations. Always run against a temp copy of the asset because mp4chaps rewrites the file in place.
# python 3.10+ (mp4v2 2.1.3 provides the mp4chaps binary)
import shutil, subprocess, pathlib
def embed_with_mp4chaps(src_m4a: str, markers: list[dict], tmp_dir: str) -> str:
tmp = pathlib.Path(tmp_dir) / pathlib.Path(src_m4a).name
shutil.copy2(src_m4a, tmp)
write_chapters_txt(str(tmp), markers) # writes <name>.chapters.txt beside tmp
subprocess.run(["mp4chaps", "--import", str(tmp)],
check=True, timeout=300)
return str(tmp)
Under the hood mp4chaps --import reads the sibling file and writes a QuickTime chapter text track plus a Nero chpl atom. The full command surface — including --export to pull existing chapters back out, --remove to strip them, and --list to print them — is documented in the mp4v2 mp4chaps documentation.
Importing chapters that already exist in a file
If an asset already carries chapters (for example, a re-delivery where an editor set markers in another tool), export them first, edit, and re-import rather than blindly overwriting. mp4chaps --export ep204.m4a writes ep204.chapters.txt from the file’s current atoms; you can diff that against your generated markers to detect drift before committing a change.
mp4chaps --export ep204.m4a # dumps current chapters to ep204.chapters.txt
# ...inspect or regenerate the file...
mp4chaps --import ep204.m4a # writes the (edited) chapters back in
Podcast-app compatibility caveats
Embedded MP4 chapters are read inconsistently across the podcast ecosystem, and a file that looks correct in one app can show nothing in another. A few caveats are worth building into your QA rather than discovering in production. Apple Podcasts reads the QuickTime chapter text track and ignores a bare chpl atom, which is precisely why writing both matters — a file chaptered by a tool that emits only chpl shows no chapters on iOS. Some apps render per-chapter artwork and links, but mp4chaps writes plain text titles only; if you need chapter images or URLs, those belong in the feed-side podcast:chapters document generated one stage up, not in the M4A. Very long titles are truncated differently per client, so treat roughly 40 visible characters as the safe budget for the part that must stay readable. Finally, a handful of car heads and legacy hardware players ignore MP4 chapters entirely; for those audiences the RSS chapter tags are the only navigation that surfaces, which is another reason the embedded and feed chapters must be generated from the same marker list.
Verification
Prove the atoms landed with a tool independent of the one that wrote them. First, mp4chaps --list reads the chapters straight back:
mp4chaps --list output.m4a
# Chapters of "output.m4a"
# 00:00:00.000 Cold open
# 00:01:13.000 Interview: scaling Kafka consumers
# 00:06:58.500 Listener questions
# 00:15:02.250 Wrap-up and credits
Then cross-check with FFmpeg’s probe, which reads the QuickTime text track and reports the chapter count as JSON. This is the check to wire into an automated gate:
ffprobe -v error -show_chapters -of json output.m4a | jq '.chapters | length'
# expected: 4
A passing run shows mp4chaps --list printing every timecode and title, and ffprobe returning a chapter count equal to your marker list length. Only after both agree should the pipeline atomically rename the temp file over the delivery path. The FFmpeg ffprobe documentation covers the -show_chapters output fields in full.
Failure Modes & Edge Cases
| Edge case | Symptom | Remediation |
|---|---|---|
First chapter not at 00:00:00.000 |
Player shows an untitled leading chapter, or rejects the list | Force the first marker to 0; validate before writing the file |
| Milliseconds dropped from timecode | Chapters snap to whole seconds, drift off speech | Always emit the .mmm fraction, even when it is .000 |
.chapters.txt not named to match the media file |
mp4chaps --import reports nothing to import |
Name it <media-stem>.chapters.txt in the same directory |
Only chpl written by another tool |
Apple Podcasts shows no chapters | Re-import with mp4chaps so the QuickTime text track is added |
| Non-UTF-8 title bytes | Mojibake titles in the player | Write the file as UTF-8; strip control characters from titles |
| mp4chaps run against the canonical asset | A crash leaves a half-tagged delivery file | Copy to a temp path first; promote only after verify passes |
| Chapter count exceeds player limits | Later chapters silently dropped on some apps | Keep to a sane count (well under 255) and merge micro-chapters |
FAQ
Why use mp4chaps instead of FFmpeg's -map_metadata for MP4 chapters?
FFmpeg writes chapters from an ffmetadata document, but its MP4 muxer does not reliably produce the Nero chpl atom that some older players need alongside the QuickTime text track. mp4chaps --import writes both representations from one file, so a wider set of podcast apps shows the chapters. For MP3 you would use ID3 CHAP frames instead; for MP4/M4A, mp4chaps is the compatibility-safe default.
Do I need an end time for each chapter?
No. The mp4chaps format is start-time only: each chapter runs until the next chapter's start, and the final chapter runs to the end of the media. That is why the marker list only needs a start and a title per chapter, and why the serializer never computes end times — the container derives them from the ordering.
Can I pull chapters back out of a delivered file to check them?
Yes. mp4chaps --export ep204.m4a writes the file's current chapters to a sibling .chapters.txt, which you can diff against your source markers to detect drift or an accidental overwrite. This export/import round-trip is also how you safely edit chapters that another tool originally embedded, rather than blindly replacing them.
Related
- Up to the parent stage: Chapter and Metadata Embedding
- Pipeline layer: Media Delivery & Publishing Automation
- Where the markers come from: mapping transcript timestamps to chapter markers
- The upstream chaptering stage: automated chapter generation