TV Baseline MP4 — Implementation Plan
Per-render H.264 Baseline-profile MP4s for Tizen 4 / legacy TVs that can't decode Bunny Stream's High-profile HLS ladder. Deferred-but-reserved workstream first sketched in media owner brief gotcha #8; spec settled between TV-app + media owners on 2026-05-26.
Status
- Spec: ✅ Settled (encoder, transport, fallback, ownership).
- Branch: ⏸ Hold —
staginghas other-chat WIP across api-client / OpenAPI / exercises / stats. Cutfeat/tv-baseline-mp4offstagingHEAD once that WIP merges. - Scope gate: Independent of 1D admin surfaces — proceeds in parallel with 1D once the branch is clear (per foundation.md → Current gate).
- Layer: F-tier-adjacent (TV-app enablement). Not in the foundation gate, not blocked by F1–F12 ordering — touches media + API + TV-app only.
Background
Bunny Stream's HLS transcode emits High-profile + B-frames in every variant. Tizen 4 / SamsungBrowser 2.1 decoders can't handle that combination — playback fails with MEDIA_ERR_DECODE or hangs at start. The TV app today reads https://{video_cdn_hostname}/{video_id}/play_{quality}.mp4 (Bunny's MP4 fallback URL); the underlying file inherits the same High-profile encoding, so the fallback doesn't actually fix Tizen 4.
The plan is to bake separate Baseline-profile MP4s during the media-service compose step, store them on Bunny Storage Zone alongside the cue manifest + thumbnails, surface them on SessionPayload, and have the TV app read them directly. The TV path does not go through Bunny Stream's HLS; it bypasses to a static .mp4 URL on Storage CDN.
Encoder spec (locked)
H.264 Constrained Baseline @ Level 3.1, two rungs per render.
-c:v libx264
-profile:v baseline
-level 3.1
-bf 0
-pix_fmt yuv420p
-preset veryfast
-crf 23 # caps: 480p ≤ 1.5 Mbps, 720p ≤ 4 Mbps
-vf scale=854:480,setsar=1:1 # 480p rung
-vf scale=1280:720,setsar=1:1 # 720p rung
-c:a aac # or `-c:a copy` if final concat audio is already 48k AAC-LC 128k stereo
-profile:a aac_low
-ar 48000
-ac 2
-b:a 128k
-movflags +faststart # moov atom at head, required for progressive playback on Tizen 4Source: the final recipe.mp4 produced by the existing concat re-encode at compose.go:495-498. Two encodes (480p, 720p) off that single file. The composer's own concat pipeline is unchanged — the baseline passes are additive.
Validated reference: https://restartix-public-staging.b-cdn.net/lumbar-2x5-baseline-{480p,720p}.mp4, encoded by the TV owner from the same composer output, playback-verified on Tizen 4 / SamsungBrowser 2.1 + newer Samsung TVs. Re-verify on the same hardware against the pipeline output before backfilling the library.
Storage layout
Already reserved in media brief gotcha #8:
platform/exercises/{slug}/renders/{recipe_hash[:12]}.{sha12}.baseline-480p.mp4
platform/exercises/{slug}/renders/{recipe_hash[:12]}.{sha12}.baseline-720p.mp4Same (rh12, sha12) filename pattern as the cue manifest at platform/exercises/{slug}/cues/{rh12}.{sha12}.json — so the reconciler can match renders/ entries to cues/ entries by filename prefix without DB joins.
Served via Storage CDN host (BUNNY_STORAGE_CDN_HOSTNAME), distinct from Stream CDN (BUNNY_STREAM_CDN_HOSTNAME).
Cross-service work
Media service (producer) — services/media/
- [ ] F8.1 Add baseline encode step to
composer.Composeafter concat finishes + sha256 is computed.- Two ffmpeg passes (480p + 720p) off the final
recipe.mp4. Start sequential; profile bake-time impact on Fargate task sizing; flip to parallel only if measured benefit (see Open items). - Audio
-c:a copy— the final concat is already 48k AAC-LC 128k stereo, no re-encode needed.
- Two ffmpeg passes (480p + 720p) off the final
- [ ] F8.2 Upload to Bunny Storage at
platform/exercises/{slug}/renders/{rh12}.{sha12}.baseline-{480p,720p}.mp4via existingbunnyStore.UploadBytes. - [ ] F8.3 Extend
Resultstruct +/v1/exercises/composeresponse with:Both URLs absolute, Storage CDN host. No nullable from the producer side — if the bake succeeded, both rungs are present.tv_mp4_urls: { baseline_480p: string, baseline_720p: string } - [ ] F8.4 Update services/media/README.md — drop "(reserved — deferred)" from the
renders/line; document the response field + encoder flags. - [ ] Verify with
cd services/media && make check.
API (persistence + transport) — services/api/
- [ ] F8.5 Add
tv_mp4_urls JSONB NULLtoexercise_renderstable (single new migration).- JSONB (not two TEXT columns) so a future
main_1080prung is an additive value-level change, not a schema change. - NULL = legacy render baked before this feature shipped (lazy backfill — see below).
- Data classification registry entry per CLAUDE.md → Data Egress:
internal_onlyclass, no external egress targets (URLs only, no PHI).
- JSONB (not two TEXT columns) so a future
- [ ] F8.6 Pass-through in
services/api/internal/integration/media/— accepttv_mp4_urlsin the compose response, persist toexercise_renders.tv_mp4_urls. - [ ] F8.7 Add
tv_mp4_urlsfield to SessionPayload assembly inservices/api/internal/core/domain/sessions/. Field is nullable at the wire level (legacy renders ⇒ omitted/null). - [ ] F8.8 Reconciler — extend
ReconcileRendersFromBunnyin services/api/internal/core/domain/exercises/reconcile.go to walkplatform/exercises/{slug}/renders/, parse filenames against^([0-9a-f]{12})\.([0-9a-f]{12})\.baseline-(480p|720p)\.mp4$, group by(rh12, sha12), and hydrateexercise_renders.tv_mp4_urlsfor matching(exercise_id, recipe_hash)rows. - [ ] F8.9 OpenAPI spec update for the SessionPayload field; regenerate
packages/api-client/src/generated.ts. - [ ] Verify with
make check.
TV app (consumer) — apps/tv/
- [ ] F8.10 apps/tv/player.js:143 — read
ex.tv_mp4_urls[QUALITY]whereQUALITYis the existing TV-side constant. Pre-resolve before kicking the player. - [ ] F8.11 Fallback path — when
tv_mp4_urlsisnullor missing the picked key, fall back to today'shttps://{video_cdn_hostname}/{video_id}/play_{quality}.mp4. Log the fallback for observability; no user-facing UI change. Existing "errored" state covers terminal failure. - [ ] F8.12 Confirm Tizen 4 hardware playback against the actual pipeline output (not the hand-encoded reference) before declaring the rollout safe to backfill.
Backfill
Lazy — the default. Decided in chat 2026-05-26.
- New renders (composed after F8.1 ships) carry
tv_mp4_urls; the TV reads them directly. - Old renders have
tv_mp4_urls = NULL; the TV falls back to the currentplay_{quality}.mp4URL. New TVs continue to work via the fallback already in production today; old TVs continue to fail (no regression — they fail today too). - Library converges over time as recipes re-bake (asset-version bumps, recipe changes, manual re-compose).
- No migration script writes baseline MP4s for existing renders. If we ever decide we want eager backfill, the reconciler is the obvious place — walk
exercise_renders WHERE tv_mp4_urls IS NULLand queue compose jobs. Out of scope for this plan.
Sequencing
PRs land in this order — producer first, then consumer can ship safely with the fallback already in place:
- PR 1 — Media producer (F8.1–F8.4). Just writes to Storage + emits the field. API silently ignores the new field until PR 2.
- PR 2 — API persistence + transport (F8.5–F8.9). Schema + integration + SessionPayload + reconciler + OpenAPI. SessionPayload starts emitting
tv_mp4_urls: nullfor legacy renders and the real URLs for new ones. - PR 3 — TV consumer (F8.10–F8.11). Read the field, fall back when absent. Safe to ship at any point after PR 2 because the field shape is null-tolerant by design.
- Validation pass (F8.12). Tizen 4 hardware verification before announcing TV support to clinics.
PRs are independently revertable. PR 1 alone is harmless (writes go to Storage, no one reads them). PR 2 alone is harmless (fields land in SessionPayload but TV ignores them). PR 3 alone needs PR 2 (no field → fallback path → behaves like today).
Exit criteria
- [ ] Every new compose call writes both rungs to
renders/and returnstv_mp4_urls. - [ ]
exercise_renders.tv_mp4_urlspopulated for all new rows; NULL for pre-feature rows. - [ ] SessionPayload includes
tv_mp4_urlsfield (nullable). - [ ] TV reads the field and plays the picked rung on Tizen 4 hardware (verified against actual pipeline output, not the hand-encoded reference).
- [ ] Fallback path verified — manually setting
tv_mp4_urls = nullon a render still plays via the existing Bunny Stream URL. - [ ] Media owner brief gotcha #8 updated to reflect "shipped" instead of "deferred but reserved".
- [ ] Reconciler walks
renders/onmake migrate-resetand re-populatestv_mp4_urlsfrom Storage state.
Open items (resolve before code lands)
- Bake-time impact on Fargate. Sequential ffmpeg passes are the safe default; parallel may help on multi-core Fargate tasks but risks oversubscribing CPU mid-pipeline. Decision criterion: measure bake-time delta on staging with sequential first; only flip to parallel if (a) the delta exceeds 25s per render AND (b) the Fargate task has ≥4 vCPUs reserved.
- CRF vs bitrate-cap mode. TV owner verified CRF 23 hits the bitrate caps naturally. Stick with CRF for simplicity; revisit only if some pathological source produces oversized files (would surprise us — the source is already a re-encoded recipe.mp4, not raw footage).
- SAR normalization. TV owner noted the source MP4 has
SAR 1280:1281from the upstream encoder. Thesetsar=1:1filter in the spec normalizes; verify ffprobe on first output confirms square pixels before declaring it done. - Audio
-c:a copyvalidity. Composer's concat re-encode already emits AAC-LC 48k 128k stereo per compose.go:495-498. Stream-copy is safe in theory; re-confirm by ffprobe on the concat output before relying on it. If-c:a copyever produces a file Tizen 4 chokes on, fall back to explicit-c:a aac -ar 48000 -ac 2 -b:a 128k. - Reconciler ordering. The reconciler currently walks
cues/to rebuild render state.renders/walk needs to come after — the cue-manifest row is the parent record,tv_mp4_urlsupdates it. Order matters because the reconciler is "rebuild from S3+Bunny truth"; the join key is(exercise_id, recipe_hash)from the cue-manifest filename.
Related
- Media owner brief — gotcha #8 is the original deferred-but-reserved note.
- Exercise content pipeline — overall asset workflow.
- Composition feature spec — design intent for the bake pipeline (stale in places per CLAUDE.md; code wins on "what is").
- Cue manifest spec — sibling JSON manifest; do not conflate with the bundle manifest (S3) or the TV MP4 surface (this plan).