# Streaming Pipeline Operational contract for Linear Void video ingest, playout, publication, caching, and browser playback. Deployment, chat, admin details live in `DEPLOYMENT.md`, `SETUP.md`, `API.md`. ## Production flow ```mermaid flowchart TD A["Normalized MP4 assets"] --> B["Playlist and schedule"] B --> C["Playout service and FFmpeg"] C --> D["Local HLS staging"] D --> E["R2 object storage"] E --> F["Cloudflare edge"] F --> G["hls.js or native HLS"] ``` The playout service is the timeline authority. It reads the ordered playlist, runs FFmpeg continuously, stages a live HLS window under `data/hls/`, and publishes complete objects to R2. Viewers read only the public R2 custom domain through Cloudflare: ```text https://media.linearvoid.tv/hls/playlist.m3u8 ``` Nginx `/hls/` is a local/test fallback, not the production origin. The SPA, admin API, and chat use `linearvoid.tv`; media uses `media.linearvoid.tv`. ## Source-media contract Only validated files may enter the production playlist. | Property | Required profile | | ---------- | ----------------------------------------------------------------------- | | Container | MP4 | | Video | H.264/AVC, `yuv420p` | | Resolution | 1280x720 or 1920x1080; avoid resolution changes within a playlist block | | Frame rate | Constant 30 fps | | GOP | Closed, fixed 2 seconds: `-g 60 -keyint_min 60 -sc_threshold 0` | | Audio | AAC-LC, stereo, 48 kHz, 128–192 kbit/s | The ingest gate uses `ffprobe` to reject incompatible codecs, variable frame rate, missing audio, invalid dimensions, or wrong keyframe cadence. Assets are immutable after acceptance; corrections create a new asset ID/object key. Reference normalization: ```bash ffmpeg -i input.mov \ -c:v libx264 -preset medium -crf 21 \ -r 30 -g 60 -keyint_min 60 -sc_threshold 0 \ -pix_fmt yuv420p \ -c:a aac -b:a 160k -ar 48000 -ac 2 \ -movflags +faststart \ output-normalized.mp4 ``` ## Playlist and timeline - `data/playlist/playlist.txt`: one local media path per line; blank lines and `#` comments ignored. - Service validates the complete playlist before activating a revision. - Playlist edits are atomic and get a revision ID. The active FFmpeg run keeps its current item and adopts the new revision at the next item boundary. - All viewers follow the same live manifest; clients never calculate the schedule independently. - Schedule metadata derives from the same active revision/timeline so "now/next" agrees with the video. - When the last item ends, playout returns to the first unless an active schedule override says otherwise. ## Playout and HLS packaging The playout service wraps FFmpeg, tracks the active item/revision, restarts failed processes, and exposes `/health` plus Prometheus metrics. | Setting | Value | | ---------------- | ------------------------------------------------------------------------ | | Protocol | Standard live HLS, not LL-HLS | | Segment format | fMP4 (`.m4s`) with init segment; `.ts` may be supported during migration | | Segment target | 2 seconds (`HLS_SEGMENT_DURATION=2`) | | Live window | 12 segments (`HLS_WINDOW_SIZE=12`), ~24 seconds | | Codec path | Stream copy for conforming assets; no per-viewer transcoding | | Expected latency | 15–35 seconds | Segment boundaries must align with keyframes; timestamps must stay monotonic across item boundaries. If FFmpeg cannot preserve a seamless timeline, insert `#EXT-X-DISCONTINUITY` and publish the corresponding new init segment. **Segment duration rationale**: 2s lowers latency and keeps the window small (12 × 2s = 24s) while staying above the manifest TTL (1–2s). The 2s GOP (30fps, `-g 60`) already matches the target, so segments cut cleanly on keyframes. Longer (4–6s) raises latency and window depth; shorter than 2s inflates segment/request overhead and R2 Class B ops with little gain. Every segment name is globally unique and never reused, including after restart or playlist loop — use a run/session ID plus a monotonically increasing media sequence: ```text hls/r-20260807T170412Z/init-000001.mp4 hls/r-20260807T170412Z/seg-000001234.m4s ``` A URL advertised as immutable must never later contain different bytes. ## Safe publication to R2 Order prevents a manifest referencing an unavailable segment: 1. FFmpeg writes a segment to a temp local name and closes it. 2. Publisher uploads the init segment (if new) and media segment to final immutable R2 keys. 3. Verify upload success; record object size/checksum. 4. Render the next manifest referencing only confirmed objects. 5. Upload the manifest to a temp key, then replace `hls/playlist.m3u8` as the final commit step. Never upload an actively written segment or publish the manifest first. Use a single publisher (or fencing/leader election) so two workers can't update the live manifest concurrently. **Run-scoped manifest URIs.** Segments and the init file are stored run-scoped under `hls/r-{runID}/…`, so the live `hls/playlist.m3u8` must reference them with that run prefix. Before the final commit step, the publisher rewrites each bare relative media URI (segments plus `#EXT-X-MAP:URI`) to `r-{runID}/`. `publishManifest` in `backend/playout/src/playlist.go` does this via `rewriteManifestToRun`, using the same `runID` under which the segments were uploaded; the rewrite is skipped in slate mode (its manifest is served as-is). The published manifest therefore contains, e.g.: ```m3u8 #EXT-X-MAP:URI="r-20260807T170412Z/init.mp4" #EXTINF:2.000000, r-20260807T170412Z/seg-000001234.m4s ``` The `linearvoid-media` Worker on `media.linearvoid.tv` is a faithful path→R2 mapper and serves these run-scoped paths directly; it does **not** rewrite URIs at serve time. `data/hls/` is regenerable staging. Source media, playlist revisions, and schedule state are authoritative; generated HLS is not backed up as application state. ## Cache contract | Objects | Edge policy | Browser policy | Required headers/behavior | | -------------------------- | --------------------------- | --------------------------------------- | ---------------------------------------------------------------------------- | | `.m4s`, `.ts`, init `.mp4` | Cache eligible; TTL 1 year | TTL 1 year | `Cache-Control: public, max-age=31536000, immutable`; full path in cache key | | Live `.m3u8` | Cache eligible; edge TTL 2s | Do not retain beyond freshness interval | `Cache-Control: public, max-age=1, s-maxage=2, must-revalidate` | | Versioned JS/CSS | Cache eligible; TTL 1 year | TTL 1 year | Content-hashed filenames and `immutable` | Further rules: - No query-string cache busters on segment URLs; uniqueness belongs in the path. - Cache keys preserve hostname and full path; ignore only explicitly approved tracking params. - HLS responses need `Access-Control-Allow-Origin: https://linearvoid.tv` (or reviewed allowlist), `GET`/`HEAD`/`OPTIONS`, and correct MIME types. - Enable Smart Tiered Cache for `media.linearvoid.tv` to cut R2 reads on regional misses. - Purge manifests when necessary, never immutable segments during normal operation. - Retain expired-window segments at least max manifest TTL + retry/grace time; practical minimum 1 hour; lifecycle deletion may run later per storage policy. Validation: ```bash curl -I https://media.linearvoid.tv/hls/playlist.m3u8 curl -I https://media.linearvoid.tv/hls/.m4s ``` The manifest must refresh within seconds; repeated segment requests should progress `CF-Cache-Status: MISS` → `HIT`, keep the same ETag/content length, and carry the immutable cache header. ## Player behavior SPA uses native HLS where supported, hls.js elsewhere. - Firefox has no native HLS → hls.js/MSE path; fMP4 (`.m4s` + init) is supported. Autoplay needs `muted` + `playsinline`, already set on the `