Chapter and Metadata Embedding
Within the Media Delivery & Publishing Automation layer, chapter and metadata embedding is the stage that writes navigation and descriptive information into the delivered file itself — so a listener who downloads an episode gets chapter jumps, cover art, and a correct title even with no network connection and no podcast feed in sight. This is a distinct concern from the feed-side chapters carried in an RSS document. Embedded metadata travels with the bytes; feed metadata is resolved by the client at subscribe time. A production pipeline needs both, and it must not confuse the two.
The stage sits after transcription-driven chaptering and after final loudness and container work, but before the asset is pushed to a CDN or referenced from a feed. It consumes a finished audio or video file plus a machine-readable chapter list — the output of automated chapter generation — and emits the same file with chapter atoms, ID3 frames, cover art, and podcast tags written in place, along with a verification record proving the tags round-trip.
Prerequisites & Environment
Tag writers are unforgiving about format versions: an M4A chapter atom that Apple Podcasts reads is written differently from the QuickTime text track older players expect, and ID3v2.3 versus v2.4 changes how CHAP frames serialize. Pin every tool.
- Python: 3.10+ (the examples use
dataclasses,pathlib, and structural pattern matching). - FFmpeg: 6.0 or newer, used both to build the
ffmetadatadocument and to mux chapters into MP4/M4A. Verify withffmpeg -version. - mp4v2 / mp4chaps: 2.1.3 (provides the
mp4chapsandmp4artCLI tools for Nero-style chapter atoms and cover art in MP4 containers). Verify withmp4chaps --version. - Python libraries (pinned):
# mutagen==1.47.0 # ID3v2 CHAP/TOC frames, MP4 atoms, cover-art APIC
# pydantic==2.7.1 # validates the chapter/metadata contract before writing
# pillow==10.3.0 # normalizes cover art to a compliant JPEG before embed
- Environment variables the embed worker reads at startup:
EMBED_ID3_VERSION=2.3 # 2.3 for widest podcast-app support; 2.4 opt-in
EMBED_ART_MAX_PX=3000 # Apple rejects cover art over 3000x3000
EMBED_ART_MIN_PX=1400 # and under 1400x1400
EMBED_ART_FORMAT=jpeg # jpeg or png; jpeg is the safe default
EMBED_FFMPEG_BIN=/usr/bin/ffmpeg
EMBED_MP4CHAPS_BIN=/usr/bin/mp4chaps
EMBED_TMP_DIR=/var/lib/mediapipe/tmp
Cover-art bounds are not arbitrary: Apple Podcasts requires square artwork between 1400×1400 and 3000×3000 pixels, and embedding out-of-range art is a common cause of an episode being silently rejected downstream. Normalize art once, at this stage, rather than trusting whatever the source asset carried.
Architecture & Queue Topology
The embed worker pulls a finished asset and its chapter contract from an upstream queue, branches on container type, writes tags with the appropriate tool, verifies the round-trip, and hands a tagged file to the delivery queue. It never generates chapter boundaries — that decision belongs upstream — and it never writes the RSS feed, which is the job of podcast RSS feed automation.
The worker is stateless per asset: the merged metadata contract is the only intermediate state, and it is derived deterministically from inputs, so a retried job produces byte-identical tags. Because embedding rewrites the container, always write to a temp path under EMBED_TMP_DIR and atomically rename on a passing verify — a crashed mp4chaps run must never leave a half-tagged file in the delivery bucket.
Step-by-Step Implementation
Each step ends with a verification command you can run before promoting the asset.
1. Assemble the chapter and metadata contract
Chapters arrive from the chaptering stage as a list of start times and titles; episode-level metadata (title, artist/author, album/show, artwork) arrives from the publishing record. Merge them into one validated contract so both embed branches read identical data. Chapter starts must be strictly increasing and the first chapter must begin at 0.
# pydantic==2.7.1
from pydantic import BaseModel, field_validator
class Chapter(BaseModel):
start_ms: int # chapter start, milliseconds from file origin
title: str # display title, <= 128 chars for player safety
url: str | None = None # optional per-chapter link (podcast apps)
class EmbedContract(BaseModel):
title: str
author: str
show: str
episode: int | None = None
artwork_path: str
chapters: list[Chapter]
@field_validator("chapters")
@classmethod
def monotonic(cls, v: list[Chapter]) -> list[Chapter]:
if not v or v[0].start_ms != 0:
raise ValueError("first chapter must start at 0 ms")
starts = [c.start_ms for c in v]
if starts != sorted(set(starts)):
raise ValueError("chapter starts must be strictly increasing")
return v
Verify the contract loads and the chapter list is monotonic:
python -c "import embed; embed.EmbedContract.model_validate_json(open('contract.json').read()); print('ok')"
2. Build an FFmpeg ffmetadata document
FFmpeg’s native metadata format (ffmetadata) expresses chapters as [CHAPTER] blocks with a TIMEBASE, START, END, and a title= key. Convert the contract into that document. Each chapter’s END is the next chapter’s START minus one tick; the last chapter ends at the media duration.
# python 3.10+
def to_ffmetadata(c: "EmbedContract", duration_ms: int) -> str:
lines = [";FFMETADATA1",
f"title={c.title}", f"artist={c.author}", f"album={c.show}"]
starts = [ch.start_ms for ch in c.chapters] + [duration_ms]
for i, ch in enumerate(c.chapters):
lines += ["[CHAPTER]", "TIMEBASE=1/1000",
f"START={ch.start_ms}", f"END={max(ch.start_ms, starts[i + 1] - 1)}",
f"title={ch.title}"]
return "\n".join(lines) + "\n"
The TIMEBASE=1/1000 sets START/END units to milliseconds, matching the contract. The FFmpeg ffmetadata documentation is the authority on the chapter-block grammar.
Verify the document parses back into chapters without re-encoding audio:
ffmpeg -i input.m4a -i chapters.ffmeta -map_metadata 1 -c copy probe.m4a && \
ffprobe -v error -show_chapters -of json probe.m4a | jq '.chapters | length'
3. Embed chapter atoms and cover art into MP4/M4A
For MP4/M4A containers, two atom styles exist: the Nero-style chpl chapter list and the QuickTime chapter text track. Apple Podcasts and modern players read the QuickTime text track; some legacy players read chpl. mp4chaps from mp4v2 writes both from a single .chapters.txt file, which is the most compatible approach. Cover art is a separate atom written with mp4art.
# python 3.10+ (mp4v2 2.1.3 CLI: mp4chaps, mp4art)
import subprocess, pathlib
def embed_mp4(asset: str, chapters_txt: str, artwork: str) -> None:
# mp4chaps imports a sibling <name>.chapters.txt when given the media file.
txt_path = pathlib.Path(asset).with_suffix(".chapters.txt")
txt_path.write_text(pathlib.Path(chapters_txt).read_text())
subprocess.run(["mp4chaps", "--import", asset], check=True, timeout=300)
subprocess.run(["mp4art", "--add", artwork, asset], check=True, timeout=300)
The granular walk-through of the .chapters.txt timecode format, importing existing chapters, and podcast-app quirks lives in embedding podcast chapters with mp4chaps. See the mp4v2 mp4chaps documentation for the full command surface.
Verify both chapter representations and the artwork are present:
mp4chaps --list output.m4a # should print each chapter timecode + title
mp4art --list output.m4a # should report 1 cover-art atom
4. Write ID3v2 CHAP and TOC frames into MP3
MP3 has no container atoms; chapters live in ID3v2 as CHAP frames (one per chapter, each with start/end times and an embedded TIT2 sub-frame for the title) plus a single CTOC table-of-contents frame that orders them. mutagen writes both, along with an APIC frame for cover art. Pin ID3v2.3 for the widest podcast-app compatibility.
# mutagen==1.47.0
from mutagen.id3 import ID3, CHAP, CTOC, TIT2, APIC, CTOCFlags
def embed_mp3(asset: str, c: "EmbedContract", duration_ms: int, art_jpeg: bytes) -> None:
tags = ID3(asset)
tags.setall("TIT2", [TIT2(encoding=3, text=[c.title])])
tags.add(APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=art_jpeg))
starts = [ch.start_ms for ch in c.chapters] + [duration_ms]
ids = [f"chp{i}" for i in range(len(c.chapters))]
for i, ch in enumerate(c.chapters):
tags.add(CHAP(element_id=ids[i], start_time=ch.start_ms,
end_time=starts[i + 1], start_offset=0xFFFFFFFF,
end_offset=0xFFFFFFFF,
sub_frames=[TIT2(encoding=3, text=[ch.title])]))
tags.add(CTOC(element_id="toc", flags=CTOCFlags.TOP_LEVEL | CTOCFlags.ORDERED,
child_element_ids=ids,
sub_frames=[TIT2(encoding=3, text=["Chapters"])]))
tags.save(asset, v2_version=3)
The start_offset/end_offset sentinels of 0xFFFFFFFF tell readers to use the time fields rather than byte offsets — the correct choice for VBR podcast audio. See the mutagen ID3 documentation for the full frame reference.
Verify the frames are present and the TOC references every chapter:
python -c "from mutagen.id3 import ID3; t=ID3('output.mp3'); \
print('CHAP', len(t.getall('CHAP')), 'CTOC', len(t.getall('CTOC')))"
# expected: CHAP == number of chapters, CTOC == 1
5. Verify embedded tags before delivery
The embed stage is only trustworthy if it proves the round-trip. Re-read the tags with an independent tool from the one that wrote them, count chapters against the contract, and confirm cover art is attached and within pixel bounds.
# python 3.10+
import json, subprocess
def verify_chapters(asset: str, expected: int) -> bool:
out = subprocess.run(["ffprobe", "-v", "error", "-show_chapters",
"-of", "json", asset], capture_output=True,
text=True, timeout=60, check=True)
return len(json.loads(out.stdout).get("chapters", [])) == expected
Verify the full contract landed:
ffprobe -v error -show_chapters -show_entries format_tags -of json output.m4a \
| jq -e '(.chapters | length) > 0 and .format.tags.title'
This verification record is the signal the podcast RSS feed automation stage checks before it references the asset in a feed.
Data Contracts
The merged contract governs what every branch writes. Any deviation halts the job rather than silently shipping a mistagged file.
| Field | Type | Validation rule | Example |
|---|---|---|---|
title |
string | non-empty, ≤ 255 chars | Episode 42: Kafka Backpressure |
author |
string | non-empty | Media Pipeline Podcast |
show |
string | non-empty (maps to album) |
Pipeline Notes |
episode |
int or null | ≥ 1 when present | 42 |
artwork_path |
string | readable file, square, 1400–3000 px | ep42.jpg |
chapters[].start_ms |
int (ms) | strictly increasing, [0] == 0 |
0, 73000, 418000 |
chapters[].title |
string | non-empty, ≤ 128 chars | Cold open |
chapters[].url |
string or null | absolute http(s) URL when present | https://…/notes#kafka |
Two output artifacts leave this stage: the tagged media file, and a small verification record — chapter count read back by ffprobe, artwork pixel dimensions, and the ID3 version or MP4 atom style actually written — that downstream stages trust instead of re-parsing the container. Feed-side chapter formats such as the podcast:chapters JSON document and Podlove Simple Chapters (PSC) are not written here; they are derived from this same contract inside the RSS stage, so the embedded and feed chapters never drift.
Resilience Patterns
Embedding rewrites the container, so failure handling centers on never shipping a partially tagged file.
- Idempotency. Key the job on the source file’s
sha256plus a hash of the contract. A retry that finds a matching verification record short-circuits and returns the existing tagged asset. - Atomic writes. Tag a copy under
EMBED_TMP_DIR; only atomically rename over the delivery path after verify passes.mp4chapsedits in place, so always operate on the temp copy, never the canonical asset. - Non-retryable vs retryable. A malformed contract, a non-monotonic chapter list, or out-of-bounds artwork is non-retryable and goes to the tag-repair dead-letter queue. A transient I/O error or a killed subprocess is retryable with capped exponential backoff.
- DLQ routing. Assets that fail the tag round-trip are diverted to a repair lane rather than blocking the pool, using the shared mechanics in retry logic and dead-letter queues.
- Version pinning at rest. Record the exact
mp4v2andmutagenversions in the verification record; a player-compatibility regression is almost always traceable to a tool upgrade that changed frame serialization.
Observability & Debugging
Instrument the worker so a mistagged episode is caught here, not by a listener whose player shows no chapters.
- Key metrics (Prometheus):
embed_job_duration_seconds— histogram, labeled by container type (mp4,mp3).embed_chapters_written— histogram of chapter count per asset; a spike to zero flags a broken contract feed.embed_verify_failed_total— counter, labeled byreason(chapter_count_mismatch,art_out_of_bounds,frame_write_error).embed_art_normalized_total— counter incremented when cover art was resized to fit the pixel bounds.
- Structured log fields:
asset_id,sha256,container,id3_version,chapter_count,art_px,mp4v2_version,mutagen_version,verify_ms. - Common errors and root causes:
- Chapters embed but the player shows none — the MP4 got a
chplatom only and the player reads the QuickTime text track; re-import withmp4chapswhich writes both. - Cover art missing on Apple Podcasts — artwork fell outside 1400–3000 px or was PNG where JPEG was required; normalize with Pillow before embed.
- Chapter titles show as mojibake — an ID3v2.3 tag was written with a non-UTF-16 encoding; set
encoding=3(UTF-8) on everyTIT2sub-frame, or write v2.4.
- Chapters embed but the player shows none — the MP4 got a
Performance Tuning
- Concurrency. Embedding is I/O-bound (it rewrites the container) and light on CPU. Size the worker pool to storage throughput, not core count; 2–4× the core count is usually safe.
- Avoid re-encoding. Every embed path here uses stream copy (
-c copyin FFmpeg, in-place edits inmp4chapsandmutagen). Re-encoding to add chapters is never necessary and would undo upstream loudness work — treat any re-encode in this stage as a bug. - Cover-art cost. Normalizing a 3000×3000 JPEG with Pillow dominates per-job CPU. Cache the normalized artwork per episode so re-runs and multi-format outputs (M4A + MP3) share one resize.
- Local scratch. Copy the asset to local NVMe under
EMBED_TMP_DIRbefore tagging;mp4chapsseeks heavily and performs poorly against network-mounted storage. - Batch by container. Group MP4 and MP3 jobs onto separate workers so each process reuses warm tool binaries and you can tune timeouts per branch.
Frequently Asked Questions
What is the difference between embedded chapters and podcast:chapters feed chapters?
Embedded chapters are written into the media file as MP4 atoms or ID3v2 CHAP/CTOC frames, so they travel with the downloaded bytes and work offline. Feed chapters are a separate podcast:chapters JSON document referenced from the RSS item; the podcast app fetches them at play time. This stage writes only the embedded ones. The feed document is generated from the same contract inside the RSS automation stage so the two never disagree.
Should I write ID3v2.3 or ID3v2.4 for podcast MP3s?
Write ID3v2.3 by default via tags.save(asset, v2_version=3). Chapter support is identical between the versions — CHAP and CTOC are defined in a separate ID3 addendum, not the base spec — but v2.3 has broader reader support across older podcast apps and car head units. Reserve v2.4 for cases that genuinely need its UTF-8 text frames or multiple-value fields.
Why write both a chpl atom and a QuickTime text track for MP4?
Different players read different structures. Apple's ecosystem and most modern apps read the QuickTime chapter text track, while some older Nero-lineage players only recognize the chpl atom. mp4chaps --import writes both from one .chapters.txt file, so you get maximum compatibility without maintaining two sources of truth.
Do I need to re-encode the audio to add chapters?
No, and you should not. Chapters and metadata are container-level or tag-level structures. FFmpeg adds them with -c copy, and mp4chaps and mutagen edit the file in place. Re-encoding would waste CPU and, worse, discard the loudness normalization and codec parameters set upstream. If your embed step transcodes, that is a defect to fix.
Where do chapter boundaries and titles actually come from?
This stage never decides chapter boundaries — it only writes them. Start times and titles are produced upstream by automated chapter generation, which maps transcript timestamps to markers and titles them. This worker's job is to serialize that finished list into the correct on-file format and prove it round-trips before delivery.
Related
- Media Delivery & Publishing Automation — the parent layer this stage belongs to.
- Embedding podcast chapters with mp4chaps — the focused mp4chaps timecode-and-import walk-through.
- Automated chapter generation — produces the chapter list this stage embeds.
- Podcast RSS feed automation — writes the feed-side chapters and references the tagged asset.
- Retry logic and dead-letter queues — the shared failure-routing mechanics referenced above.