diff --git a/load-test/media-fanout-capacity.js b/load-test/media-fanout-capacity.js new file mode 100644 index 0000000..fc1edf4 --- /dev/null +++ b/load-test/media-fanout-capacity.js @@ -0,0 +1,778 @@ +/* + * media-fanout-capacity.js + * + * Purpose: + * Measure the REAL media-plane ceiling of the CrowdStream SFU (mediasoup), + * not just the signaling / control path. One broadcaster streams fake media; + * viewers ramp up in batches. For every viewer we measure join and + * first-frame latency AND sample inbound video getStats (bitrate, + * packet-loss %, jitter, fps, resolution). + * + * getStats is captured WITHOUT any frontend changes: we use + * evaluateOnNewDocument to wrap window.RTCPeerConnection in each headless + * tab so every PeerConnection the app constructs is pushed into + * window.__csPCs. We then iterate those PCs and read their inbound-rtp + * reports directly. + * + * As viewers climb, the ceiling reveals itself as falling bitrate/fps, + * rising packet loss, or climbing join / first-frame failures. + * + * Authored by Claude (Anthropic), via Claude Code — 2026-08-27. + */ + +const puppeteer = require("puppeteer"); +const fs = require("fs"); + +/* + * --------------------------------------------------------- + * CLI ARGS + * --------------------------------------------------------- + */ + +function arg(name, def) { + const i = process.argv.indexOf(`--${name}`); + if (i === -1) return def; + return process.argv[i + 1]; +} + +const BASE_URL = arg("baseUrl", "http://localhost"); + +const BATCH_SIZE = parseInt(arg("batchSize", "5"), 10); +const BATCH_INTERVAL_MS = parseInt( + arg("batchIntervalMs", "10000"), + 10 +); +const MAX_VIEWERS = parseInt(arg("maxViewers", "100"), 10); +const STATS_SETTLE_MS = parseInt( + arg("statsSettleMs", "2000"), + 10 +); + +const OUT_CSV = arg("out", "media-fanout-results.csv"); + +const WAIT_TIMEOUT_MS = 20000; +let failures = 0; + +/* + * Credentials. + * + * Read from --email / --password OR the environment. NEVER hardcode + * credentials in this file. + */ + +const TEST_EMAIL = arg("email", process.env.CROWDSTREAM_TEST_EMAIL); +const TEST_PASSWORD = arg( + "password", + process.env.CROWDSTREAM_TEST_PASSWORD +); + +if (!TEST_EMAIL || !TEST_PASSWORD) { + console.error( + "Missing broadcaster credentials.\n" + + "Pass --email --password

, or set the environment variables\n" + + "CROWDSTREAM_TEST_EMAIL and CROWDSTREAM_TEST_PASSWORD." + ); + process.exit(1); +} + +const CHROME_FLAGS = [ + "--use-fake-device-for-media-stream", + "--use-fake-ui-for-media-stream", + "--disable-gpu", + "--no-sandbox", + "--mute-audio", +]; + +const sleep = (ms) => + new Promise((resolve) => setTimeout(resolve, ms)); + +/* + * --------------------------------------------------------- + * PERCENTILE / MEAN + * --------------------------------------------------------- + */ + +function percentile(values, p) { + if (!values.length) return null; + + const index = Math.ceil((p / 100) * values.length) - 1; + + return values[ + Math.min(Math.max(index, 0), values.length - 1) + ]; +} + +function mean(values) { + const clean = values.filter((n) => Number.isFinite(n)); + if (!clean.length) return null; + return clean.reduce((a, b) => a + b, 0) / clean.length; +} + +/* + * --------------------------------------------------------- + * LOGIN + * --------------------------------------------------------- + */ + +async function login(page) { + await page.goto(`${BASE_URL}/signin`, { + waitUntil: "networkidle2", + timeout: 30000, + }); + + await page.waitForSelector("#email", { + timeout: WAIT_TIMEOUT_MS, + }); + + await page.type("#email", TEST_EMAIL); + await page.type("#password", TEST_PASSWORD); + + await page.click('button[type="submit"]'); + + // SignInPage navigates to /dashboard after successful sign-in. + await page.waitForFunction( + () => window.location.pathname === "/dashboard", + { timeout: WAIT_TIMEOUT_MS } + ); + + await page.waitForFunction( + () => + window.__csSocket && + window.__csSocket.connected === true, + { timeout: WAIT_TIMEOUT_MS } + ); +} + +/* + * --------------------------------------------------------- + * getStats HOOK + * + * Wrap window.RTCPeerConnection so every PC the app constructs is pushed + * into window.__csPCs. Installed via evaluateOnNewDocument so it runs before + * any page script on every navigation of the tab (signin -> dashboard -> + * viewer). No frontend changes required. + * --------------------------------------------------------- + */ + +async function installPeerConnectionHook(page) { + await page.evaluateOnNewDocument(() => { + window.__csPCs = []; + + const Native = + window.RTCPeerConnection || + window.webkitRTCPeerConnection; + + if (!Native) return; + + const Wrapped = new Proxy(Native, { + construct(target, args) { + const pc = new target(...args); + try { + window.__csPCs.push(pc); + } catch (e) {} + return pc; + }, + }); + + window.RTCPeerConnection = Wrapped; + window.webkitRTCPeerConnection = Wrapped; + }); +} + +/* + * Take one snapshot of inbound-rtp (video + audio) across every PC in the + * tab. Returns { ts, reports } where ts is the in-page wall clock so bitrate + * deltas are measured against the moment the sample was actually read. + */ + +async function sampleInboundRtp(page) { + return await page.evaluate(async () => { + const pcs = window.__csPCs || []; + const reports = []; + + for (const pc of pcs) { + let stats; + try { + stats = await pc.getStats(); + } catch (e) { + continue; + } + + stats.forEach((report) => { + if ( + report.type === "inbound-rtp" && + (report.kind === "video" || + report.kind === "audio") + ) { + reports.push({ + kind: report.kind, + bytesReceived: report.bytesReceived || 0, + packetsReceived: report.packetsReceived || 0, + packetsLost: report.packetsLost || 0, + jitter: report.jitter || 0, + framesPerSecond: report.framesPerSecond || 0, + frameWidth: report.frameWidth || 0, + frameHeight: report.frameHeight || 0, + }); + } + }); + } + + return { ts: Date.now(), reports }; + }); +} + +/* + * Two samples, STATS_SETTLE_MS apart, reduced to per-viewer video metrics. + * bitrateKbps is the delta of bytesReceived across the two samples; + * lossPct = packetsLost / (packetsLost + packetsReceived). + */ + +async function measureVideoStats(page) { + const empty = { + bitrateKbps: null, + lossPct: null, + fps: null, + frameWidth: null, + frameHeight: null, + }; + + let sample1; + try { + sample1 = await sampleInboundRtp(page); + } catch (e) { + return empty; + } + + await sleep(STATS_SETTLE_MS); + + let sample2; + try { + sample2 = await sampleInboundRtp(page); + } catch (e) { + return empty; + } + + const video1 = sample1.reports.filter( + (r) => r.kind === "video" + ); + const video2 = sample2.reports.filter( + (r) => r.kind === "video" + ); + + if (!video2.length) return empty; + + const bytes1 = video1.reduce( + (a, r) => a + r.bytesReceived, + 0 + ); + const bytes2 = video2.reduce( + (a, r) => a + r.bytesReceived, + 0 + ); + + const deltaMs = sample2.ts - sample1.ts; + + // bytes * 8 = bits; bits / ms == kbits / s == kbps. + const bitrateKbps = + deltaMs > 0 + ? ((bytes2 - bytes1) * 8) / deltaMs + : null; + + const packetsLost = video2.reduce( + (a, r) => a + r.packetsLost, + 0 + ); + const packetsReceived = video2.reduce( + (a, r) => a + r.packetsReceived, + 0 + ); + + const lossDenom = packetsLost + packetsReceived; + const lossPct = + lossDenom > 0 + ? (packetsLost / lossDenom) * 100 + : null; + + const fps = + video2.reduce( + (a, r) => a + (r.framesPerSecond || 0), + 0 + ) / video2.length; + + // Largest reported frame wins as the "resolution" for this viewer. + const main = video2 + .slice() + .sort( + (a, b) => + b.frameWidth * b.frameHeight - + a.frameWidth * a.frameHeight + )[0]; + + return { + bitrateKbps, + lossPct, + fps: Number.isFinite(fps) ? fps : null, + frameWidth: main ? main.frameWidth : null, + frameHeight: main ? main.frameHeight : null, + }; +} + +/* + * --------------------------------------------------------- + * BROADCASTER + * --------------------------------------------------------- + */ + +async function launchBroadcaster(browser) { + const context = browser.defaultBrowserContext(); + + await context.overridePermissions(BASE_URL, [ + "camera", + "microphone", + ]); + + const page = await browser.newPage(); + + page.on("pageerror", (error) => { + console.error( + `[BROADCASTER PAGE ERROR] ${error.message}` + ); + }); + + await login(page); + + console.log( + `Opening broadcaster: ${BASE_URL}/broadcaster` + ); + + await page.goto(`${BASE_URL}/broadcaster`, { + waitUntil: "networkidle2", + timeout: 30000, + }); + + /* + * Make sure the "Go live" button actually exists. + */ + + await page.waitForFunction( + () => + Array.from( + document.querySelectorAll("button") + ).some((button) => + button.textContent?.includes("Go live") + ), + { timeout: WAIT_TIMEOUT_MS } + ); + + console.log('"Go live" button found.'); + + /* + * Click "Go live". + */ + + await page.evaluate(() => { + const button = Array.from( + document.querySelectorAll("button") + ).find((button) => + button.textContent?.includes("Go live") + ); + + if (!button) { + throw new Error("Go live button disappeared"); + } + + button.click(); + }); + + console.log('Clicked "Go live".'); + + /* + * BroadcasterPage sets window.__csRoomId after createRoom() succeeds, + * then window.__csLiveAt once media is flowing. + */ + + await page.waitForFunction( + () => Boolean(window.__csRoomId), + { timeout: WAIT_TIMEOUT_MS } + ); + + const roomId = await page.evaluate( + () => window.__csRoomId + ); + + console.log(`ROOM CREATED. Room ID: ${roomId}`); + + try { + await page.waitForFunction( + () => Boolean(window.__csLiveAt), + { timeout: WAIT_TIMEOUT_MS } + ); + + console.log("Broadcaster is LIVE."); + } catch { + console.warn( + "Room exists, but __csLiveAt was not detected." + ); + } + + return { page, roomId }; +} + +/* + * --------------------------------------------------------- + * VIEWER + * --------------------------------------------------------- + */ + +async function launchViewer(browser, roomId, index) { + const page = await browser.newPage(); + + page.on("pageerror", (error) => { + console.error( + `[VIEWER ${index} PAGE ERROR] ${error.message}` + ); + }); + + // Hook must be installed BEFORE any navigation so it captures the + // consumer PeerConnection the viewer page builds. + await installPeerConnectionHook(page); + + await login(page); + + const startTime = Date.now(); + + try { + await page.goto( + `${BASE_URL}/viewer?roomId=${roomId}`, + { + waitUntil: "domcontentloaded", + timeout: 30000, + } + ); + + await page.waitForSelector("form", { + timeout: WAIT_TIMEOUT_MS, + }); + + /* + * Submit the viewer form. + */ + + await page.$eval("form", (form) => { + form.requestSubmit(); + }); + + /* + * Wait for the ViewerPage join marker. + */ + + let joinedAt = null; + + try { + await page.waitForFunction( + () => Boolean(window.__csJoinedAt), + { timeout: WAIT_TIMEOUT_MS } + ); + + joinedAt = await page.evaluate( + () => window.__csJoinedAt + ); + } catch {} + + /* + * Wait for the actual first video frame. + */ + + let firstFrameAt = null; + + try { + await page.waitForFunction( + () => Boolean(window.__csFirstFrameAt), + { timeout: WAIT_TIMEOUT_MS } + ); + + firstFrameAt = await page.evaluate( + () => window.__csFirstFrameAt + ); + } catch {} + + /* + * Sample inbound video getStats under the CURRENT load. + */ + + const stats = await measureVideoStats(page); + + return { + index, + page, + + joined: Boolean(joinedAt), + + joinLatencyMs: joinedAt + ? joinedAt - startTime + : null, + + firstFrameLatencyMs: firstFrameAt + ? firstFrameAt - startTime + : null, + + bitrateKbps: stats.bitrateKbps, + lossPct: stats.lossPct, + fps: stats.fps, + frameWidth: stats.frameWidth, + frameHeight: stats.frameHeight, + + error: null, + }; + } catch (error) { + return { + index, + page, + + joined: false, + + joinLatencyMs: null, + firstFrameLatencyMs: null, + + bitrateKbps: null, + lossPct: null, + fps: null, + frameWidth: null, + frameHeight: null, + + error: error.message, + }; + } +} + +/* + * --------------------------------------------------------- + * MAIN + * --------------------------------------------------------- + */ + +async function main() { + console.log( + "========================================" + ); + console.log("CrowdStream MEDIA-FANOUT CAPACITY TEST"); + console.log( + "========================================" + ); + console.log(`Frontend: ${BASE_URL}`); + console.log(`Batch size: ${BATCH_SIZE}`); + console.log(`Batch interval: ${BATCH_INTERVAL_MS}ms`); + console.log(`Max viewers: ${MAX_VIEWERS}`); + console.log(`Stats settle: ${STATS_SETTLE_MS}ms`); + console.log(""); + + const browser = await puppeteer.launch({ + headless: true, + protocolTimeout: 120000, + args: CHROME_FLAGS, + }); + + try { + /* + * STEP 1: create a REAL broadcaster. + */ + + const broadcaster = await launchBroadcaster(browser); + const roomId = broadcaster.roomId; + + /* + * Give mediasoup a moment to stabilize. + */ + + console.log("\nWaiting 5 seconds for media..."); + await sleep(5000); + + /* + * CSV. + */ + + const csvRows = [ + [ + "timestamp", + "viewers", + "joinP50", + "joinP99", + "firstFrameP50", + "firstFrameP99", + "meanBitrateKbps", + "meanLossPct", + "meanFps", + "failures", + ].join(","), + ]; + + const viewers = []; + + /* + * STEP 2: add viewers in batches. + */ + + for ( + let target = BATCH_SIZE; + target <= MAX_VIEWERS; + target += BATCH_SIZE + ) { + console.log( + `\n========================================` + ); + console.log(`ADDING VIEWERS: ${target}`); + console.log( + `========================================` + ); + + const batch = await Promise.all( + Array.from({ length: BATCH_SIZE }, (_, index) => + launchViewer( + browser, + roomId, + viewers.length + index + ) + ) + ); + + viewers.push(...batch); + + /* + * Join latency. + */ + + const joinLatencies = batch + .map((viewer) => viewer.joinLatencyMs) + .filter(Number.isFinite) + .sort((a, b) => a - b); + + /* + * First frame latency. + */ + + const firstFrameLatencies = batch + .map((viewer) => viewer.firstFrameLatencyMs) + .filter(Number.isFinite) + .sort((a, b) => a - b); + + /* + * Media-plane means for this batch. + */ + + const meanBitrate = mean( + batch.map((viewer) => viewer.bitrateKbps) + ); + const meanLoss = mean( + batch.map((viewer) => viewer.lossPct) + ); + const meanFps = mean( + batch.map((viewer) => viewer.fps) + ); + + const failed = batch.filter( + (viewer) => !viewer.joined + ).length; + + failures += failed; + + const joinP50 = percentile(joinLatencies, 50); + const joinP99 = percentile(joinLatencies, 99); + const frameP50 = percentile( + firstFrameLatencies, + 50 + ); + const frameP99 = percentile( + firstFrameLatencies, + 99 + ); + + console.log(`\nViewers: ${viewers.length}`); + console.log(`Join P50: ${joinP50 ?? "n/a"} ms`); + console.log(`Join P99: ${joinP99 ?? "n/a"} ms`); + console.log( + `First frame P50: ${frameP50 ?? "n/a"} ms` + ); + console.log( + `First frame P99: ${frameP99 ?? "n/a"} ms` + ); + console.log( + `Mean bitrate: ${ + meanBitrate != null + ? meanBitrate.toFixed(1) + : "n/a" + } kbps` + ); + console.log( + `Mean loss: ${ + meanLoss != null + ? meanLoss.toFixed(2) + : "n/a" + } %` + ); + console.log( + `Mean fps: ${ + meanFps != null ? meanFps.toFixed(1) : "n/a" + }` + ); + console.log(`Failures: ${failures}`); + + /* + * Save results after every batch. + */ + + csvRows.push( + [ + new Date().toISOString(), + viewers.length, + joinP50 ?? "n/a", + joinP99 ?? "n/a", + frameP50 ?? "n/a", + frameP99 ?? "n/a", + meanBitrate != null + ? meanBitrate.toFixed(1) + : "n/a", + meanLoss != null + ? meanLoss.toFixed(2) + : "n/a", + meanFps != null + ? meanFps.toFixed(1) + : "n/a", + failures, + ].join(",") + ); + + fs.writeFileSync(OUT_CSV, csvRows.join("\n")); + + /* + * Wait before next batch. + */ + + if (target < MAX_VIEWERS) { + console.log( + `\nWaiting ${ + BATCH_INTERVAL_MS / 1000 + } seconds...` + ); + await sleep(BATCH_INTERVAL_MS); + } + } + + console.log( + `\n========================================` + ); + console.log("MEDIA-FANOUT TEST COMPLETE"); + console.log(`Results: ${OUT_CSV}`); + console.log( + `========================================` + ); + } finally { + await browser.close(); + } +} + +main().catch((error) => { + console.error("\nTEST FAILED:"); + console.error(error); + process.exit(1); +}); diff --git a/load-test/results/MEDIA-FANOUT-CAPACITY.md b/load-test/results/MEDIA-FANOUT-CAPACITY.md new file mode 100644 index 0000000..e8e11b4 --- /dev/null +++ b/load-test/results/MEDIA-FANOUT-CAPACITY.md @@ -0,0 +1,175 @@ +# Media-Fanout Capacity Testing + +> **Authored by Claude (Anthropic), via Claude Code — 2026-08-27.** +> Both this document and the accompanying `media-fanout-capacity.js` script were written by Claude. + +**Roadmap gap: media-plane capacity** (control-plane is already covered by the signaling load test — see `results/SIGNALING.MD`). + +The signaling test proves the SFU can accept thousands of viewers through the +control path (`joinRoom` → `createViewerTransport` → `connectConsumerTransport` +→ `consume` → `resumeConsumer`). It does **not** prove that media actually +flows to those viewers at a usable bitrate. This test closes that gap: it +measures the real media plane. + +## What it tests + +One broadcaster streams fake media; viewers ramp up in batches. Each new batch +of viewers joins under the load created by every viewer already connected, so +the SFU's fan-out cost accumulates as the run progresses. + +``` +launchBroadcaster (Go live -> __csRoomId -> __csLiveAt) + | + v + ramp viewers in batches of --batchSize + | + v + per viewer: join (__csJoinedAt) + first frame (__csFirstFrameAt) + inbound-rtp getStats sample x2 (--statsSettleMs apart) + | + v + aggregate per batch -> append CSV row -> wait --batchIntervalMs +``` + +For every viewer we capture: + +- **Join latency** — form submit → `window.__csJoinedAt`. +- **First-frame latency** — form submit → `window.__csFirstFrameAt`. +- **Inbound video getStats** — bitrate (kbps), packet-loss %, jitter, fps, and + resolution (frame width × height). + +### The getStats hook (no frontend changes) + +The frontend does not expose `getStats` output, and we do not modify it. Instead +the script uses Puppeteer's `page.evaluateOnNewDocument(...)` to wrap +`window.RTCPeerConnection` **before any page script runs**, on every navigation +of the tab (signin → dashboard → viewer). Every PeerConnection the app +constructs is pushed into `window.__csPCs`: + +```js +const Wrapped = new Proxy(Native, { + construct(target, args) { + const pc = new target(...args); + window.__csPCs.push(pc); + return pc; + }, +}); +window.RTCPeerConnection = Wrapped; +``` + +To sample, the script iterates `window.__csPCs`, calls `await pc.getStats()`, +and collects `inbound-rtp` reports (`kind: "video" | "audio"`), reading +`bytesReceived, packetsReceived, packetsLost, jitter, framesPerSecond, +frameWidth, frameHeight`. + +- **Bitrate (kbps)** = delta of `bytesReceived` across two samples taken + `--statsSettleMs` apart: `(deltaBytes * 8) / deltaMs`. +- **Packet-loss %** = `packetsLost / (packetsLost + packetsReceived) * 100`. +- **fps / resolution** are read from the latest (second) video report. + +## Prerequisites + +- **Puppeteer** — already installed in `load-test/node_modules`; no extra install. +- **A broadcaster-capable account** — the credentials must be able to open + `/broadcaster` and click "Go live". Supplied via `--email`/`--password` or the + environment variables `CROWDSTREAM_TEST_EMAIL` / `CROWDSTREAM_TEST_PASSWORD`. + Credentials are never hardcoded; the script exits with an error if they are + missing. +- **A running CrowdStream frontend + backend** reachable at `--baseUrl`. +- **CPU headroom on the client.** Each viewer is a real headless Chrome tab + decoding real video. The client browser fleet is **CPU-bound**, so large runs + (hundreds of viewers) must be spread across **multiple machines** — otherwise + you are measuring the load generator, not the SFU. + +## How to run + +```bash +cd load-test + +export CROWDSTREAM_TEST_EMAIL="broadcaster@example.com" +export CROWDSTREAM_TEST_PASSWORD="••••••••" + +node media-fanout-capacity.js \ + --baseUrl http://localhost \ + --batchSize 5 \ + --batchIntervalMs 10000 \ + --maxViewers 100 \ + --statsSettleMs 2000 \ + --out media-fanout-results.csv +``` + +Credentials may instead be passed inline with `--email` / `--password`. + +### Arguments + +| Arg | Default | Meaning | +|---|---|---| +| `--baseUrl` | `http://localhost` | Frontend base URL. | +| `--batchSize` | `5` | Viewers added per batch. | +| `--batchIntervalMs` | `10000` | Wait between batches (ms). | +| `--maxViewers` | `100` | Stop after this many total viewers. | +| `--statsSettleMs` | `2000` | Gap between the two getStats samples (ms) used for the bitrate delta. | +| `--out` | `media-fanout-results.csv` | CSV output path (rewritten after each batch). | +| `--email` | env `CROWDSTREAM_TEST_EMAIL` | Broadcaster login email. | +| `--password` | env `CROWDSTREAM_TEST_PASSWORD` | Broadcaster login password. | + +### CSV output + +One row per batch: + +``` +timestamp,viewers,joinP50,joinP99,firstFrameP50,firstFrameP99,meanBitrateKbps,meanLossPct,meanFps,failures +``` + +`viewers` and `failures` are cumulative; the latency percentiles and the +`meanBitrateKbps` / `meanLossPct` / `meanFps` columns describe the batch that +just joined (i.e. the newest viewers experiencing the current, highest load). + +## Interpreting results — spotting the ceiling + +The media-plane ceiling is **not** a single hard cliff; it shows up as +degradation across several columns as `viewers` climbs: + +- **`meanBitrateKbps` falls** — the SFU (or the client) can no longer push full + video to each viewer; per-viewer bitrate is being starved. +- **`meanFps` drops** below the source frame rate — frames are being dropped + end-to-end. +- **`meanLossPct` rises** above ~1–2% — packets are being lost on the media + path. +- **`firstFrameP99` climbs** sharply — new viewers wait longer (or time out) + before the first decoded frame. +- **`failures` increases** — viewers no longer reach `__csJoinedAt` / + `__csFirstFrameAt` at all. + +The **knee** is the viewer count where bitrate/fps start dropping and loss/ +failures start rising together. That is the practical media-plane capacity for +the tested hardware. If none of these degrade up to `--maxViewers`, raise +`--maxViewers` (and add client machines) until they do. + +## Caveats + +- **The client CPU is usually the bottleneck first.** These are real Chrome tabs + decoding real video. Before trusting a ceiling, confirm the load-generator + host is not CPU-saturated; if it is, the degradation is on the client, not the + SFU. Spread viewers across multiple machines and re-run. +- **These are client-observed numbers.** For **authoritative server-side** + bitrate / loss / jitter / fps, wire up the backend's own `getStats()` path — + `backend/src/utils/sendMetrics.ts` already sketches exactly this + (bandwidth, packet loss, jitter, frame rate, resolution) but is **currently + commented out** in its entirety. Enabling it would let the server report the + media plane directly rather than inferring it from headless clients. +- **Per-batch means describe the newest viewers**, not a re-sample of every + active viewer. This is intentional (the newest viewers see the highest load), + but it means a viewer that degrades after its batch measurement is not + re-counted. +- **Fake media** (`--use-fake-device-for-media-stream`) is a synthetic pattern; + absolute bitrate figures depend on the encoder settings the app negotiates. + +## Results + +_Pending execution against a running backend — not fabricated._ + +| viewers | joinP50 | joinP99 | firstFrameP50 | firstFrameP99 | meanBitrateKbps | meanLossPct | meanFps | failures | +|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| _tbd_ | _tbd_ | _tbd_ | _tbd_ | _tbd_ | _tbd_ | _tbd_ | _tbd_ | _tbd_ |