test(sandbox): add ffmpeg + Mux live/DVR test-stream scripts and orchestrator

apps/sandbox/scripts/live/ — spin up an ephemeral Mux live source for
exercising the live path in any HLS-capable sandbox page:
- create-stream.sh: create a low-latency Mux live stream (creds via
  --token-id/--token-secret flags or MUX_TOKEN_ID/MUX_TOKEN_SECRET env;
  --quiet for scripting)
- broadcast.sh: push an ffmpeg testsrc + tone feed to the stream
- dvr-url.sh: resolve active_asset_id to the DVR/EVENT playback URL
- live-test.sh: orchestrate create -> broadcast -> wait -> open the page,
  parameterized by --page (renderer-agnostic), --flavor sliding|dvr|both,
  --port, --params, --latency; deletes the stream on exit unless --keep
- README.md: quick-start, manual steps, and cross-renderer reuse notes

Credentials are read only from flags/env; none are committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 13:52:21 -07:00
co-authored by Claude Opus 4.8
parent 78f33556dc
commit da33b10952
5 changed files with 362 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
# Live / DVR test streams (ffmpeg + Mux Video)
Spin up an ephemeral live HLS source to exercise the live path in the sandbox.
Uses `ffmpeg`'s built-in test-pattern generator pushed to a Mux live stream.
The stream is **renderer-agnostic** — it's a normal Mux HLS source, playable by
any HLS-capable sandbox page (the SPF engine, an hls.js-backed player, or native
HLS). Reuse comes from pointing `--page` at a different template, not from
per-renderer scripts.
One broadcast yields **two** sources to test:
- **Sliding-window live** — the live stream's own playback id (small window that
rolls off the back).
- **DVR / EVENT** — the recording asset's playback id (`#EXT-X-PLAYLIST-TYPE:EVENT`,
grows from the start, seekable all the way back), reached via the stream's
`active_asset_id` while it's broadcasting.
Both are CMAF/fMP4, which is what the SPF MSE pipeline appends (it does not
transmux MPEG-TS, so generic TS live test streams won't play there).
## Prerequisites
- `ffmpeg`, `curl`, `jq` on `PATH`.
- A Mux access token (Mux dashboard → Settings → Access Tokens). Provide it via
env vars (**preferred** — flag values show up in the process list) or flags:
```sh
export MUX_TOKEN_ID=...
export MUX_TOKEN_SECRET=... # never commit these
```
- The sandbox dev server running: `pnpm dev` (from `apps/sandbox`).
## Quick start — `live-test.sh` (orchestrator)
Creates a stream, broadcasts, waits for it to go live, and opens the page.
Ctrl-C stops the broadcast and deletes the stream (use `--keep` to retain it).
```sh
cd apps/sandbox/scripts/live
# SPF segment-loading harness, sliding-window live (defaults):
./live-test.sh
# DVR / EVENT source on the SPF harness:
./live-test.sh --flavor dvr
# Open both flavors at once:
./live-test.sh --flavor both
```
Reuse across renderers — just change `--page` (and `--params` if the page
doesn't need SPF's `preload=auto` load quirk):
```sh
./live-test.sh --page live-hls-engine # bare SPF engine harness
./live-test.sh --page html-hls-video # hls.js-backed player component
./live-test.sh --page html-native-hls-video # native HLS (Safari)
```
Options: `--page`, `--flavor sliding|dvr|both`, `--port`, `--params`,
`--latency low|reduced|standard`, `--no-open`, `--keep`, `--token-id`,
`--token-secret`. See `./live-test.sh --help`.
## Manual steps (the individual scripts)
```sh
# 1. Create a low-latency live stream. Note the Stream ID, Stream key, playback URL.
./create-stream.sh # or: --token-id ID --token-secret SECRET --latency low
# 2. Broadcast a test feed (color bars + 440 Hz tone). Leave running; Ctrl-C to stop.
./broadcast.sh <STREAM_KEY>
# 3. Play it in the sandbox (default dev port 5173):
open "http://localhost:5173/spf-segment-loading/?src=https://stream.mux.com/<PLAYBACK_ID>.m3u8&muted=true&autoplay=true&preload=auto"
# 4. DVR / EVENT — resolve the recording asset's playback URL (stream must be
# broadcasting), then open it the same way:
./dvr-url.sh <STREAM_ID>
```
The `spf-segment-loading` page's Live / DVR panel classifies the source
(`sliding live` vs `DVR (… seekable)`) and the seek controls drive
seek-to-live / DVR back-seek.
## Notes
- Live streams are **ephemeral**. `live-test.sh` deletes its stream on exit
(unless `--keep`); the manual scripts leave it — clean up from the Mux
dashboard so they don't accumulate.
- `preload=auto` is in the default params because the SPF page needs it to
load+play a live source (autoplay alone isn't sufficient). Player-component
pages manage their own loading; override `--params` for them as needed.
- `--latency low` gives LL-HLS (≈2 s segments); `reduced` / `standard` test
other latencies.
- For an **on-demand** CMAF baseline (no live machinery), the sandbox default
`https://stream.mux.com/JX01bG8eB4uaoV3OpDuK602rBfvdSgrMObjwuUOBn4JrQ.m3u8`
is a public VOD asset.
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Push an ffmpeg-generated test feed (color bars + 440 Hz tone) to a Mux live
# stream. Run in its own terminal; Ctrl-C to stop. Mux turns this into both a
# sliding-window live playlist and (because the stream records) a growing
# DVR / EVENT playlist.
#
# Usage: ./broadcast.sh <STREAM_KEY>
# STREAM_KEY is printed by create-stream.sh.
set -euo pipefail
key="${1:-}"
if [ -z "$key" ]; then
echo "Usage: $0 <STREAM_KEY> (the stream key printed by create-stream.sh)" >&2
exit 1
fi
# -re paces input at real time; zerolatency + short GOP keep segments small for
# low-latency live. CMAF/fMP4 output is what the SPF MSE pipeline appends.
exec ffmpeg -re \
-f lavfi -i "testsrc=size=1280x720:rate=30" \
-f lavfi -i "sine=frequency=440:sample_rate=48000" \
-c:v libx264 -preset veryfast -tune zerolatency -g 60 -keyint_min 60 \
-b:v 2500k -pix_fmt yuv420p \
-c:a aac -b:a 128k -ar 48000 \
-f flv "rtmps://global-live.mux.com:443/app/$key"
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Create a low-latency Mux live stream for testing live / DVR playback in any
# HLS-capable sandbox page. Prints the stream id, stream key, RTMPS ingest URL,
# and the live playback URL.
#
# Requires: curl, jq, and Mux API credentials. Credentials resolve from (in
# precedence order) the --token-id / --token-secret flags, else the
# MUX_TOKEN_ID / MUX_TOKEN_SECRET environment variables. Get them from the Mux
# dashboard → Settings → Access Tokens; never commit them. (Prefer the env vars:
# flag values are visible in the process list.)
#
# Usage: ./create-stream.sh [--token-id ID] [--token-secret SECRET]
# [--latency low|reduced|standard] [--quiet]
# --quiet print only "<id> <key> <playback-id>" on one line (for scripting).
set -euo pipefail
token_id="${MUX_TOKEN_ID:-}"
token_secret="${MUX_TOKEN_SECRET:-}"
latency="low"
quiet=0
usage() { sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; }
while [ $# -gt 0 ]; do
case "$1" in
--token-id) token_id="$2"; shift 2 ;;
--token-secret) token_secret="$2"; shift 2 ;;
--latency) latency="$2"; shift 2 ;;
--quiet) quiet=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 1 ;;
esac
done
[ -n "$token_id" ] || { echo "missing Mux token id (--token-id or MUX_TOKEN_ID)" >&2; exit 1; }
[ -n "$token_secret" ] || { echo "missing Mux token secret (--token-secret or MUX_TOKEN_SECRET)" >&2; exit 1; }
# latency_mode "low" → LL-HLS (PART-INF, ~2s target duration). new_asset_settings
# makes the stream record, so its recording asset can serve the DVR / EVENT
# manifest (see dvr-url.sh).
resp=$(curl -sS -u "$token_id:$token_secret" \
-H 'Content-Type: application/json' \
-d "{\"latency_mode\":\"$latency\",\"playback_policy\":[\"public\"],\"new_asset_settings\":{\"playback_policy\":[\"public\"]}}" \
https://api.mux.com/video/v1/live-streams)
id=$(echo "$resp" | jq -r '.data.id // empty')
key=$(echo "$resp" | jq -r '.data.stream_key // empty')
pb=$(echo "$resp" | jq -r '.data.playback_ids[0].id // empty')
if [ -z "$id" ]; then
echo "Mux API error:" >&2
echo "$resp" | jq . >&2 2>/dev/null || echo "$resp" >&2
exit 1
fi
if [ "$quiet" -eq 1 ]; then
echo "$id $key $pb"
exit 0
fi
cat <<EOF
Live stream created.
Stream ID: $id
Stream key: $key
RTMPS ingest: rtmps://global-live.mux.com:443/app/$key
Live playback: https://stream.mux.com/$pb.m3u8 (sliding-window live)
Next:
./broadcast.sh $key # push a test feed in a separate terminal
./dvr-url.sh $id # once broadcasting, get the DVR / EVENT playback URL
EOF
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Resolve the DVR / EVENT playback URL for a live stream that is currently
# broadcasting. Mux records by default; while the stream is active its recording
# asset (active_asset_id) serves a growing #EXT-X-PLAYLIST-TYPE:EVENT manifest —
# the DVR source, seekable back to the start — which is distinct from the live
# stream's own sliding-window playback id (from create-stream.sh).
#
# Usage: ./dvr-url.sh [--quiet] <STREAM_ID>
# --quiet print only the playback .m3u8 URL (for scripting).
# Requires curl, jq, and MUX_TOKEN_ID / MUX_TOKEN_SECRET in the environment.
# Exits non-zero (no asset yet) until the stream has started broadcasting.
set -euo pipefail
: "${MUX_TOKEN_ID:?set MUX_TOKEN_ID}"
: "${MUX_TOKEN_SECRET:?set MUX_TOKEN_SECRET}"
quiet=0
id=""
while [ $# -gt 0 ]; do
case "$1" in
--quiet) quiet=1; shift ;;
-h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) id="$1"; shift ;;
esac
done
[ -n "$id" ] || { echo "Usage: $0 [--quiet] <STREAM_ID>" >&2; exit 1; }
asset=$(curl -sS -u "$MUX_TOKEN_ID:$MUX_TOKEN_SECRET" \
"https://api.mux.com/video/v1/live-streams/$id" | jq -r '.data.active_asset_id // empty')
if [ -z "$asset" ]; then
echo "No active_asset_id yet — is the stream broadcasting? Start ./broadcast.sh first." >&2
exit 1
fi
pb=$(curl -sS -u "$MUX_TOKEN_ID:$MUX_TOKEN_SECRET" \
"https://api.mux.com/video/v1/assets/$asset" | jq -r '.data.playback_ids[0].id // empty')
[ -n "$pb" ] || { echo "recording asset $asset has no public playback id" >&2; exit 1; }
if [ "$quiet" -eq 1 ]; then
echo "https://stream.mux.com/$pb.m3u8"
else
echo "DVR / EVENT playback: https://stream.mux.com/$pb.m3u8 (growing window, back-seek to start)"
fi
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Orchestrate a live / DVR sandbox test end to end: create a Mux live stream,
# start an ffmpeg test broadcast, wait for the playlist to go live, and open the
# chosen sandbox page pointed at it. On exit (Ctrl-C) the broadcast stops and the
# stream is deleted (unless --keep).
#
# The stream itself is renderer-agnostic — any HLS-capable sandbox page works.
# Point --page at the SPF harness or an hls.js / native player page, e.g.:
# spf-segment-loading, live-hls-engine, html-hls-video, html-native-hls-video
#
# Usage: ./live-test.sh [options]
# --page <name> sandbox template dir (default: spf-segment-loading)
# --flavor <f> sliding | dvr | both (default: sliding)
# --port <n> dev server port (default: 5173)
# --params <qs> query string appended to the page URL
# (default: muted=true&autoplay=true&preload=auto)
# --latency <m> low | reduced | standard (default: low)
# --no-open print the URL(s) instead of opening a browser
# --keep do not delete the Mux stream on exit
# --token-id ID / --token-secret SECRET Mux creds (default: env vars)
#
# Requires: curl, jq, ffmpeg, and Mux credentials (flags or MUX_TOKEN_ID /
# MUX_TOKEN_SECRET). Start the sandbox dev server (`pnpm dev`) first.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
page="spf-segment-loading"
flavor="sliding"
port="5173"
params="muted=true&autoplay=true&preload=auto"
latency="low"
do_open=1
keep=0
token_id="${MUX_TOKEN_ID:-}"
token_secret="${MUX_TOKEN_SECRET:-}"
usage() { sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'; }
while [ $# -gt 0 ]; do
case "$1" in
--page) page="$2"; shift 2 ;;
--flavor) flavor="$2"; shift 2 ;;
--port) port="$2"; shift 2 ;;
--params) params="$2"; shift 2 ;;
--latency) latency="$2"; shift 2 ;;
--no-open) do_open=0; shift ;;
--keep) keep=1; shift ;;
--token-id) token_id="$2"; shift 2 ;;
--token-secret) token_secret="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 1 ;;
esac
done
case "$flavor" in sliding|dvr|both) ;; *) echo "--flavor must be sliding|dvr|both" >&2; exit 1 ;; esac
[ -n "$token_id" ] && [ -n "$token_secret" ] || { echo "missing Mux creds (--token-id/--token-secret or MUX_TOKEN_ID/MUX_TOKEN_SECRET)" >&2; exit 1; }
# Export so the child scripts (create-stream, dvr-url) inherit the resolved creds.
export MUX_TOKEN_ID="$token_id" MUX_TOKEN_SECRET="$token_secret"
open_url() {
if [ "$do_open" -eq 0 ]; then echo " open: $1"; return; fi
if command -v open >/dev/null 2>&1; then open "$1"
elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$1"
else echo " open manually: $1"; fi
}
wait_for_m3u8() {
for _ in $(seq 1 30); do
curl -sf -o /dev/null --max-time 5 "$1" && return 0
sleep 1
done
return 1
}
stream_id=""; bpid=""
cleanup() {
trap - INT TERM EXIT
[ -n "$bpid" ] && kill "$bpid" 2>/dev/null || true
if [ "$keep" -eq 0 ] && [ -n "$stream_id" ]; then
curl -sS -u "$MUX_TOKEN_ID:$MUX_TOKEN_SECRET" -X DELETE \
"https://api.mux.com/video/v1/live-streams/$stream_id" >/dev/null 2>&1 || true
echo "deleted live stream $stream_id"
elif [ -n "$stream_id" ]; then
echo "kept live stream $stream_id (delete it from the Mux dashboard when done)"
fi
}
trap cleanup INT TERM EXIT
read -r stream_id stream_key live_pb < <("$here/create-stream.sh" --quiet --latency "$latency") || true
[ -n "$stream_id" ] || { echo "failed to create stream (check Mux credentials)" >&2; exit 1; }
echo "created live stream $stream_id"
log="${TMPDIR:-/tmp}/live-test-ffmpeg-$stream_id.log"
"$here/broadcast.sh" "$stream_key" >"$log" 2>&1 &
bpid=$!
echo "broadcasting (ffmpeg pid $bpid; log: $log)"
base="http://localhost:$port"
curl -sf -o /dev/null --max-time 2 "$base/$page/" \
|| echo "warning: dev server not responding at $base/$page/ — run 'pnpm dev' in apps/sandbox"
if [ "$flavor" = sliding ] || [ "$flavor" = both ]; then
m="https://stream.mux.com/$live_pb.m3u8"
echo "waiting for sliding-window live to go live…"
wait_for_m3u8 "$m" && open_url "$base/$page/?src=$m&$params" || echo "sliding live did not come up in time"
fi
if [ "$flavor" = dvr ] || [ "$flavor" = both ]; then
echo "waiting for the DVR / EVENT recording asset…"
dvr=""
for _ in $(seq 1 40); do
dvr="$("$here/dvr-url.sh" --quiet "$stream_id" 2>/dev/null)" && [ -n "$dvr" ] && break
dvr=""; sleep 2
done
if [ -n "$dvr" ] && wait_for_m3u8 "$dvr"; then open_url "$base/$page/?src=$dvr&$params"
else echo "DVR / EVENT source did not come up in time"; fi
fi
echo "broadcasting — Ctrl-C to stop and clean up."
wait "$bpid"