mirror of
https://github.com/livekit/livekit.git
synced 2026-08-28 11:44:43 +00:00
example implementation of moq output
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# local_video MoQ Harness
|
||||
|
||||
This harness runs a local LiveKit server with the experimental MoQ/WebTransport
|
||||
listener enabled, publishes H264 from the Rust SDK `examples/local_video`
|
||||
publisher, and receives the stream in a browser with the `@moq/net` MoQ client
|
||||
next to a LiveKit JS SDK WebRTC subscriber for the same room and track.
|
||||
|
||||
The current receiver validates transport compatibility and timing:
|
||||
|
||||
- WebTransport connect time
|
||||
- first MoQ object time
|
||||
- received frame/object count
|
||||
- total payload bytes
|
||||
- Annex-B H264 NAL types, including whether an IDR was seen
|
||||
- WebCodecs H264 decode and canvas render count
|
||||
- rendered canvas pixel validation to catch blank or flat output
|
||||
- rendered-frame motion validation for the burned timestamp overlay
|
||||
- packet-trailer timing metadata over a companion `<track>.timing` MoQ track
|
||||
- subscriber-style timing overlay with exposure, receive, decode, paint, and
|
||||
e2e latency calculations
|
||||
- side-by-side LiveKit JS SDK WebRTC rendering of the same publisher video
|
||||
- WebRTC connect, first-frame, render, visual, motion, and browser-exposed
|
||||
video-frame timing metadata
|
||||
- WebRTC packet-trailer extraction through the LiveKit JS SDK packet trailer
|
||||
worker, with frame ID and user timestamp lookup via `TimeSyncUpdate`
|
||||
|
||||
The MoQ object payload is one Annex-B H264 access unit per MoQ group/frame.
|
||||
The browser receiver configures WebCodecs from the first SPS, decodes each
|
||||
access unit, draws decoded frames into a canvas, and validates that nonblank,
|
||||
moving video is rendered. The WebRTC pane attaches the remote video track with
|
||||
`livekit-client` and samples the `<video>` element for the same visual and
|
||||
motion checks. Chromium may expose WebRTC capture and receive timestamps through
|
||||
`requestVideoFrameCallback`. Packet-trailer frame IDs and user timestamps are
|
||||
decoded through `Room({ packetTrailer: { worker } })` and
|
||||
`RemoteVideoTrack.lookupFrameMetadata({ rtpTimestamp })`. The receiver serves a
|
||||
same-origin `packet-trailer-worker.js` shim because browsers block cross-origin
|
||||
worker entrypoints; it imports the SDK worker module from esm.sh. The worker URL
|
||||
can be overridden with the `packetTrailerWorkerUrl` receiver query parameter.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
./test/moq/local_video/run.sh
|
||||
```
|
||||
|
||||
Then open the printed receiver URL in a Chromium-based browser. Set `OPEN=1` to
|
||||
open it automatically on macOS. The receiver URL includes both the MoQ token and
|
||||
a separate WebRTC viewer token, so the page connects to the same room twice and
|
||||
keeps both videos playing after the initial validation frame count is reached.
|
||||
|
||||
The runner defaults to:
|
||||
|
||||
- LiveKit HTTP/WebSocket: `127.0.0.1:7880`
|
||||
- LiveKit RTC TCP/UDP: `127.0.0.1:7881` / `127.0.0.1:7882`
|
||||
- MoQ WebTransport: `https://127.0.0.1:7883/moq/v1`
|
||||
- receiver page: `http://127.0.0.1:8899`
|
||||
- WebRTC compare URL: `ws://127.0.0.1:7880`
|
||||
- Rust SDK checkout: `../rust-sdks`
|
||||
- room: `moq-local-video`
|
||||
- track: `camera`
|
||||
- publisher: `--test-pattern --attach-timestamp --attach-frame-id --burn-timestamp --disable-dynacast --min-playout-delay 0 --max-playout-delay 1 --codec h264 --encoder software --width 640 --height 480 --fps 60`
|
||||
|
||||
`--disable-dynacast` keeps the Rust SDK publisher encoding continuously for
|
||||
this MoQ proof. This checkout does not currently handle LiveKit
|
||||
`SubscribedQualityUpdate` on the publisher side, so a pure MoQ subscriber cannot
|
||||
wake dynacast by itself yet.
|
||||
|
||||
Useful overrides:
|
||||
|
||||
```bash
|
||||
RUST_SDKS_DIR=/path/to/rust-sdks ROOM=my-room FRAMES=120 OPEN=1 ./test/moq/local_video/run.sh
|
||||
WIDTH=1280 HEIGHT=720 FPS=60 PUBLISHER_ARGS="--max-bitrate 2500000" ./test/moq/local_video/run.sh
|
||||
PUBLISHER_ENCODER=auto ./test/moq/local_video/run.sh
|
||||
LOW_LATENCY=0 ./test/moq/local_video/run.sh
|
||||
PUBLISHER_MIN_PLAYOUT_DELAY_MS=0 PUBLISHER_MAX_PLAYOUT_DELAY_MS=50 ./test/moq/local_video/run.sh
|
||||
PUBLISHER_RUST_LOG=debug ./test/moq/local_video/run.sh
|
||||
SERVER_BIN=/path/to/livekit-server ./test/moq/local_video/run.sh
|
||||
```
|
||||
|
||||
Low-latency publisher mode is enabled by default and passes
|
||||
`--min-playout-delay 0 --max-playout-delay 1`. Set `LOW_LATENCY=0` or
|
||||
`PUBLISHER_LOW_LATENCY=0` to omit those flags, or set
|
||||
`PUBLISHER_MIN_PLAYOUT_DELAY_MS` / `PUBLISHER_MAX_PLAYOUT_DELAY_MS` to pass
|
||||
custom publisher playout-delay values.
|
||||
|
||||
The receiver uses `@moq/net@0.1.5` from esm.sh and negotiates `moq-lite-04`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
import "https://esm.sh/livekit-client@2.19.2/packet-trailer-worker";
|
||||
Executable
+337
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LIVEKIT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
WORKSPACE_DIR="$(cd "$LIVEKIT_DIR/.." && pwd)"
|
||||
|
||||
RUST_SDKS_DIR="${RUST_SDKS_DIR:-$WORKSPACE_DIR/rust-sdks}"
|
||||
TMP_DIR="${TMP_DIR:-$(mktemp -d /private/tmp/livekit-moq-local-video.XXXXXX)}"
|
||||
|
||||
API_KEY="${API_KEY:-devkey}"
|
||||
API_SECRET="${API_SECRET:-secret}"
|
||||
ROOM="${ROOM:-moq-local-video}"
|
||||
TRACK="${TRACK:-camera}"
|
||||
PUBLISHER_IDENTITY="${PUBLISHER_IDENTITY:-moq-publisher}"
|
||||
MOQ_IDENTITY="${MOQ_IDENTITY:-moq-client}"
|
||||
WEBRTC_IDENTITY="${WEBRTC_IDENTITY:-webrtc-client}"
|
||||
|
||||
LIVEKIT_PORT="${LIVEKIT_PORT:-7880}"
|
||||
LIVEKIT_RTC_TCP_PORT="${LIVEKIT_RTC_TCP_PORT:-7881}"
|
||||
LIVEKIT_RTC_UDP_PORT="${LIVEKIT_RTC_UDP_PORT:-7882}"
|
||||
MOQ_PORT="${MOQ_PORT:-7883}"
|
||||
WEB_PORT="${WEB_PORT:-8899}"
|
||||
|
||||
WIDTH="${WIDTH:-640}"
|
||||
HEIGHT="${HEIGHT:-480}"
|
||||
FPS="${FPS:-60}"
|
||||
FRAMES="${FRAMES:-30}"
|
||||
OPEN="${OPEN:-0}"
|
||||
PUBLISHER_READY_TIMEOUT="${PUBLISHER_READY_TIMEOUT:-120}"
|
||||
PUBLISHER_RUST_LOG="${PUBLISHER_RUST_LOG:-info}"
|
||||
PUBLISHER_ENCODER="${PUBLISHER_ENCODER:-software}"
|
||||
PUBLISHER_LOW_LATENCY="${PUBLISHER_LOW_LATENCY:-${LOW_LATENCY:-1}}"
|
||||
PUBLISHER_MIN_PLAYOUT_DELAY_MS="${PUBLISHER_MIN_PLAYOUT_DELAY_MS:-}"
|
||||
PUBLISHER_MAX_PLAYOUT_DELAY_MS="${PUBLISHER_MAX_PLAYOUT_DELAY_MS:-}"
|
||||
|
||||
case "$PUBLISHER_LOW_LATENCY" in
|
||||
1|true|TRUE|yes|YES|on|ON)
|
||||
PUBLISHER_MIN_PLAYOUT_DELAY_MS="${PUBLISHER_MIN_PLAYOUT_DELAY_MS:-0}"
|
||||
PUBLISHER_MAX_PLAYOUT_DELAY_MS="${PUBLISHER_MAX_PLAYOUT_DELAY_MS:-1}"
|
||||
;;
|
||||
0|false|FALSE|no|NO|off|OFF)
|
||||
;;
|
||||
*)
|
||||
echo "invalid PUBLISHER_LOW_LATENCY=$PUBLISHER_LOW_LATENCY; expected 1/0, true/false, yes/no, or on/off" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
SERVER_PID=""
|
||||
PUBLISHER_PID=""
|
||||
WEB_PID=""
|
||||
|
||||
kill_tree() {
|
||||
local pid="$1"
|
||||
local child
|
||||
if [[ -z "$pid" ]] || ! kill -0 "$pid" >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
while IFS= read -r child; do
|
||||
kill_tree "$child"
|
||||
done < <(pgrep -P "$pid" 2>/dev/null || true)
|
||||
kill "$pid" >/dev/null 2>&1
|
||||
wait "$pid" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
set +e
|
||||
for pid in "$PUBLISHER_PID" "$WEB_PID" "$SERVER_PID"; do
|
||||
kill_tree "$pid"
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "missing required command: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_tcp() {
|
||||
local host="$1"
|
||||
local port="$2"
|
||||
local name="$3"
|
||||
local deadline=$((SECONDS + 30))
|
||||
until nc -z "$host" "$port" >/dev/null 2>&1; do
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "timed out waiting for $name on $host:$port" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_log() {
|
||||
local file="$1"
|
||||
local pattern="$2"
|
||||
local name="$3"
|
||||
local pid="$4"
|
||||
local timeout="$5"
|
||||
local deadline=$((SECONDS + timeout))
|
||||
until grep -q "$pattern" "$file" >/dev/null 2>&1; do
|
||||
if ! kill -0 "$pid" >/dev/null 2>&1; then
|
||||
echo "$name exited before becoming ready; see $file" >&2
|
||||
tail -80 "$file" >&2
|
||||
exit 1
|
||||
fi
|
||||
if (( SECONDS >= deadline )); then
|
||||
echo "timed out waiting for $name readiness pattern '$pattern'; see $file" >&2
|
||||
tail -80 "$file" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
}
|
||||
|
||||
require_cmd cargo
|
||||
require_cmd go
|
||||
require_cmd nc
|
||||
require_cmd openssl
|
||||
require_cmd python3
|
||||
require_cmd xxd
|
||||
|
||||
if [[ ! -d "$RUST_SDKS_DIR/examples/local_video" ]]; then
|
||||
echo "RUST_SDKS_DIR does not point at a rust-sdks checkout: $RUST_SDKS_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CERT_FILE="$TMP_DIR/moq-cert.pem"
|
||||
KEY_FILE="$TMP_DIR/moq-key.pem"
|
||||
OPENSSL_CONF="$TMP_DIR/openssl.cnf"
|
||||
CONFIG_FILE="$TMP_DIR/livekit.yaml"
|
||||
|
||||
cat > "$OPENSSL_CONF" <<EOF_CONF
|
||||
[req]
|
||||
distinguished_name = req_distinguished_name
|
||||
x509_extensions = v3_req
|
||||
prompt = no
|
||||
|
||||
[req_distinguished_name]
|
||||
CN = localhost
|
||||
|
||||
[v3_req]
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
||||
IP.1 = 127.0.0.1
|
||||
EOF_CONF
|
||||
|
||||
openssl req \
|
||||
-x509 \
|
||||
-newkey ec \
|
||||
-pkeyopt ec_paramgen_curve:prime256v1 \
|
||||
-nodes \
|
||||
-days 1 \
|
||||
-keyout "$KEY_FILE" \
|
||||
-out "$CERT_FILE" \
|
||||
-config "$OPENSSL_CONF" >/dev/null 2>&1
|
||||
|
||||
CERT_HASH="$(openssl x509 -in "$CERT_FILE" -outform DER | openssl dgst -sha256 -binary | xxd -p -c 256)"
|
||||
|
||||
cat > "$CONFIG_FILE" <<EOF_CONFIG
|
||||
development: true
|
||||
port: $LIVEKIT_PORT
|
||||
bind_addresses:
|
||||
- 127.0.0.1
|
||||
rtc:
|
||||
use_external_ip: false
|
||||
tcp_port: $LIVEKIT_RTC_TCP_PORT
|
||||
udp_port: $LIVEKIT_RTC_UDP_PORT
|
||||
keys:
|
||||
"$API_KEY": "$API_SECRET"
|
||||
moq:
|
||||
enabled: true
|
||||
port: $MOQ_PORT
|
||||
bind_addresses:
|
||||
- 127.0.0.1
|
||||
path: /moq/v1
|
||||
cert_file: "$CERT_FILE"
|
||||
key_file: "$KEY_FILE"
|
||||
track_queue_size: 256
|
||||
cache_max_bytes: 2097152
|
||||
write_timeout: 2s
|
||||
EOF_CONFIG
|
||||
|
||||
SERVER_BIN="${SERVER_BIN:-}"
|
||||
if [[ -z "$SERVER_BIN" ]]; then
|
||||
SERVER_BIN="$TMP_DIR/livekit-server"
|
||||
echo "building livekit-server..."
|
||||
(cd "$LIVEKIT_DIR" && go build -o "$SERVER_BIN" ./cmd/server)
|
||||
fi
|
||||
|
||||
TOKEN_OUTPUT="$("$SERVER_BIN" --config "$CONFIG_FILE" create-join-token --room "$ROOM" --identity "$MOQ_IDENTITY")"
|
||||
TOKEN="$(printf "%s\n" "$TOKEN_OUTPUT" | sed -n "s/^Token: //p" | tail -1)"
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
echo "failed to generate receiver token" >&2
|
||||
printf "%s\n" "$TOKEN_OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WEBRTC_TOKEN_OUTPUT="$("$SERVER_BIN" --config "$CONFIG_FILE" create-join-token --room "$ROOM" --identity "$WEBRTC_IDENTITY")"
|
||||
WEBRTC_TOKEN="$(printf "%s\n" "$WEBRTC_TOKEN_OUTPUT" | sed -n "s/^Token: //p" | tail -1)"
|
||||
if [[ -z "$WEBRTC_TOKEN" ]]; then
|
||||
echo "failed to generate WebRTC viewer token" >&2
|
||||
printf "%s\n" "$WEBRTC_TOKEN_OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "starting livekit-server..."
|
||||
"$SERVER_BIN" --config "$CONFIG_FILE" > "$TMP_DIR/livekit.log" 2>&1 &
|
||||
SERVER_PID="$!"
|
||||
wait_for_tcp 127.0.0.1 "$LIVEKIT_PORT" "LiveKit"
|
||||
|
||||
publisher_cmd=(
|
||||
cargo run -p local_video -F desktop --bin publisher --
|
||||
--test-pattern
|
||||
--attach-timestamp
|
||||
--attach-frame-id
|
||||
--burn-timestamp
|
||||
--disable-dynacast
|
||||
--room-name "$ROOM"
|
||||
--identity "$PUBLISHER_IDENTITY"
|
||||
--codec h264
|
||||
--encoder "$PUBLISHER_ENCODER"
|
||||
--width "$WIDTH"
|
||||
--height "$HEIGHT"
|
||||
--fps "$FPS"
|
||||
--url "ws://127.0.0.1:$LIVEKIT_PORT"
|
||||
--api-key "$API_KEY"
|
||||
--api-secret "$API_SECRET"
|
||||
)
|
||||
|
||||
if [[ -n "$PUBLISHER_MIN_PLAYOUT_DELAY_MS" ]]; then
|
||||
publisher_cmd+=(--min-playout-delay "$PUBLISHER_MIN_PLAYOUT_DELAY_MS")
|
||||
fi
|
||||
if [[ -n "$PUBLISHER_MAX_PLAYOUT_DELAY_MS" ]]; then
|
||||
publisher_cmd+=(--max-playout-delay "$PUBLISHER_MAX_PLAYOUT_DELAY_MS")
|
||||
fi
|
||||
|
||||
if [[ -n "${PUBLISHER_ARGS:-}" ]]; then
|
||||
extra_args=($PUBLISHER_ARGS)
|
||||
publisher_cmd+=("${extra_args[@]}")
|
||||
fi
|
||||
|
||||
echo "starting rust-sdks local_video publisher..."
|
||||
(cd "$RUST_SDKS_DIR" && RUST_LOG="$PUBLISHER_RUST_LOG" "${publisher_cmd[@]}") > "$TMP_DIR/publisher.log" 2>&1 &
|
||||
PUBLISHER_PID="$!"
|
||||
wait_for_log "$TMP_DIR/livekit.log" "mediaTrack published" "publisher media track" "$PUBLISHER_PID" "$PUBLISHER_READY_TIMEOUT"
|
||||
|
||||
echo "starting receiver web server..."
|
||||
python3 -m http.server "$WEB_PORT" --bind 127.0.0.1 --directory "$SCRIPT_DIR/receiver" > "$TMP_DIR/web.log" 2>&1 &
|
||||
WEB_PID="$!"
|
||||
wait_for_tcp 127.0.0.1 "$WEB_PORT" "receiver web server"
|
||||
|
||||
RECEIVER_URL="$(python3 - "$WEB_PORT" "$MOQ_PORT" "$LIVEKIT_PORT" "$TOKEN" "$WEBRTC_TOKEN" "$ROOM" "$TRACK" "$CERT_HASH" "$FRAMES" "$WEBRTC_IDENTITY" <<'PY'
|
||||
import sys
|
||||
from urllib.parse import urlencode
|
||||
|
||||
web_port, moq_port, livekit_port, token, webrtc_token, room, track, cert_hash, frames, webrtc_identity = sys.argv[1:]
|
||||
query = urlencode({
|
||||
"url": f"https://127.0.0.1:{moq_port}/moq/v1",
|
||||
"token": token,
|
||||
"liveKitUrl": f"ws://127.0.0.1:{livekit_port}",
|
||||
"webrtcToken": webrtc_token,
|
||||
"webrtcIdentity": webrtc_identity,
|
||||
"room": room,
|
||||
"track": track,
|
||||
"certHash": cert_hash,
|
||||
"frames": frames,
|
||||
})
|
||||
print(f"http://127.0.0.1:{web_port}/?{query}")
|
||||
PY
|
||||
)"
|
||||
|
||||
MEET_URL="$(python3 - "$LIVEKIT_PORT" "$WEBRTC_TOKEN" <<'PY'
|
||||
import sys
|
||||
from urllib.parse import urlencode
|
||||
|
||||
livekit_port, token = sys.argv[1:]
|
||||
query = urlencode({
|
||||
"liveKitUrl": f"ws://127.0.0.1:{livekit_port}",
|
||||
"token": token,
|
||||
})
|
||||
print(f"https://meet.livekit.io/custom/?{query}")
|
||||
PY
|
||||
)"
|
||||
|
||||
cat <<EOF_STATUS
|
||||
|
||||
MoQ local_video harness is running.
|
||||
|
||||
Receiver URL:
|
||||
$RECEIVER_URL
|
||||
|
||||
LiveKit Meet URL:
|
||||
$MEET_URL
|
||||
|
||||
WebRTC compare:
|
||||
URL: ws://127.0.0.1:$LIVEKIT_PORT
|
||||
Room: $ROOM
|
||||
Publisher identity: $PUBLISHER_IDENTITY
|
||||
MoQ identity: $MOQ_IDENTITY
|
||||
WebRTC identity: $WEBRTC_IDENTITY
|
||||
|
||||
Logs:
|
||||
LiveKit: $TMP_DIR/livekit.log
|
||||
Publisher: $TMP_DIR/publisher.log
|
||||
Web: $TMP_DIR/web.log
|
||||
|
||||
Press Ctrl-C to stop.
|
||||
EOF_STATUS
|
||||
|
||||
if [[ "$OPEN" == "1" ]]; then
|
||||
if command -v open >/dev/null 2>&1; then
|
||||
open "$RECEIVER_URL"
|
||||
else
|
||||
echo "OPEN=1 requested, but the macOS 'open' command is unavailable" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
while true; do
|
||||
if ! kill -0 "$SERVER_PID" >/dev/null 2>&1; then
|
||||
echo "livekit-server exited; see $TMP_DIR/livekit.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! kill -0 "$PUBLISHER_PID" >/dev/null 2>&1; then
|
||||
echo "publisher exited; see $TMP_DIR/publisher.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! kill -0 "$WEB_PID" >/dev/null 2>&1; then
|
||||
echo "receiver web server exited; see $TMP_DIR/web.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
Reference in New Issue
Block a user