Skip to content

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 — staging has other-chat WIP across api-client / OpenAPI / exercises / stats. Cut feat/tv-baseline-mp4 off staging HEAD 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 4

Source: 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.mp4

Same (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.Compose after 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.
  • [ ] F8.2 Upload to Bunny Storage at platform/exercises/{slug}/renders/{rh12}.{sha12}.baseline-{480p,720p}.mp4 via existing bunnyStore.UploadBytes.
  • [ ] F8.3 Extend Result struct + /v1/exercises/compose response with:
    tv_mp4_urls: { baseline_480p: string, baseline_720p: string }
    Both URLs absolute, Storage CDN host. No nullable from the producer side — if the bake succeeded, both rungs are present.
  • [ ] 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 NULL to exercise_renders table (single new migration).
    • JSONB (not two TEXT columns) so a future main_1080p rung 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_only class, no external egress targets (URLs only, no PHI).
  • [ ] F8.6 Pass-through in services/api/internal/integration/media/ — accept tv_mp4_urls in the compose response, persist to exercise_renders.tv_mp4_urls.
  • [ ] F8.7 Add tv_mp4_urls field to SessionPayload assembly in services/api/internal/core/domain/sessions/. Field is nullable at the wire level (legacy renders ⇒ omitted/null).
  • [ ] F8.8 Reconciler — extend ReconcileRendersFromBunny in services/api/internal/core/domain/exercises/reconcile.go to walk platform/exercises/{slug}/renders/, parse filenames against ^([0-9a-f]{12})\.([0-9a-f]{12})\.baseline-(480p|720p)\.mp4$, group by (rh12, sha12), and hydrate exercise_renders.tv_mp4_urls for 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] where QUALITY is the existing TV-side constant. Pre-resolve before kicking the player.
  • [ ] F8.11 Fallback path — when tv_mp4_urls is null or missing the picked key, fall back to today's https://{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 current play_{quality}.mp4 URL. 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 NULL and 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:

  1. PR 1 — Media producer (F8.1–F8.4). Just writes to Storage + emits the field. API silently ignores the new field until PR 2.
  2. PR 2 — API persistence + transport (F8.5–F8.9). Schema + integration + SessionPayload + reconciler + OpenAPI. SessionPayload starts emitting tv_mp4_urls: null for legacy renders and the real URLs for new ones.
  3. 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.
  4. 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 returns tv_mp4_urls.
  • [ ] exercise_renders.tv_mp4_urls populated for all new rows; NULL for pre-feature rows.
  • [ ] SessionPayload includes tv_mp4_urls field (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 = null on 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/ on make migrate-reset and re-populates tv_mp4_urls from 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:1281 from the upstream encoder. The setsar=1:1 filter in the spec normalizes; verify ffprobe on first output confirms square pixels before declaring it done.
  • Audio -c:a copy validity. 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 copy ever 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_urls updates 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.