From da33b10952bd9d24257038609c779482d125a630 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Thu, 25 Jun 2026 13:52:21 -0700 Subject: [PATCH] test(sandbox): add ffmpeg + Mux live/DVR test-stream scripts and orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/sandbox/scripts/live/README.md | 99 +++++++++++++++++ apps/sandbox/scripts/live/broadcast.sh | 25 +++++ apps/sandbox/scripts/live/create-stream.sh | 72 +++++++++++++ apps/sandbox/scripts/live/dvr-url.sh | 46 ++++++++ apps/sandbox/scripts/live/live-test.sh | 120 +++++++++++++++++++++ 5 files changed, 362 insertions(+) create mode 100644 apps/sandbox/scripts/live/README.md create mode 100755 apps/sandbox/scripts/live/broadcast.sh create mode 100755 apps/sandbox/scripts/live/create-stream.sh create mode 100755 apps/sandbox/scripts/live/dvr-url.sh create mode 100755 apps/sandbox/scripts/live/live-test.sh diff --git a/apps/sandbox/scripts/live/README.md b/apps/sandbox/scripts/live/README.md new file mode 100644 index 00000000..410af04c --- /dev/null +++ b/apps/sandbox/scripts/live/README.md @@ -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 + +# 3. Play it in the sandbox (default dev port 5173): +open "http://localhost:5173/spf-segment-loading/?src=https://stream.mux.com/.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 +``` + +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. diff --git a/apps/sandbox/scripts/live/broadcast.sh b/apps/sandbox/scripts/live/broadcast.sh new file mode 100755 index 00000000..15be2316 --- /dev/null +++ b/apps/sandbox/scripts/live/broadcast.sh @@ -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 is printed by create-stream.sh. +set -euo pipefail + +key="${1:-}" +if [ -z "$key" ]; then + echo "Usage: $0 (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" diff --git a/apps/sandbox/scripts/live/create-stream.sh b/apps/sandbox/scripts/live/create-stream.sh new file mode 100755 index 00000000..32065298 --- /dev/null +++ b/apps/sandbox/scripts/live/create-stream.sh @@ -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 " " 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 < +# --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] " >&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 diff --git a/apps/sandbox/scripts/live/live-test.sh b/apps/sandbox/scripts/live/live-test.sh new file mode 100755 index 00000000..1531426c --- /dev/null +++ b/apps/sandbox/scripts/live/live-test.sh @@ -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 sandbox template dir (default: spf-segment-loading) +# --flavor sliding | dvr | both (default: sliding) +# --port dev server port (default: 5173) +# --params query string appended to the page URL +# (default: muted=true&autoplay=true&preload=auto) +# --latency 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"