dragonflight/docs/superpowers/specs/2026-05-29-hls-vod-playback-design.md

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/stream returns { url: /api/v1/assets/:id/video, type: 'mp4' } for ready assets.
  • GET /assets/:id/video streams proxies/<id>.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/<id>/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/<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 :file against ^(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=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/<id>/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).