Files
linearvoid/docs/STREAMING.md
T
zwitschi d8cf853842
Release / release (push) Successful in 1m15s
CI / test (push) Successful in 2m53s
CI / docker (push) Successful in 2m0s
chore(release): 1.1.0
- playout: run-scoped R2 manifest URIs (candidate a rewrite via rewriteManifestToRun)
- tests: playlist_rewrite_test.go (rewrite + publish integration + slate guard)
- docs: STREAMING, cloudflare-workers-setup, architecture (incl. Level-2 diagram)
- release housekeeping: TODO->DONE, CHANGELOG 1.1.0, version bump
2026-09-03 09:27:38 +02:00

12 KiB
Raw Blame History

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

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:

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:

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:

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}/<name>. 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.:

#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:

curl -I https://media.linearvoid.tv/hls/playlist.m3u8
curl -I https://media.linearvoid.tv/hls/<segment>.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 <video> element.
  • Start at the live edge with a conservative buffer; do not seek to playlist start.
  • Treat the manifest as the sync source; target ~3 segments behind the newest unless testing supports a smaller margin.
  • Recover transient network/media errors with bounded retries + exponential backoff.
  • On fatal hls.js network error, reload the manifest; on fatal media error, attempt media recovery once before recreating the player.
  • If tab suspension/drift leaves playback materially behind the target, seek forward rather than play the backlog.
  • Surface a clear reconnecting state; never silently loop stale media.
  • Schedule/chat timestamps should account for measured player latency, not wall-clock playout.

Failure and recovery

The service retries an item failure with bounded backoff. Past the failure threshold it switches to a pre-normalized looping slate and reports mode=slate, periodically retrying normal playout, returning only after a segment and committed manifest publish successfully.

On process/host restart:

  • create a new run/session ID and continue with unique segment paths;
  • resume from schedule-derived current position when reliable state exists, else start the current playlist item;
  • never overwrite old segment objects;
  • keep the last valid manifest available until the replacement is playable;
  • emit a discontinuity when timeline continuity isn't guaranteed.

/health must report more than "process running": at least mode (live/slate/starting/degraded), active item, playlist revision, run ID, last segment sequence/time, last successful R2 publication, consecutive failures, uptime. Mark unhealthy if the manifest or media sequence hasn't advanced for more than two segment durations.

Monitoring and acceptance

Monitor/alert on: manifest age and media-sequence advancement; time since last published segment; FFmpeg restarts and consecutive failures; live/slate mode; R2 upload latency/errors; segment 404/5xx and manifest/segment latency; Cloudflare cache-hit ratio, origin requests, R2 Class B ops; player startup, fatal errors, rebuffer ratio, live-edge drift; local staging disk usage.

A release is streaming-ready only when: one full playlist boundary plays without timestamp errors; the manifest never references a missing object; a second viewer joins the same timeline; cache headers match the table; a forced FFmpeg failure activates slate; and normal playout recovers without reusing an object URL.

Required configuration

Variable Purpose
PLAYLIST_PATH Active playlist file
HLS_SEGMENT_DURATION Target segment duration; baseline 2
HLS_WINDOW_SIZE Manifest window; baseline 12
R2_ENDPOINT, R2_REGION S3-compatible publication endpoint
R2_BUCKET_NAME Media bucket
R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY Write credentials, publisher only
R2_KEEP_RUNS Run folders to retain (default 2)
R2_MAX_RETENTION_BYTES Max bytes across run folders (10 GiB)
MEDIA_DOMAIN Public R2 custom domain
VITE_PUBLIC_HLS_URL Public live manifest URL

Production values: MEDIA_DOMAIN=media.linearvoid.tv, VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8. Local fallback may use http://localhost/hls/playlist.m3u8.

Explicitly deferred

ABR ladders, LL-HLS, DVR/catch-up, multi-origin playout, and regional chat affinity are outside the v1 contract. Adding any requires an ADR and an update to this document.