From a28dc43ed5863771ad6118fd7696301407f16256 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Fri, 29 May 2026 16:13:29 -0400 Subject: [PATCH] docs: HLS VOD playback design (supplement MP4 proxy) --- .../2026-05-29-hls-vod-playback-design.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-29-hls-vod-playback-design.md diff --git a/docs/superpowers/specs/2026-05-29-hls-vod-playback-design.md b/docs/superpowers/specs/2026-05-29-hls-vod-playback-design.md new file mode 100644 index 0000000..20f0acb --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-hls-vod-playback-design.md @@ -0,0 +1,84 @@ +# 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).