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 ffmetadata document and to mux chapters into MP4/M4A. Verify with ffmpeg -version.
  • mp4v2 / mp4chaps: 2.1.3 (provides the mp4chaps and mp4art CLI tools for Nero-style chapter atoms and cover art in MP4 containers). Verify with mp4chaps --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.

Queue topology for a chapter and metadata embedding worker A data-flow diagram. A chaptered transcript and a finished media asset are pulled from an ingest queue into an embed worker. A metadata builder merges the chapter list, episode tags, and normalized cover art into a single contract. The worker then branches on container type: MP4 and M4A assets go through FFmpeg ffmetadata plus mp4chaps and mp4art to write chapter atoms and cover art, while MP3 assets go through mutagen to write ID3v2 CHAP and TOC frames plus an APIC cover-art frame. Both branches converge on a verify step that re-reads the tags with ffprobe and mp4chaps list. Files whose tags round-trip are written to the delivery queue; files whose tags fail verification are routed to a tag-repair dead-letter queue. Chaptered transcript Finished asset + cover art Metadata builder merge chapters + tags + art → contract MP4 / M4A branch ffmetadata · mp4chaps · mp4art MP3 branch mutagen ID3 CHAP / TOC / APIC Verify tags ffprobe · mp4chaps -l Delivery queue tags round-trip Tag-repair DLQ verify failed pass fail

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 sha256 plus 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. mp4chaps edits 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 mp4v2 and mutagen versions 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 by reason (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 chpl atom only and the player reads the QuickTime text track; re-import with mp4chaps which 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 every TIT2 sub-frame, or write v2.4.

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 copy in FFmpeg, in-place edits in mp4chaps and mutagen). 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_DIR before tagging; mp4chaps seeks 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.