TDD 0006: Trick play, subtitles, and adaptive renditions
- Status: Draft
- Created: 2026-09-21
- Updated: 2026-09-21
- Related ADRs: None
- Related designs: TDD 0002 (the mapper interface this extends), TDD 0004 (the input this builds on)
Summary
Three product gaps were chosen for the next stage, in this order of delivery:
- HLS I-frame playlists, for scrub previews and fast-forward. No mapper change, no decoding.
- Sidecar WebVTT subtitles, listed by the mapper and served by segmentor.
- Adaptive renditions, several source files per title served as one adaptive stream, listed by the mapper.
DRM is required but deferred to its own design, after these three. Section DRM records what these designs must not make harder.
The mapper changes are additive: a mapper that sends none of the new fields behaves exactly as today, and a segmentor that does not know the fields ignores them.
Context
An asset is one MP4 today: one video track, some audio tracks, and a single video variant. That gives players no way to switch quality on a slow connection, no captions, and no fast scrubbing, which are the things a viewer notices first. The packaging pipeline already has what each feature needs: the sync flags of every sample, a per-request fragment writer, and a mapper that names where the media is.
Goals
- Scrubbing that costs no decoding and no extra storage.
- Captions in HLS and DASH from files the mapper already hosts.
- One asset ID that plays as an adaptive stream when the mapper lists several renditions, and as today when it lists one.
- Every new input is bounded and validated like a media file: size limits, the
[remote_media]policy for URLs, and errors that name what was wrong.
Non-goals
- Transcoding to make renditions. They are separate encodes that already exist.
- DASH trick-mode adaptation sets. Only HLS I-frame playlists are built.
- Subtitle formats other than WebVTT, and converting text tracks inside the MP4.
- Live or dynamic manifests.
- DRM. See below.
1. HLS I-frame playlists
Implemented as designed. The conformance suite checks, for every fixture with video, that the playlist lists the source’s keyframes and that each fragment decodes to one picture. The first version trusts
stss, as described below; the open question about IDR pictures remains.
Output
The master playlist gains an #EXT-X-I-FRAME-STREAM-INF line pointing at video/iframes.m3u8. That playlist has #EXT-X-I-FRAMES-ONLY and one entry per keyframe. Each entry is its own small resource, /hls/{asset}/video/iframes/{n}/media.m4s: a fragment holding that one sample, with the video track’s existing init segment.
One fragment per keyframe is deliberate. A byte range into the ordinary media segment would not work for fMP4, because the segment’s single moof describes every sample in it, so a range covering only the I-frame has no metadata for the player. The fragment writer already produces a moof and mdat for any run of samples on request, so a one-sample fragment is a TrackSegment covering one sample and no new machinery.
EXTINFis the time from this keyframe to the next one, or to the end.BANDWIDTHis the peak I-frame bitrate: the largest keyframe in bits over the interval it represents.AVERAGE-BANDWIDTHuses the totals.CODECSis the video codec alone, andRESOLUTIONis the video’s.- Only tracks with a video track and at least one sync sample get one.
- The playlist is rendered at load with the others, from the sync flags already in the index. It costs roughly a hundred bytes per keyframe.
Keyframes that are not independently decodable
stss marks sync samples, and a sync sample is not always an IDR picture: an HEVC open-GOP file marks CRA pictures too, which cannot be decoded without earlier frames. Telling them apart needs a look at the NAL unit type. For H.264 the sync table normally marks IDR pictures and the risk is small, so the first version trusts stss and records the gap. See Open questions.
Testing
Each I-frame resource, prefixed with the init segment, must decode to exactly one frame with FFmpeg, for every fixture with video. Scrubbing itself is a Safari and tvOS behaviour and is checked by hand.
2. Sidecar WebVTT subtitles
Implemented as designed, with two refinements: an
httpsubtitle origin must support ranged requests (it is opened like media, so it gets the same[remote_media]and redirect protection), and the size and count limits arelimits.max_subtitle_bytes,limits.max_subtitles_total_bytes, andlimits.max_subtitles. The version covers subtitle content; a mapper must still change its ownversionwhen a caption changes, because that is what triggers a reload. Checked in headless Chrome with hls.js and dash.js: cues appear at the right times, including on a file whose edit lists shift the timeline by 67 ms. The shift is the edit lists’ shared offsetO, not a track’s own delay (a late-starting video is already part of the presentation the cues were written against), and a fragmented file is not shifted. Not checked in Safari.
Mapper answer
An optional subtitles list, each entry:
{ "language": "en", "label": "English", "default": true, "forced": false,
"location": { "type": "http", "url": "https://origin.example.net/subs/movie.en.vtt" } }
location is a file or http location exactly as for media, and an http one is subject to the same [remote_media] policy: allowed hosts, no private addresses, no redirects. language is a BCP 47 tag and unique within the asset. default and forced are optional.
What segmentor does with a file
The file is fetched when the asset loads and is held in memory, so requests never touch the origin:
- Validation. UTF-8, at most
limits.max_subtitle_bytes(default 2 MiB) each and a limit in total, and it must begin withWEBVTT. Anything else fails the asset load with a message naming the language. - Timeline correction. An asset whose edit lists trim encoder delay is served on a timeline
Olater than the source’s clock (see TDD 0004), so a cue authored against the source would appear early byO. Cue timing lines are shifted byOwhen the asset loads. A track’s own delay is not part ofO, and a fragmented file, whose timeline starts at zero, is not shifted. Everything else in the file is passed through unchanged. - Version. The asset version covers the subtitle content, so changing a caption gives new URLs.
Output
- HLS.
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs"per language withLANGUAGE,NAME,DEFAULT,AUTOSELECT, andFORCED, andSUBTITLES="subs"on the variant. Its playlistsubtitles/{language}/index.m3u8lists the whole file as a single segmentsubtitles/{language}/sub.vttwith anEXTINFof the asset duration, which is valid for VOD. - DASH. A text
AdaptationSetwithlang,mimeType="text/vtt", and aRepresentationwhoseBaseURLissubtitles/{language}/sub.vtt.
Subtitles are not tracks of the MP4, so they are held beside the tracks and are not a TrackKey.
Testing
Cue-shifting unit tests, including hour rollover and settings after the timing; validation rejects for every bad file; SSRF cases through the existing policy; and Chrome through hls.js and dash.js showing a cue in video.textTracks at the shifted time.
3. Adaptive renditions
Mapper answer
Instead of a single location, an answer may give renditions:
{ "renditions": [
{ "id": "1080p", "location": { "type": "http", "url": "https://origin.example.net/m/1080.mp4" } },
{ "id": "720p", "location": { "type": "http", "url": "https://origin.example.net/m/720.mp4" } },
{ "id": "audio-en", "language": "en", "location": { "type": "http", "url": "https://origin.example.net/m/en.m4a" } } ] }
location and renditions are mutually exclusive, and a single-location answer is one implicit rendition, so nothing changes for existing mappers. A rendition with video is a video rendition; one with only audio is an audio rendition, which is how separate audio files, and several audio languages, are supplied. id is a short URL-safe label, unique in the asset.
Structure
Each rendition is loaded as its own packaged asset: its own index, plan, init segments, and version, so all the existing parsing, limits, and caching apply to it unchanged. A composite asset holds the list and answers for the master playlist and the DASH manifest. Track keys stay the free-form names the routes already accept, so no route changes: video-{id} for a video rendition’s video track (video when there is one), and audio-{n} for the shared audio tracks. The composite maps each key to a rendition and a real track.
Audio
Audio is a single shared group, so that switching video quality never switches or restarts audio. It comes from the audio renditions when there are any, and otherwise from the audio of the first video rendition that has some. The audio of the other video renditions is ignored.
Alignment
Players switch between renditions at segment boundaries, so every video rendition must have the same segments. The composite refuses the asset unless all video renditions have the same number of segments with the same start times, within one sample of the coarser rendition, and the same start offset. The error names the two renditions and the first segment that differs. Keyframe placement in the sources decides this, so it is a property of the encodes and not something segmentor can repair.
Output
- HLS. One
#EXT-X-STREAM-INFper video rendition, sorted by bandwidth, each with its ownBANDWIDTH,AVERAGE-BANDWIDTH,CODECS, andRESOLUTION, all pointing at the shared audio group. - DASH. One video
AdaptationSetwith aRepresentationper rendition (id="video-{id}") andsegmentAlignment="true". - I-frame playlists (section 1) are emitted for the lowest-bandwidth rendition only, since scrubbing does not need more.
Limits and failure
limits.max_renditions (default 8). Each rendition counts toward max_index_bytes like any asset. Loading is all or nothing: if any rendition fails, the asset fails with the rendition named, so a viewer never gets a ladder with a silent gap. The composite’s version hashes the mapper’s version and every rendition’s version.
Testing
Fixtures made from one source at different resolutions and bitrates with a fixed keyframe interval, so they align; a fixture with misaligned keyframes for the refusal; an audio-only rendition. A conformance case, and Chrome switching level in hls.js and dash.js with playback continuing.
DRM (later)
Not designed here. To keep it possible, these designs keep init segment generation per rendition and per track, and keep rendition and subtitle handling separate from how a sample entry is copied, because encrypted sources add sinf, tenc, and pssh boxes to the init segment and senc to fragments, and need key-system signaling in HLS and DASH. Encrypted sample entries (encv, enca) stay rejected until that design is accepted.
Security and limits
Every new input goes through an existing gate: subtitle and rendition URLs through the [remote_media] policy, sizes through the new limits, counts through max_renditions and the subtitle limit, and parsing through the same bounded, checked-arithmetic style as the media parser. A hostile subtitle file can fail its asset and cannot allocate past its limit. I-frame resources are generated from the existing index and add no new input.
Observability
Load events gain the number of renditions, subtitles, and keyframes served. Refusals (misaligned renditions, bad subtitles) are asset_load_failed with the specific reason.
Rollout
Three independent changes, in order. The first needs no mapper change and can ship alone. The second and third extend TDD 0002 and the mapper API reference, additively. Each ships with its own tests and documentation, and none changes the output for an asset that does not use it.
Open questions
- IDR versus other sync samples. Should the I-frame playlist inspect the NAL unit type of each keyframe, so open-GOP HEVC files do not list pictures that cannot decode alone?
- I-frame streams per rendition. Is one stream, from the lowest rendition, enough for Apple’s seek bar, or should each rendition have one?
- Default subtitle track. When the mapper marks none as
default, should segmentor pick none, or the first? - Other subtitle formats. SRT is common in source libraries; converting it to WebVTT is small, but it should be a decision and not a surprise.