4 KiB
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/streamreturns{ url: /api/v1/assets/:id/video, type: 'mp4' }for ready assets.GET /assets/:id/videostreamsproxies/<id>.mp4through 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/<id>/index.m3u8), andhls.jsis already loaded and wired inscreens-asset.jsxfortype === '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/<id>.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/<id>.mp4, remux it to HLS into a temp dir:
ffmpeg -i <proxy.mp4> -c copy -f hls \
-hls_time 6 -hls_playlist_type vod -hls_flags independent_segments \
-hls_segment_filename <tmp>/seg_%03d.ts <tmp>/index.m3u8
Upload every file in the temp dir to hls/<assetId>/ (playlist + .ts). Set
assets.hls_s3_key = 'hls/<assetId>/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/<assetId>/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
:fileagainst^(index\.m3u8|seg_\d+\.ts)$(no traversal). - Whole-object GET of
hls/<id>/<file>from S3 — no Range handling. - Content-Type:
application/vnd.apple.mpegurl(m3u8) /video/mp2t(ts). Cache-Control: private, max-age=3600for segments;no-cachefor the playlist.- Covered by the existing
requireAuthgate;hls.jscarries 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
/videoendpoint (kept as fallback + for panel). - Live-asset playback changes (already HLS).
Test plan
- Upload/capture an asset → proxy job produces MP4 and
hls/<id>/index.m3u8. /streamreturnstype: 'hls';/assets/:id/hls/index.m3u8→ 200 m3u8;/assets/:id/hls/seg_000.ts→ 200video/mp2t, whole-file (no 206/Range).- Browser: asset plays + seeks via hls.js (no range-stitching path hit).
reprocess?type=hlsbackfills an older asset; it then plays via HLS.- MP4 proxy +
/hiresdownload still work (panel workflow intact).