# HLS VOD Playback for Browser Date: 2026-05-29 | Status: design → implementation Authors: Zac + Claude ## Purpose Replace the browser playback path for **recorded (VOD) assets** with HLS, retiring the MP4 range-stitching workaround. The MP4 proxy is **kept** (supplements, not replaces) because the Premiere UXP panel and conform pipeline consume it. ## Background — current state - `GET /assets/:id/stream` returns `{ url: /api/v1/assets/:id/video, type: 'mp4' }` for ready assets. - `GET /assets/:id/video` streams `proxies/.mp4` through Node with the **RustFS range-stitching hack** (`stitchedS3Stream`): RustFS mis-serves ranged GETs whose start offset is past ~5.8 MB, so the endpoint streams from byte 0 and drops bytes. Works, but wastes bandwidth/CPU per seek and is fragile. - **Live** assets already use HLS (`type: 'hls'`, `/live//index.m3u8`), and `hls.js` is already loaded and wired in `screens-asset.jsx` for `type === 'hls'`. - The proxy worker (`services/worker/src/workers/proxy.js`) produces a single H.264/AAC/yuv420p MP4 — already HLS-compatible. ## Decisions - **Supplement, not replace.** Keep `proxies/.mp4`; add an HLS rendition. - **Generate in the proxy worker** via fast remux (`-c copy`) — no re-encode. - **Serve segments through mam-api** as whole-file GETs (no Range) — sidesteps the RustFS range bug entirely and reuses session auth. ## Architecture ### 1. Generation (worker/proxy.js) After uploading `proxies/.mp4`, remux it to HLS into a temp dir: ``` ffmpeg -i -c copy -f hls \ -hls_time 6 -hls_playlist_type vod -hls_flags independent_segments \ -hls_segment_filename /seg_%03d.ts /index.m3u8 ``` Upload every file in the temp dir to `hls//` (playlist + `.ts`). Set `assets.hls_s3_key = 'hls//index.m3u8'`. Remux is seconds; failure is non-fatal (MP4 path still works as fallback). ### 2. Storage / schema Migration adds `assets.hls_s3_key TEXT` (nullable). Presence = HLS available. Segment objects live under `hls//seg_NNN.ts`; playlist references **relative** segment names so the serving endpoint is path-agnostic. ### 3. Serving (mam-api) New `GET /assets/:id/hls/:file` (file = `index.m3u8` or `seg_NNN.ts`): - Validate `:file` against `^(index\.m3u8|seg_\d+\.ts)$` (no traversal). - Whole-object GET of `hls//` from S3 — **no Range handling**. - Content-Type: `application/vnd.apple.mpegurl` (m3u8) / `video/mp2t` (ts). - `Cache-Control: private, max-age=3600` for segments; `no-cache` for the playlist. - Covered by the existing `requireAuth` gate; `hls.js` carries the same-origin session cookie (same mechanism the live HLS path already relies on). ### 4. Stream selection (mam-api `/stream`) For non-live assets: if `hls_s3_key` is set → `{ url: '/api/v1/assets/:id/hls/index.m3u8', type: 'hls' }`. Else fall back to the existing MP4 `/video` response. Live unchanged. ### 5. Backfill (existing assets) Add an `hls` BullMQ job + `POST /assets/:id/reprocess?type=hls`: downloads the existing `proxy_s3_key`, remuxes to HLS, uploads, sets `hls_s3_key`. No re-encode. ### 6. Frontend No change required — `screens-asset.jsx` already plays `type: 'hls'` via `hls.js`. Verify `hls.js` xhr carries credentials (same-origin cookie) for the proxied segments; add `xhrSetup` withCredentials only if needed. ## Out of scope - Multi-bitrate/ABR ladders (single rendition for now). - Replacing the MP4 proxy or the `/video` endpoint (kept as fallback + for panel). - Live-asset playback changes (already HLS). ## Test plan 1. Upload/capture an asset → proxy job produces MP4 **and** `hls//index.m3u8`. 2. `/stream` returns `type: 'hls'`; `/assets/:id/hls/index.m3u8` → 200 m3u8; `/assets/:id/hls/seg_000.ts` → 200 `video/mp2t`, whole-file (no 206/Range). 3. Browser: asset plays + seeks via hls.js (no range-stitching path hit). 4. `reprocess?type=hls` backfills an older asset; it then plays via HLS. 5. MP4 proxy + `/hires` download still work (panel workflow intact).