playout
July 28, 2026 · View on GitHub
The playout server for the A Dana Life dashcam slow-TV stream: it loops a directory of dashcam clips and publishes them as one continuous, gapless RTSP stream that OBS composites and restreams.
It replaces the libvlc-based vlc-server in
tripbot. libvlc's stream output
terminates the RTP stream at every clip boundary, forcing the consumer to
reconnect per clip (a 1.5–3.5s visible seam); splicing clips without
re-encoding corrupts the decoder instead (inter-frames referencing content
from the previous clip). This server removes both failure modes structurally:
- clips are decoded, normalized, and fed through one long-lived encoder — inputs swap in front of it, so the output is a single unbroken H.264 stream with no per-clip EOF and no stale-reference corruption.
ENCODER=passthroughskips decode and encode entirely: the airing corpus is transcoded to one uniform spec (identical params, IDR-leading closed GOPs), which is what makes splicing the compressed streams safe, andh264parsere-sends SPS/PPS at every IDR so each splice and every late joiner resyncs. Stage and prod both run this — x264 can't hold 1080p60 realtime on the minipc.
Architecture
Rust on gstreamer-rs.
$\text{text} \text{playlist} \text{manager} (\text{active} \text{clip} + \text{prerolled} \text{next}) → \text{uridecodebin3} \text{per} \text{clip} (\text{gapless} \text{input} \text{swaps}) → \text{concat} (\text{rewrites} \text{segments} \text{so} \text{running} \text{time} \text{never} \text{resets} \text{across} \text{boundaries}) → \text{decode} \text{path} \text{only}: \text{videoconvert} ! \text{videoscale} ! \text{videorate} ! \text{capsfilter} (1920 \times 1080 @ 60\text{fps}) → \text{tee} ├─ \text{encode}: [\text{x264enc} / \text{vah264enc} →] \text{h264parse} → \text{rtspclientsink} → \text{MediaMTX} ├─ \text{map}-\text{only}: \text{fakesink}, \text{when} \text{the} \text{MediaMTX} \text{relay} \text{is} \text{parked} — \text{the} \text{pipeline} │ \text{keeps} \text{playing} \text{so} \text{the} \text{console} \text{map} \text{still} \text{advances} └─ \text{window}: \text{queue} → \text{autovideosink} (\text{optional} \text{local} \text{preview}) $
MediaMTX sits between playout and its consumers so the OBS-facing RTSP endpoint survives playout restarts, and off-cluster viewers get TCP transport.
Control plane
- NATS commands on
tripbot.<env>.vlc.<verb>.<platform>(fire-and-forget):play.random,play.file,play.at,skip,back,seek. The platform leaf keeps instances isolated — a Twitch-triggered skip can't advance the YouTube stream. tripbot'splayout-clientis the publisher. - Resume: the active clip + position are republished every 5s to the
TRIPBOT_VLC_LASTPLAYEDJetStream last-value cache, which a restarting instance reads to pick up where it left off. NATS being down degrades to looping the corpus uncommanded, never to no stream. - HTTP on
:8080:/health/live,/health/ready(ready = pipeline PLAYING),/version,/vlc/current(bare basename of the active clip),/debug/pipeline(live topology as Graphviz). - Metrics: OTLP push to Grafana Cloud, gated on
OTEL_EXPORTER_OTLP_ENDPOINTso local runs export nothing. - Watchdog: an RTSP DESCRIBE probe every 30s, since
rtspclientsinkin RECORD mode reports PLAYING without proving data flow. Three consecutive failures exit non-zero for a k8s restart.
Local development
brew install mise go-task gstreamer mediamtx ffmpeg
mise install # rust, pinned in .tool-versions
pre-commit install
task mediamtx # terminal 1: local RTSP server on :8554
VIDEO_DIR=~/clips task run # terminal 2: publish the loop
task play # terminal 3: watch it
task probe streams packet timestamps off the RTSP feed — the check for
boundary EOFs and PTS discontinuities.
curl localhost:8080/debug/pipeline | dot -Tsvg > pipe.svg dumps the live
pipeline topology (elements, pads, negotiated caps) — handy for confirming
the passthrough-vs-encode wiring on a running pod. Or point
gst-dots-viewer at the saved .dot.
Releasing
Trunk-based main + release-please, with towncrier changelog fragments:
- Feature PRs target
main(squash-merge, conventional title); each adds a fragment (task changelog:add TYPE=<type>— no PR number needed, CI fills it in on push) or carries theskip-changeloglabel. dev-image.ymlfloatsghcr.io/adanalife/playout:mainon every main push — what stage deploys.release-please.ymlmaintains a standing release PR that bumps the version, the prod pin (cdk8s/versions.yaml), and the committed dist from the conventional commits, and collates thechangelog.d/fragments intoCHANGELOG.mdon the PR branch.- To ship: squash-merge the release PR. That tags
vX.Y.Z, creates the GitHub Release, and dispatchesrelease.ymlto build the image to GHCR. No manual version/changelog steps — the version follows from the commit types (feat:→ minor,fix:→ patch,feat!:/BREAKING CHANGE→ major).