@@ -511,6 +567,194 @@
Console Logs
let currentSessionId = null;
let currentSubscribeToken = null;
let model = models.realtime("lucy-2.1");
+
+ // -----------------------------------------------------------------------
+ // LiveKit publish profiles for 1:1 streaming to the AI inference server.
+ // Profile A-B and F-H candidates keep H.264 and maxFramerate: 30.
+ // The default entry intentionally preserves the SDK's simulcast baseline.
+ // For a single backend consumer, single-layer H.264 is the expected best path;
+ // simulcast remains here only as a control to measure its overhead.
+ // Profiles C-E keep the VP9/AV1 SVC candidates for comparison.
+ // -----------------------------------------------------------------------
+ const PUBLISH_PROFILES = {
+ A: {
+ label: 'H.264 economy — 2.1 Mbps @ 30 fps, maintain-resolution',
+ description: 'Bandwidth-shave candidate. ~91% of F\'s budget and 84% of G\'s, same resolution and 30 fps target, biased to preserve pixel fidelity (drop fps before res). Tests how much bitrate we can shave before model input quality breaks.',
+ roomOptions: {
+ adaptiveStream: false,
+ dynacast: true,
+ },
+ publishOptions: {
+ videoCodec: 'h264',
+ simulcast: false,
+ videoEncoding: {
+ maxBitrate: 2_100_000,
+ maxFramerate: 30,
+ priority: 'high',
+ },
+ degradationPreference: 'maintain-resolution',
+ },
+ },
+ B: {
+ label: 'H.264 sharper-frames — 2.5 Mbps @ 24 fps, maintain-resolution',
+ description: 'Bits-per-frame candidate. Same 2.5 Mbps as G but at 24 fps, so each frame gets ~25% more bits and visibly sharper detail. Bets the inference model values per-frame fidelity over cadence — and that 24 fps is enough for its temporal needs.',
+ roomOptions: {
+ adaptiveStream: false,
+ dynacast: true,
+ },
+ publishOptions: {
+ videoCodec: 'h264',
+ simulcast: false,
+ videoEncoding: {
+ maxBitrate: 2_500_000,
+ maxFramerate: 24,
+ priority: 'high',
+ },
+ degradationPreference: 'maintain-resolution',
+ },
+ },
+ C: {
+ label: 'VP9 SVC L1T3 (temporal only)',
+ description: 'VP9 with 3 temporal layers (single resolution). Loss recovery without spatial encode cost.',
+ roomOptions: {
+ adaptiveStream: false,
+ dynacast: false,
+ },
+ publishOptions: {
+ videoCodec: 'vp9',
+ scalabilityMode: 'L1T3',
+ simulcast: false,
+ videoEncoding: {
+ maxBitrate: 2_000_000,
+ maxFramerate: 30,
+ },
+ backupCodec: { codec: 'h264' },
+ degradationPreference: 'maintain-framerate',
+ },
+ },
+ D: {
+ label: 'VP9 SVC L3T3_KEY (spatial+temporal)',
+ description: 'VP9 full SVC: 3 spatial x 3 temporal layers. Best resilience on bad networks.',
+ roomOptions: {
+ adaptiveStream: false,
+ dynacast: false,
+ },
+ publishOptions: {
+ videoCodec: 'vp9',
+ scalabilityMode: 'L3T3_KEY',
+ simulcast: false,
+ videoEncoding: {
+ maxBitrate: 2_300_000,
+ maxFramerate: 30,
+ },
+ backupCodec: { codec: 'h264' },
+ degradationPreference: 'maintain-framerate',
+ },
+ },
+ E: {
+ label: 'AV1 SVC L3T3_KEY (compression-first)',
+ description: 'AV1 full SVC. Best compression, but watch client CPU. Falls back to H.264 if unsupported.',
+ roomOptions: {
+ adaptiveStream: false,
+ dynacast: false,
+ },
+ publishOptions: {
+ videoCodec: 'av1',
+ scalabilityMode: 'L3T3_KEY',
+ simulcast: false,
+ videoEncoding: {
+ maxBitrate: 2_000_000,
+ maxFramerate: 30,
+ },
+ backupCodec: { codec: 'h264' },
+ degradationPreference: 'maintain-framerate',
+ },
+ },
+ F: {
+ label: 'H.264 constrained — 2.3 Mbps @ 24 fps, maintain-framerate',
+ description: 'H.264 single layer with a lower bitrate ceiling for weaker networks; drops resolution before FPS (preserves 24 fps cadence under pressure).',
+ roomOptions: {
+ adaptiveStream: false,
+ dynacast: false,
+ },
+ publishOptions: {
+ videoCodec: 'h264',
+ simulcast: false,
+ videoEncoding: {
+ maxBitrate: 2_300_000,
+ maxFramerate: 24,
+ priority: 'high',
+ },
+ degradationPreference: 'maintain-framerate',
+ },
+ },
+ G: {
+ label: 'H.264 high bitrate — 2.5 Mbps @ 30 fps, maintain-resolution',
+ description: 'H.264 single layer with more bitrate headroom for clean networks while still preserving resolution under pressure (drops fps before resolution).',
+ roomOptions: {
+ adaptiveStream: false,
+ dynacast: false,
+ },
+ publishOptions: {
+ videoCodec: 'h264',
+ simulcast: false,
+ videoEncoding: {
+ maxBitrate: 2_500_000,
+ maxFramerate: 30,
+ priority: 'high',
+ },
+ degradationPreference: 'maintain-resolution',
+ },
+ },
+ };
+
+ const DEFAULT_PROFILE_KEY = Object.keys(PUBLISH_PROFILES)[0];
+ const DEFAULT_CHAIN_PROFILE_KEYS = ['A', 'B', 'F', 'G'];
+
+ function shuffledDefaultChain() {
+ const keys = DEFAULT_CHAIN_PROFILE_KEYS.filter((k) => k in PUBLISH_PROFILES);
+ for (let i = keys.length - 1; i > 0; i -= 1) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [keys[i], keys[j]] = [keys[j], keys[i]];
+ }
+ return keys;
+ }
+
+ // Chain state: array of profile keys to run sequentially, plus the index currently active.
+ // Read lazily so it tracks the current bench-duration input value at chain time.
+ function getChainIntervalMs() {
+ const durationSeconds = Number(elements?.benchDuration?.value);
+ return Number.isFinite(durationSeconds) && durationSeconds > 0
+ ? (durationSeconds * 1000) + 6000
+ : 127_000;
+ }
+ const chainedProfileKeys = shuffledDefaultChain();
+ let chainIndex = 0;
+ let chainActive = false;
+ let chainAdvanceTimer = null;
+
+ function getSelectedPublishProfile() {
+ const key = chainedProfileKeys[chainIndex] ?? chainedProfileKeys[0] ?? DEFAULT_PROFILE_KEY;
+ const profile = PUBLISH_PROFILES[key];
+ if (!profile) return { key: DEFAULT_PROFILE_KEY, ...PUBLISH_PROFILES[DEFAULT_PROFILE_KEY] };
+ return { key, ...profile };
+ }
+
+ const PROFILE_SHORT_LABELS = {
+ A: 'A — H.264 2.1 Mbps @ 30 fps, maintain-resolution',
+ B: 'B — H.264 2.5 Mbps @ 24 fps, maintain-resolution',
+ C: 'C — VP9 SVC L1T3',
+ D: 'D — VP9 SVC L3T3_KEY',
+ E: 'E — AV1 SVC L3T3_KEY',
+ F: 'F — H.264 2.3 Mbps @ 24 fps, maintain-framerate',
+ G: 'G — H.264 2.5 Mbps @ 30 fps, maintain-resolution',
+ };
+
+ function buildProfileOptionsHtml() {
+ return Object.keys(PUBLISH_PROFILES)
+ .map((key) => `
`)
+ .join('');
+ }
// DOM elements
const elements = {
@@ -518,12 +762,44 @@
Console Logs
modelSelect: document.getElementById('model-select'),
realtimeBaseUrl: document.getElementById('realtime-base-url'),
resolutionSelect: document.getElementById('resolution-select'),
+ chainedProfilesContainer: document.getElementById('chained-profiles-container'),
+ publishProfileDescription: document.getElementById('publish-profile-description'),
initialPrompt: document.getElementById('initial-prompt'),
cameraFps: document.getElementById('camera-fps'),
- streamAudioToggle: document.getElementById('stream-audio-toggle'),
+ sourceFile: document.getElementById('source-file'),
+ startFileSource: document.getElementById('start-file-source'),
startCamera: document.getElementById('start-camera'),
connectBtn: document.getElementById('connect-btn'),
disconnectBtn: document.getElementById('disconnect-btn'),
+ printStatsBtn: document.getElementById('print-stats-btn'),
+ sessionTimer: document.getElementById('session-timer'),
+ // Benchmark dashboard
+ benchDuration: document.getElementById('bench-duration'),
+ benchStart: document.getElementById('bench-start'),
+ benchStop: document.getElementById('bench-stop'),
+ recordingName: document.getElementById('recording-name'),
+ includeRecordingToggle: document.getElementById('include-recording-toggle'),
+ benchExportAll: document.getElementById('bench-export-all'),
+ benchDashboard: document.getElementById('bench-dashboard'),
+ benchClear: document.getElementById('bench-clear'),
+ benchStatusText: document.getElementById('bench-status-text'),
+ benchElapsedText: document.getElementById('bench-elapsed-text'),
+ benchLive: document.getElementById('bench-live'),
+ benchChartFpsTx: document.getElementById('bench-chart-fps-tx'),
+ benchChartFpsRx: document.getElementById('bench-chart-fps-rx'),
+ benchChartBitrateTx: document.getElementById('bench-chart-bitrate-tx'),
+ benchChartBitrateRx: document.getElementById('bench-chart-bitrate-rx'),
+ benchChartQpTx: document.getElementById('bench-chart-qp-tx'),
+ benchChartQpRx: document.getElementById('bench-chart-qp-rx'),
+ benchChartBppTx: document.getElementById('bench-chart-bpp-tx'),
+ benchChartBppRx: document.getElementById('bench-chart-bpp-rx'),
+ benchChartRtt: document.getElementById('bench-chart-rtt'),
+ benchChartLoss: document.getElementById('bench-chart-loss'),
+ benchChartNackTx: document.getElementById('bench-chart-nack-tx'),
+ benchChartNackRx: document.getElementById('bench-chart-nack-rx'),
+ benchChartKeyframesTx: document.getElementById('bench-chart-keyframes-tx'),
+ benchChartKeyframesRx: document.getElementById('bench-chart-keyframes-rx'),
+ benchChartSummary: document.getElementById('bench-chart-summary'),
localVideo: document.getElementById('local-video'),
remoteVideo: document.getElementById('remote-video'),
promptInput: document.getElementById('prompt-input'),
@@ -543,6 +819,7 @@
Console Logs
publisherSubscribeToken: document.getElementById('publisher-subscribe-token'),
copySubscribeToken: document.getElementById('copy-subscribe-token'),
subscribeTokenInput: document.getElementById('subscribe-token-input'),
+ subscribeSection: document.getElementById('subscribe-section'),
subscribeBtn: document.getElementById('subscribe-btn'),
logsContainer: document.getElementById('logs-container'),
// Image reference elements
@@ -562,7 +839,7 @@
Console Logs
processStatusText: document.getElementById('process-status-text'),
processedContainer: document.getElementById('processed-container'),
originalVideo: document.getElementById('original-video'),
- processedVideo: document.getElementById('processed-video')
+ processedVideo: document.getElementById('processed-video'),
};
// Pre-populate API key from environment variable if available
@@ -638,6 +915,1268 @@
Console Logs
};
}
+ // --- Stats aggregator: tracks running sum + count per numeric field, per group key. ---
+ const STATS_POLL_INTERVAL_MS = 5000;
+ let statsPollTimer = null;
+ let statsRunCount = 0;
+ // Map
>
+ const statsAggregates = new Map();
+
+ function aggregateStatObject(groupKey, statObj) {
+ if (!statObj) return;
+ let groupAgg = statsAggregates.get(groupKey);
+ if (!groupAgg) {
+ groupAgg = new Map();
+ statsAggregates.set(groupKey, groupAgg);
+ }
+ for (const [field, value] of Object.entries(statObj)) {
+ if (typeof value !== 'number' || !Number.isFinite(value)) continue;
+ const slot = groupAgg.get(field) ?? { sum: 0, count: 0 };
+ slot.sum += value;
+ slot.count += 1;
+ groupAgg.set(field, slot);
+ }
+ }
+
+ function formatStatsForLog(statObj) {
+ const slim = {};
+ for (const [k, v] of Object.entries(statObj)) {
+ if (v === null || v === undefined) continue;
+ if (typeof v === 'object') continue;
+ slim[k] = v;
+ }
+ return JSON.stringify(slim);
+ }
+
+ async function pollAndAggregateStats() {
+ if (!decartRealtime?.getVideoStats) return;
+ let result;
+ try {
+ result = await decartRealtime.getVideoStats();
+ } catch (error) {
+ addLog(`Stats poll failed: ${error.message}`, 'error');
+ return;
+ }
+ statsRunCount += 1;
+ const { sender = [], receiver = [] } = result;
+ if (sender.length === 0 && receiver.length === 0) {
+ addLog(`[stats #${statsRunCount}] no tracks yet`, 'info');
+ return;
+ }
+ sender.forEach((stats, idx) => {
+ const key = `sender[${stats.rid || idx}]`;
+ aggregateStatObject(key, stats);
+ addLog(`[stats #${statsRunCount}] ${key} ${formatStatsForLog(stats)}`, 'info');
+ });
+ receiver.forEach((stats, idx) => {
+ const key = `receiver[${idx}]`;
+ aggregateStatObject(key, stats);
+ addLog(`[stats #${statsRunCount}] ${key} ${formatStatsForLog(stats)}`, 'info');
+ });
+ }
+
+ // --- Session timer ---
+ let sessionTimerInterval = null;
+ let sessionStartedAt = 0;
+
+ function startSessionTimer() {
+ stopSessionTimer();
+ sessionStartedAt = Date.now();
+ elements.sessionTimer.textContent = '0s';
+ sessionTimerInterval = setInterval(() => {
+ const seconds = Math.floor((Date.now() - sessionStartedAt) / 1000);
+ elements.sessionTimer.textContent = `${seconds}s`;
+ }, 1000);
+ }
+
+ function stopSessionTimer() {
+ if (sessionTimerInterval) {
+ clearInterval(sessionTimerInterval);
+ sessionTimerInterval = null;
+ }
+ }
+
+ // --- Prompt cycler ---
+ const CYCLE_PROMPTS = [
+ 'add a red baseball cap',
+ 'put a black leather jacket on the person',
+ 'replace the shirt with a white tank top',
+ 'add round sunglasses',
+ 'add a knitted winter scarf',
+ 'put a yellow raincoat on the person',
+ 'add a gold chain necklace',
+ ];
+ const PROMPT_CYCLE_INTERVAL_MS = 10000;
+ let promptCycleTimer = null;
+ let promptCycleIndex = 0;
+
+ function sendCyclePrompt() {
+ if (!decartRealtime || !isConnected || activeConnectionMode !== 'publisher') return;
+ const prompt = CYCLE_PROMPTS[promptCycleIndex % CYCLE_PROMPTS.length];
+ promptCycleIndex += 1;
+ try {
+ decartRealtime.setPrompt(prompt, { enhance: true });
+ addLog(`[cycle ${promptCycleIndex}/${CYCLE_PROMPTS.length}] prompt → "${prompt}"`, 'info');
+ } catch (error) {
+ addLog(`Cycle prompt failed: ${error.message}`, 'error');
+ }
+ }
+
+ function startPromptCycler() {
+ stopPromptCycler();
+ promptCycleIndex = 0;
+ sendCyclePrompt();
+ promptCycleTimer = setInterval(sendCyclePrompt, PROMPT_CYCLE_INTERVAL_MS);
+ addLog(`Started prompt cycler (${CYCLE_PROMPTS.length} prompts × ${PROMPT_CYCLE_INTERVAL_MS / 1000}s)`, 'info');
+ }
+
+ function stopPromptCycler() {
+ if (promptCycleTimer) {
+ clearInterval(promptCycleTimer);
+ promptCycleTimer = null;
+ }
+ }
+
+ function startStatsPolling() {
+ stopStatsPolling();
+ statsPollTimer = setInterval(pollAndAggregateStats, STATS_POLL_INTERVAL_MS);
+ elements.printStatsBtn.disabled = false;
+ startSessionTimer();
+ addLog(`Started stats polling every ${STATS_POLL_INTERVAL_MS / 1000}s`, 'info');
+ }
+
+ function stopStatsPolling() {
+ if (statsPollTimer) {
+ clearInterval(statsPollTimer);
+ statsPollTimer = null;
+ }
+ }
+
+ function resetStatsAggregates() {
+ statsAggregates.clear();
+ statsRunCount = 0;
+ addLog('Cleared stats aggregates', 'info');
+ }
+
+ function buildStatsSummary() {
+ const summary = { samples: statsRunCount };
+ for (const [groupKey, fields] of statsAggregates) {
+ const group = {};
+ for (const [field, { sum, count }] of fields) {
+ group[field] = sum;
+ }
+ summary[groupKey] = group;
+ }
+ return summary;
+ }
+
+ function printStatsSummary() {
+ const summary = buildStatsSummary();
+ console.log('[Decart SDK] Stats averages:', summary);
+ addLog('Stats averages printed to console (see DevTools for full object)', 'success');
+ }
+
+ // --- Benchmark engine ---
+ const BENCH_POLL_INTERVAL_MS = 1000;
+ const BENCH_PALETTE = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'];
+
+ const benchmarkRuns = [];
+ let currentBenchRun = null;
+ let benchPollTimer = null;
+ let benchRunTimer = null;
+ let benchElapsedTimer = null;
+ let benchCharts = null;
+
+ function nowMs() { return Date.now(); }
+
+ let annotationPluginRegistered = false;
+ function ensureAnnotationPlugin() {
+ if (annotationPluginRegistered || typeof Chart === 'undefined') return;
+ const plugin = window['chartjs-plugin-annotation'];
+ if (plugin) {
+ Chart.register(plugin);
+ annotationPluginRegistered = true;
+ }
+ }
+
+ const QP_MAX = { H264: 51, VP8: 127, VP9: 255, AV1: 255 };
+ function qpMaxForCodec(codec) {
+ const key = (codec || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
+ if (key.includes('H264') || key.includes('AVC')) return QP_MAX.H264;
+ if (key.includes('VP8')) return QP_MAX.VP8;
+ if (key.includes('VP9')) return QP_MAX.VP9;
+ if (key.includes('AV1')) return QP_MAX.AV1;
+ return null;
+ }
+
+ function normalizeQp(qp, codec) {
+ if (qp == null || !Number.isFinite(qp)) return null;
+ const max = qpMaxForCodec(codec);
+ if (!max) return null;
+ return Math.max(0, Math.min(100, (qp / max) * 100));
+ }
+
+ const stripePatternCache = new Map();
+ function makeStripePattern(color) {
+ if (stripePatternCache.has(color)) return stripePatternCache.get(color);
+ const c = document.createElement('canvas');
+ c.width = 8; c.height = 8;
+ const ctx = c.getContext('2d');
+ ctx.fillStyle = color;
+ ctx.fillRect(0, 0, 8, 8);
+ ctx.strokeStyle = 'rgba(255,255,255,0.65)';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(-2, 6); ctx.lineTo(6, -2);
+ ctx.moveTo(2, 10); ctx.lineTo(10, 2);
+ ctx.stroke();
+ const pattern = ctx.createPattern(c, 'repeat');
+ stripePatternCache.set(color, pattern);
+ return pattern;
+ }
+
+ function ensureBenchCharts() {
+ if (benchCharts || typeof Chart === 'undefined') return benchCharts;
+ ensureAnnotationPlugin();
+ const sharedTooltip = {
+ mode: 'index',
+ intersect: false,
+ position: 'nearest',
+ itemSort: (a, b) => (b.parsed.y ?? -Infinity) - (a.parsed.y ?? -Infinity),
+ callbacks: {
+ label: (ctx) => {
+ const v = ctx.parsed.y;
+ const value = v == null || Number.isNaN(v) ? '—' : Number(v).toLocaleString(undefined, { maximumFractionDigits: 2 });
+ return `${ctx.dataset.label}: ${value}`;
+ },
+ },
+ };
+ const timeSeriesPlugins = (titleText) => ({
+ title: { display: true, text: titleText },
+ tooltip: sharedTooltip,
+ legend: { labels: { boxWidth: 10 } },
+ });
+ const commonOpts = {
+ animation: false,
+ responsive: true,
+ maintainAspectRatio: true,
+ interaction: { mode: 'index', intersect: false, axis: 'x' },
+ scales: {
+ x: { type: 'linear', title: { display: true, text: 't (s)' } },
+ y: { beginAtZero: true },
+ },
+ };
+ const buildQpChartOptions = (titleText) => ({
+ animation: false,
+ responsive: true,
+ maintainAspectRatio: true,
+ interaction: { mode: 'index', intersect: false, axis: 'x' },
+ scales: {
+ x: { type: 'linear', title: { display: true, text: 't (s)' } },
+ y: {
+ type: 'linear',
+ position: 'left',
+ min: 0,
+ max: 100,
+ title: { display: true, text: 'Avg QP % (0–100, lower = better)' },
+ },
+ },
+ plugins: {
+ title: { display: true, text: titleText },
+ tooltip: {
+ ...sharedTooltip,
+ callbacks: {
+ label(ctx) {
+ const ds = ctx.dataset;
+ const v = ctx.parsed.y;
+ const value = v == null || Number.isNaN(v) ? '—' : Number(v).toFixed(1);
+ if (ds._qpRaw) {
+ const raw = ds._qpRaw[ctx.dataIndex];
+ const codec = ds._qpCodec;
+ const rawStr = raw != null && Number.isFinite(raw) ? raw.toFixed(1) : '—';
+ return `${ds.label}: ${value}% (raw ${rawStr} ${codec ?? '?'})`;
+ }
+ return `${ds.label}: ${value}`;
+ },
+ },
+ },
+ legend: { labels: { boxWidth: 10 } },
+ annotation: {
+ annotations: {
+ qpGreen: { type: 'box', yScaleID: 'y', yMin: 0, yMax: 40, backgroundColor: 'rgba(76, 175, 80, 0.06)', borderWidth: 0 },
+ qpYellow: { type: 'box', yScaleID: 'y', yMin: 40, yMax: 65, backgroundColor: 'rgba(255, 193, 7, 0.08)', borderWidth: 0 },
+ qpRed: { type: 'box', yScaleID: 'y', yMin: 65, yMax: 100, backgroundColor: 'rgba(244, 67, 54, 0.08)', borderWidth: 0 },
+ },
+ },
+ },
+ });
+ const buildBppChartOptions = (titleText) => ({
+ animation: false,
+ responsive: true,
+ maintainAspectRatio: true,
+ interaction: { mode: 'index', intersect: false, axis: 'x' },
+ scales: {
+ x: { type: 'linear', title: { display: true, text: 't (s)' } },
+ y: {
+ type: 'linear',
+ position: 'left',
+ beginAtZero: true,
+ title: { display: true, text: 'BPP (bits per pixel)' },
+ },
+ },
+ plugins: {
+ title: { display: true, text: titleText },
+ tooltip: sharedTooltip,
+ legend: { labels: { boxWidth: 10 } },
+ annotation: {
+ annotations: {
+ bppLow: { type: 'line', yScaleID: 'y', yMin: 0.07, yMax: 0.07, borderColor: 'rgba(0,0,0,0.4)', borderWidth: 1, borderDash: [4, 4], label: { display: true, content: '0.07', position: 'end', backgroundColor: 'rgba(0,0,0,0.0)', color: 'rgba(0,0,0,0.6)' } },
+ bppHigh: { type: 'line', yScaleID: 'y', yMin: 0.15, yMax: 0.15, borderColor: 'rgba(0,0,0,0.4)', borderWidth: 1, borderDash: [4, 4], label: { display: true, content: '0.15', position: 'end', backgroundColor: 'rgba(0,0,0,0.0)', color: 'rgba(0,0,0,0.6)' } },
+ },
+ },
+ },
+ });
+ benchCharts = {
+ fpsTx: new Chart(elements.benchChartFpsTx, {
+ type: 'line',
+ data: { datasets: [] },
+ options: { ...commonOpts, plugins: timeSeriesPlugins('Sender FPS (tx, per second)') },
+ }),
+ fpsRx: new Chart(elements.benchChartFpsRx, {
+ type: 'line',
+ data: { datasets: [] },
+ options: { ...commonOpts, plugins: timeSeriesPlugins('Receiver FPS (rx, per second)') },
+ }),
+ bitrateTx: new Chart(elements.benchChartBitrateTx, {
+ type: 'line',
+ data: { datasets: [] },
+ options: { ...commonOpts, plugins: timeSeriesPlugins('Sender bitrate (tx, kbps)') },
+ }),
+ bitrateRx: new Chart(elements.benchChartBitrateRx, {
+ type: 'line',
+ data: { datasets: [] },
+ options: { ...commonOpts, plugins: timeSeriesPlugins('Receiver bitrate (rx, kbps)') },
+ }),
+ rtt: new Chart(elements.benchChartRtt, {
+ type: 'line',
+ data: { datasets: [] },
+ options: { ...commonOpts, plugins: timeSeriesPlugins('RTT (ms)') },
+ }),
+ loss: new Chart(elements.benchChartLoss, {
+ type: 'line',
+ data: { datasets: [] },
+ options: { ...commonOpts, plugins: timeSeriesPlugins('Packet loss % (cumulative)') },
+ }),
+ nackTx: new Chart(elements.benchChartNackTx, {
+ type: 'line',
+ data: { datasets: [] },
+ options: { ...commonOpts, plugins: timeSeriesPlugins('NACKs received by sender (tx, per sample)') },
+ }),
+ nackRx: new Chart(elements.benchChartNackRx, {
+ type: 'line',
+ data: { datasets: [] },
+ options: { ...commonOpts, plugins: timeSeriesPlugins('NACKs sent by receiver (rx, per sample)') },
+ }),
+ keyframesTx: new Chart(elements.benchChartKeyframesTx, {
+ type: 'line',
+ data: { datasets: [] },
+ options: { ...commonOpts, plugins: timeSeriesPlugins('Keyframes sent (tx, per sample)') },
+ }),
+ keyframesRx: new Chart(elements.benchChartKeyframesRx, {
+ type: 'line',
+ data: { datasets: [] },
+ options: { ...commonOpts, plugins: timeSeriesPlugins('Keyframes received (rx, per sample / PLI storms)') },
+ }),
+ qpTx: new Chart(elements.benchChartQpTx, {
+ type: 'line',
+ data: { datasets: [] },
+ options: buildQpChartOptions('Avg QP % per frame — sender (tx, normalized across codecs)'),
+ }),
+ qpRx: new Chart(elements.benchChartQpRx, {
+ type: 'line',
+ data: { datasets: [] },
+ options: buildQpChartOptions('Avg QP % per frame — receiver (rx, normalized across codecs)'),
+ }),
+ bppTx: new Chart(elements.benchChartBppTx, {
+ type: 'line',
+ data: { datasets: [] },
+ options: buildBppChartOptions('BPP — sender (tx, bits per pixel), 0.07–0.15 reference'),
+ }),
+ bppRx: new Chart(elements.benchChartBppRx, {
+ type: 'line',
+ data: { datasets: [] },
+ options: buildBppChartOptions('BPP — receiver (rx, bits per pixel), 0.07–0.15 reference'),
+ }),
+ summary: new Chart(elements.benchChartSummary, {
+ type: 'bar',
+ data: { labels: [], datasets: [] },
+ options: {
+ animation: false,
+ responsive: true,
+ interaction: { mode: 'index', intersect: false, axis: 'x' },
+ plugins: {
+ title: { display: true, text: 'Per-run summary' },
+ tooltip: {
+ ...sharedTooltip,
+ callbacks: {
+ label(ctx) {
+ const ds = ctx.dataset;
+ const v = ctx.parsed.y;
+ const value = v == null || Number.isNaN(v)
+ ? '—'
+ : Number(v).toLocaleString(undefined, { maximumFractionDigits: 2 });
+ if (ds._qpRaw) {
+ const raw = ds._qpRaw[ctx.dataIndex];
+ const codec = ds._qpCodec[ctx.dataIndex];
+ const rawStr = raw != null && Number.isFinite(raw) ? raw.toFixed(1) : '—';
+ return `${ds.label}: ${value}% (raw ${rawStr} ${codec ?? '?'})`;
+ }
+ return `${ds.label}: ${value}`;
+ },
+ },
+ },
+ },
+ scales: { y: { beginAtZero: true } },
+ },
+ }),
+ };
+ return benchCharts;
+ }
+
+ function runColor(idx) { return BENCH_PALETTE[idx % BENCH_PALETTE.length]; }
+
+ function runLabel(run, idx) { return `#${idx + 1} ${run.profile}`; }
+
+ function refreshTimeSeriesCharts() {
+ const charts = ensureBenchCharts();
+ if (!charts) return;
+ const runs = [...benchmarkRuns, ...(currentBenchRun ? [currentBenchRun] : [])];
+ const datasets = {
+ fpsTx: [], fpsRx: [],
+ bitrateTx: [], bitrateRx: [],
+ rtt: [], loss: [],
+ nackTx: [], nackRx: [],
+ keyframesTx: [], keyframesRx: [],
+ qpTx: [], qpRx: [],
+ bppTx: [], bppRx: [],
+ };
+ runs.forEach((run, idx) => {
+ const color = runColor(idx);
+ const label = runLabel(run, idx);
+ const points = run.samples.map((s) => ({ x: +(s.tMs / 1000).toFixed(2), d: s.derived }));
+ const outCodec = points.find((p) => p.d.outboundCodec)?.d.outboundCodec ?? '?';
+ const inCodec = points.find((p) => p.d.inboundCodec)?.d.inboundCodec ?? '?';
+ const lineBase = { borderColor: color, backgroundColor: color, spanGaps: true, pointRadius: 0 };
+ datasets.fpsTx.push({ label, ...lineBase, data: points.map((p) => ({ x: p.x, y: p.d.senderFps })) });
+ datasets.fpsRx.push({ label, ...lineBase, data: points.map((p) => ({ x: p.x, y: p.d.receiverFps })) });
+ datasets.bitrateTx.push({ label, ...lineBase, data: points.map((p) => ({ x: p.x, y: p.d.senderBitrateBps != null ? p.d.senderBitrateBps / 1000 : null })) });
+ datasets.bitrateRx.push({ label, ...lineBase, data: points.map((p) => ({ x: p.x, y: p.d.receiverBitrateBps != null ? p.d.receiverBitrateBps / 1000 : null })) });
+ datasets.rtt.push({ label, ...lineBase, data: points.map((p) => ({ x: p.x, y: p.d.rtt })) });
+ datasets.loss.push({ label, ...lineBase, data: points.map((p) => ({ x: p.x, y: p.d.cumulativeLossPct })) });
+ datasets.nackTx.push({ label, ...lineBase, data: points.map((p) => ({ x: p.x, y: p.d.nackOutDelta })) });
+ datasets.nackRx.push({ label, ...lineBase, data: points.map((p) => ({ x: p.x, y: p.d.nackInDelta })) });
+ datasets.keyframesTx.push({ label, ...lineBase, data: points.map((p) => ({ x: p.x, y: p.d.keyFramesOutDelta })) });
+ datasets.keyframesRx.push({ label, ...lineBase, data: points.map((p) => ({ x: p.x, y: p.d.keyFramesInDelta })) });
+ const qpOutRawSeries = points.map((p) => p.d.avgQpOut);
+ const qpInRawSeries = points.map((p) => p.d.avgQpIn);
+ datasets.qpTx.push({
+ label: `${label} (${outCodec})`,
+ ...lineBase,
+ yAxisID: 'y',
+ data: points.map((p) => ({ x: p.x, y: normalizeQp(p.d.avgQpOut, outCodec) })),
+ _qpRaw: qpOutRawSeries,
+ _qpCodec: outCodec,
+ });
+ datasets.qpRx.push({
+ label: `${label} (${inCodec})`,
+ ...lineBase,
+ yAxisID: 'y',
+ data: points.map((p) => ({ x: p.x, y: normalizeQp(p.d.avgQpIn, inCodec) })),
+ _qpRaw: qpInRawSeries,
+ _qpCodec: inCodec,
+ });
+ datasets.bppTx.push({ label, ...lineBase, yAxisID: 'y', data: points.map((p) => ({ x: p.x, y: p.d.bppOut })) });
+ datasets.bppRx.push({ label, ...lineBase, yAxisID: 'y', data: points.map((p) => ({ x: p.x, y: p.d.bppIn })) });
+ });
+ Object.keys(datasets).forEach((key) => {
+ if (!charts[key]) return;
+ charts[key].data.datasets = datasets[key];
+ charts[key].update();
+ });
+ }
+
+ function refreshSummaryChart() {
+ const charts = ensureBenchCharts();
+ if (!charts) return;
+ const labels = benchmarkRuns.map((r, i) => runLabel(r, i));
+ const pick = (key) => benchmarkRuns.map((r) => key(r.summary));
+
+ const BITRATE_COLOR = '#17becf';
+ const BPP_COLOR = '#b15928';
+ const QP_COLOR = '#6a3d9a';
+
+ const qpInRaw = pick((s) => s.avgQpIn);
+ const qpOutRaw = pick((s) => s.avgQpOut);
+ const qpInCodec = pick((s) => s.inboundCodec);
+ const qpOutCodec = pick((s) => s.outboundCodec);
+
+ charts.summary.data.labels = labels;
+ charts.summary.data.datasets = [
+ { label: 'Avg FPS', data: pick((s) => s.fps.avg), backgroundColor: '#2ca02c' },
+ { label: 'p5 FPS', data: pick((s) => s.fps.p5), backgroundColor: '#ff7f0e' },
+ { label: 'FPS σ', data: pick((s) => s.fps.stddev), backgroundColor: '#9467bd' },
+
+ {
+ label: 'Avg tx kbps / 100',
+ data: pick((s) => s.senderBitrateBps / 100000),
+ backgroundColor: BITRATE_COLOR,
+ borderColor: BITRATE_COLOR,
+ },
+ {
+ label: 'Avg rx kbps / 100',
+ data: pick((s) => s.receiverBitrateBps / 100000),
+ backgroundColor: makeStripePattern(BITRATE_COLOR),
+ borderColor: BITRATE_COLOR,
+ borderWidth: 1,
+ },
+
+ {
+ label: 'Avg QP % out',
+ data: qpOutRaw.map((q, i) => normalizeQp(q, qpOutCodec[i])),
+ backgroundColor: QP_COLOR,
+ borderColor: QP_COLOR,
+ _qpRaw: qpOutRaw,
+ _qpCodec: qpOutCodec,
+ },
+ {
+ label: 'Avg QP % in',
+ data: qpInRaw.map((q, i) => normalizeQp(q, qpInCodec[i])),
+ backgroundColor: makeStripePattern(QP_COLOR),
+ borderColor: QP_COLOR,
+ borderWidth: 1,
+ _qpRaw: qpInRaw,
+ _qpCodec: qpInCodec,
+ },
+
+ {
+ label: 'Avg BPP out × 100',
+ data: pick((s) => s.avgBppOut != null ? s.avgBppOut * 100 : null),
+ backgroundColor: BPP_COLOR,
+ borderColor: BPP_COLOR,
+ },
+ {
+ label: 'Avg BPP in × 100',
+ data: pick((s) => s.avgBppIn != null ? s.avgBppIn * 100 : null),
+ backgroundColor: makeStripePattern(BPP_COLOR),
+ borderColor: BPP_COLOR,
+ borderWidth: 1,
+ },
+
+ { label: 'Freezes total', data: pick((s) => s.freezeCount), backgroundColor: '#d62728' },
+ ];
+ charts.summary.update();
+ }
+
+ function sumLayerField(layers, field) {
+ let total = 0;
+ let seen = false;
+ for (const layer of layers ?? []) {
+ const value = layer?.[field];
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ total += value;
+ seen = true;
+ }
+ }
+ return seen ? total : null;
+ }
+
+ function safeDelta(currTotal, prevTotal) {
+ if (currTotal == null || prevTotal == null) return null;
+ const delta = currTotal - prevTotal;
+ return Number.isFinite(delta) && delta >= 0 ? delta : null;
+ }
+
+ function firstCodec(items) {
+ for (const item of items ?? []) {
+ if (item?.codecMimeType) return item.codecMimeType;
+ }
+ return null;
+ }
+
+ function deriveSampleMetrics(curr, prev) {
+ const d = {
+ outboundCodec: null,
+ inboundCodec: null,
+ receiverFps: null,
+ receiverBitrateBps: null,
+ senderBitrateBps: null,
+ senderFps: null,
+ packetsLostDelta: 0,
+ cumulativeLossPct: null,
+ rtt: null,
+ outFrameWidth: null,
+ outFrameHeight: null,
+ inFrameWidth: null,
+ inFrameHeight: null,
+ inFps: null,
+ nackOutDelta: 0,
+ nackInDelta: 0,
+ keyFramesOutTotal: null,
+ keyFramesOutDelta: 0,
+ keyFramesInTotal: null,
+ keyFramesInDelta: 0,
+ qlReasonOut: null,
+ bppOut: null,
+ bppIn: null,
+ qpSumOutTotal: null,
+ framesEncodedTotal: null,
+ avgQpOut: null,
+ totalEncodeTimeSec: null,
+ avgEncodeMs: null,
+ qpSumInTotal: null,
+ framesDecodedTotal: null,
+ avgQpIn: null,
+ freezeCountTotal: null,
+ freezeCountDelta: 0,
+ totalFreezesDurationSec: null,
+ pauseCountTotal: null,
+ pauseCountDelta: 0,
+ framesDroppedInTotal: null,
+ framesDroppedInDelta: 0,
+ };
+
+ const recv = curr.receiver?.[0];
+ if (recv) {
+ d.inFrameWidth = recv.frameWidth ?? null;
+ d.inFrameHeight = recv.frameHeight ?? null;
+ d.inFps = typeof recv.framesPerSecond === 'number' ? recv.framesPerSecond : null;
+ d.qpSumInTotal = recv.qpSum ?? null;
+ d.framesDecodedTotal = recv.framesDecoded ?? null;
+ d.keyFramesInTotal = recv.keyFramesDecoded ?? null;
+ d.freezeCountTotal = recv.freezeCount ?? null;
+ d.pauseCountTotal = recv.pauseCount ?? null;
+ d.framesDroppedInTotal = recv.framesDropped ?? null;
+ d.totalFreezesDurationSec = recv.totalFreezesDuration ?? null;
+
+ const prevRecv = prev?.receiver?.[0];
+ if (prevRecv) {
+ const dtMs = recv.timestamp - prevRecv.timestamp;
+ if (dtMs > 0) {
+ d.receiverFps = ((recv.framesDecoded - prevRecv.framesDecoded) * 1000) / dtMs;
+ d.receiverBitrateBps = ((recv.bytesReceived - prevRecv.bytesReceived) * 8 * 1000) / dtMs;
+ }
+ d.packetsLostDelta = Math.max(0, (recv.packetsLost ?? 0) - (prevRecv.packetsLost ?? 0));
+ d.nackInDelta = Math.max(0, (recv.nackCount ?? 0) - (prevRecv.nackCount ?? 0));
+ d.freezeCountDelta = Math.max(0, (recv.freezeCount ?? 0) - (prevRecv.freezeCount ?? 0));
+ d.pauseCountDelta = Math.max(0, (recv.pauseCount ?? 0) - (prevRecv.pauseCount ?? 0));
+ d.framesDroppedInDelta = Math.max(0, (recv.framesDropped ?? 0) - (prevRecv.framesDropped ?? 0));
+ d.keyFramesInDelta = Math.max(0, (recv.keyFramesDecoded ?? 0) - (prevRecv.keyFramesDecoded ?? 0));
+
+ const qpDelta = safeDelta(recv.qpSum, prevRecv.qpSum);
+ const decodedDelta = safeDelta(recv.framesDecoded, prevRecv.framesDecoded);
+ if (qpDelta != null && decodedDelta && decodedDelta > 0) {
+ d.avgQpIn = qpDelta / decodedDelta;
+ }
+ }
+ const totalLost = recv.packetsLost ?? 0;
+ const totalRecv = recv.packetsReceived ?? 0;
+ const denom = totalLost + totalRecv;
+ d.cumulativeLossPct = denom > 0 ? (totalLost / denom) * 100 : null;
+ }
+
+ if (curr.sender && curr.sender.length > 0) {
+ d.senderFps = curr.sender.reduce((m, s) => Math.max(m, s.framesPerSecond ?? 0), 0);
+ d.qlReasonOut = curr.sender[0].qualityLimitationReason ?? null;
+ d.outFrameWidth = curr.sender[0].frameWidth ?? null;
+ d.outFrameHeight = curr.sender[0].frameHeight ?? null;
+ d.qpSumOutTotal = sumLayerField(curr.sender, 'qpSum');
+ d.framesEncodedTotal = sumLayerField(curr.sender, 'framesEncoded');
+ d.totalEncodeTimeSec = sumLayerField(curr.sender, 'totalEncodeTime');
+ d.keyFramesOutTotal = sumLayerField(curr.sender, 'keyFramesEncoded');
+ const nackOutTotal = sumLayerField(curr.sender, 'nackCount');
+
+ let bytesNow = 0;
+ let bytesPrev = 0;
+ let dtMs = 0;
+ for (let i = 0; i < curr.sender.length; i++) {
+ const s = curr.sender[i];
+ bytesNow += s.bytesSent ?? 0;
+ const p = prev?.sender?.[i];
+ if (p) {
+ bytesPrev += p.bytesSent ?? 0;
+ if (!dtMs) dtMs = s.timestamp - p.timestamp;
+ }
+ }
+ if (dtMs > 0) d.senderBitrateBps = ((bytesNow - bytesPrev) * 8 * 1000) / dtMs;
+ d.rtt = curr.sender.reduce((m, s) => Math.max(m, (s.roundTripTime ?? 0) * 1000), 0) || null;
+
+ if (prev?.sender) {
+ const prevQpOut = sumLayerField(prev.sender, 'qpSum');
+ const prevFramesEncoded = sumLayerField(prev.sender, 'framesEncoded');
+ const prevTotalEncodeTime = sumLayerField(prev.sender, 'totalEncodeTime');
+ const prevKeyFramesEncoded = sumLayerField(prev.sender, 'keyFramesEncoded');
+ const prevNackOut = sumLayerField(prev.sender, 'nackCount');
+
+ const qpDelta = safeDelta(d.qpSumOutTotal, prevQpOut);
+ const framesEncodedDelta = safeDelta(d.framesEncodedTotal, prevFramesEncoded);
+ const totalEncodeTimeDelta = safeDelta(d.totalEncodeTimeSec, prevTotalEncodeTime);
+
+ if (qpDelta != null && framesEncodedDelta && framesEncodedDelta > 0) {
+ d.avgQpOut = qpDelta / framesEncodedDelta;
+ }
+ if (totalEncodeTimeDelta != null && framesEncodedDelta && framesEncodedDelta > 0) {
+ d.avgEncodeMs = (totalEncodeTimeDelta * 1000) / framesEncodedDelta;
+ }
+ d.keyFramesOutDelta = safeDelta(d.keyFramesOutTotal, prevKeyFramesEncoded) ?? 0;
+ d.nackOutDelta = safeDelta(nackOutTotal, prevNackOut) ?? 0;
+ }
+ }
+
+ d.outboundCodec = firstCodec(curr.sender);
+ d.inboundCodec = firstCodec(curr.receiver);
+
+ let senderPixelsPerSec = 0;
+ for (const s of curr.sender ?? []) {
+ const w = s.frameWidth;
+ const h = s.frameHeight;
+ const fps = s.framesPerSecond;
+ if (w && h && fps) senderPixelsPerSec += w * h * fps;
+ }
+ if (senderPixelsPerSec > 0 && d.senderBitrateBps != null && d.senderBitrateBps > 0) {
+ d.bppOut = d.senderBitrateBps / senderPixelsPerSec;
+ }
+
+ const inFpsForBpp = d.inFps && d.inFps > 0 ? d.inFps : d.receiverFps;
+ if (
+ d.inFrameWidth && d.inFrameHeight && inFpsForBpp && inFpsForBpp > 0 &&
+ d.receiverBitrateBps != null && d.receiverBitrateBps > 0
+ ) {
+ d.bppIn = d.receiverBitrateBps / (d.inFrameWidth * d.inFrameHeight * inFpsForBpp);
+ }
+
+ return d;
+ }
+
+
+ async function collectBenchSample() {
+ if (!currentBenchRun || !decartRealtime?.getVideoStats) return;
+ let raw;
+ try { raw = await decartRealtime.getVideoStats(); } catch (err) { addLog(`Benchmark sample failed: ${err.message}`, 'error'); return; }
+ const tMs = nowMs() - currentBenchRun.startedAtMs;
+ const derived = deriveSampleMetrics(raw, currentBenchRun.prevRaw);
+ const sample = { tMs, sender: raw.sender, receiver: raw.receiver, derived };
+ currentBenchRun.samples.push(sample);
+ currentBenchRun.prevRaw = raw;
+ elements.benchLive.textContent =
+ `t=${(tMs / 1000).toFixed(0)}s ` +
+ `rxFps=${(derived.receiverFps ?? 0).toFixed(1)} rxKbps=${((derived.receiverBitrateBps ?? 0) / 1000).toFixed(0)} ` +
+ `txKbps=${((derived.senderBitrateBps ?? 0) / 1000).toFixed(0)} rtt=${(derived.rtt ?? 0).toFixed(0)}ms ` +
+ `out=${derived.outFrameWidth ?? '?'}×${derived.outFrameHeight ?? '?'} (${derived.outboundCodec ?? '?'}) ` +
+ `in=${derived.inFrameWidth ?? '?'}×${derived.inFrameHeight ?? '?'} (${derived.inboundCodec ?? '?'}) ` +
+ `qpOut=${derived.avgQpOut?.toFixed?.(1) ?? '?'} qpIn=${derived.avgQpIn?.toFixed?.(1) ?? '?'} ` +
+ `bppOut=${derived.bppOut?.toFixed?.(3) ?? '?'} bppIn=${derived.bppIn?.toFixed?.(3) ?? '?'}`;
+ if (currentBenchRun.samples.length % 2 === 0) refreshTimeSeriesCharts();
+ }
+
+ function avg(arr) { return arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; }
+ function stddev(arr) {
+ if (arr.length < 2) return 0;
+ const m = avg(arr);
+ return Math.sqrt(arr.reduce((acc, v) => acc + (v - m) ** 2, 0) / arr.length);
+ }
+ function percentile(arr, p) {
+ if (!arr.length) return null;
+ const s = [...arr].sort((a, b) => a - b);
+ const i = Math.min(s.length - 1, Math.max(0, Math.floor((p / 100) * s.length)));
+ return s[i];
+ }
+
+ function meanIgnoringNulls(arr) {
+ const filtered = arr.filter((v) => v != null && Number.isFinite(v));
+ return filtered.length ? filtered.reduce((a, b) => a + b, 0) / filtered.length : null;
+ }
+
+ function averageFromRunDeltas(samples, totalKey, countKey) {
+ const first = samples.find((s) =>
+ typeof s.derived[totalKey] === 'number' &&
+ Number.isFinite(s.derived[totalKey]) &&
+ typeof s.derived[countKey] === 'number' &&
+ Number.isFinite(s.derived[countKey])
+ )?.derived;
+ const last = [...samples].reverse().find((s) =>
+ typeof s.derived[totalKey] === 'number' &&
+ Number.isFinite(s.derived[totalKey]) &&
+ typeof s.derived[countKey] === 'number' &&
+ Number.isFinite(s.derived[countKey])
+ )?.derived;
+ const totalDelta = safeDelta(last?.[totalKey], first?.[totalKey]);
+ const countDelta = safeDelta(last?.[countKey], first?.[countKey]);
+ return totalDelta != null && countDelta != null && countDelta > 0 ? totalDelta / countDelta : null;
+ }
+
+ function computeBenchSummary(run) {
+ const fpsArr = run.samples.map((s) => s.derived.receiverFps).filter((v) => v != null && Number.isFinite(v));
+ const rxBr = run.samples.map((s) => s.derived.receiverBitrateBps).filter((v) => v != null && Number.isFinite(v));
+ const txBr = run.samples.map((s) => s.derived.senderBitrateBps).filter((v) => v != null && Number.isFinite(v));
+ const rtts = run.samples.map((s) => s.derived.rtt).filter((v) => v != null && Number.isFinite(v) && v > 0);
+ const totalLost = run.samples.reduce((a, s) => a + (s.derived.packetsLostDelta || 0), 0);
+ const totalReceived = run.samples.reduce(
+ (a, s) => a + ((s.receiver?.[0]?.packetsReceived ?? 0) - (a > 0 ? 0 : 0)),
+ 0,
+ );
+ const lastRecv = run.samples[run.samples.length - 1]?.receiver?.[0];
+ const firstRecv = run.samples.find((s) => s.receiver?.[0])?.receiver?.[0];
+ const denom = (lastRecv?.packetsReceived ?? 0) - (firstRecv?.packetsReceived ?? 0)
+ + (lastRecv?.packetsLost ?? 0) - (firstRecv?.packetsLost ?? 0);
+ const loss = denom > 0 ? totalLost / denom : 0;
+
+ const lastSample = run.samples[run.samples.length - 1];
+ const firstSampleWithFreeze = run.samples.find((s) => s.derived.freezeCountTotal != null);
+ const freezeCount = lastSample?.derived.freezeCountTotal != null
+ ? lastSample.derived.freezeCountTotal - (firstSampleWithFreeze?.derived.freezeCountTotal ?? 0)
+ : 0;
+ const freezeDurSec = lastSample?.derived.totalFreezesDurationSec != null
+ ? lastSample.derived.totalFreezesDurationSec - (firstSampleWithFreeze?.derived.totalFreezesDurationSec ?? 0)
+ : 0;
+
+ const outResolution = lastSample?.derived.outFrameWidth && lastSample.derived.outFrameHeight
+ ? `${lastSample.derived.outFrameWidth}×${lastSample.derived.outFrameHeight}` : 'n/a';
+ const inResolution = lastSample?.derived.inFrameWidth && lastSample.derived.inFrameHeight
+ ? `${lastSample.derived.inFrameWidth}×${lastSample.derived.inFrameHeight}` : 'n/a';
+ const avgFps = avg(fpsArr);
+ const avgRx = avg(rxBr);
+ const bitratePerFrame = avgFps > 0 ? avgRx / avgFps : 0;
+ const outboundCodec = run.samples.find((s) => s.derived.outboundCodec)?.derived.outboundCodec ?? null;
+ const inboundCodec = run.samples.find((s) => s.derived.inboundCodec)?.derived.inboundCodec ?? null;
+ const sumDeltas = (key) => run.samples.reduce((a, s) => a + (s.derived[key] || 0), 0);
+ return {
+ durationMs: run.endedAtMs - run.startedAtMs,
+ samples: run.samples.length,
+ fps: {
+ avg: avgFps,
+ min: fpsArr.length ? Math.min(...fpsArr) : null,
+ p5: percentile(fpsArr, 5),
+ p95: percentile(fpsArr, 95),
+ stddev: stddev(fpsArr),
+ largestDip: fpsArr.length ? avgFps - Math.min(...fpsArr) : null,
+ },
+ senderBitrateBps: avg(txBr),
+ receiverBitrateBps: avgRx,
+ bytesPerFrame: bitratePerFrame / 8,
+ rttMs: avg(rtts),
+ packetLoss: loss,
+ freezeCount,
+ totalFreezesDurationSec: freezeDurSec,
+ outResolution,
+ inResolution,
+ outboundCodec,
+ inboundCodec,
+ avgQpOut: averageFromRunDeltas(run.samples, 'qpSumOutTotal', 'framesEncodedTotal'),
+ avgQpIn: averageFromRunDeltas(run.samples, 'qpSumInTotal', 'framesDecodedTotal'),
+ avgBppOut: meanIgnoringNulls(run.samples.map((s) => s.derived.bppOut)),
+ avgBppIn: meanIgnoringNulls(run.samples.map((s) => s.derived.bppIn)),
+ avgEncodeMs: meanIgnoringNulls(run.samples.map((s) => s.derived.avgEncodeMs)),
+ keyFramesOutDeltaTotal: sumDeltas('keyFramesOutDelta'),
+ keyFramesInDeltaTotal: sumDeltas('keyFramesInDelta'),
+ nackOutDeltaTotal: sumDeltas('nackOutDelta'),
+ nackInDeltaTotal: sumDeltas('nackInDelta'),
+ };
+ }
+
+ function startBenchmark() {
+ if (currentBenchRun) { addLog('Benchmark already running', 'error'); return; }
+ if (!decartRealtime?.getVideoStats) { addLog('Not connected — cannot start benchmark', 'error'); return; }
+ const profileKey = getSelectedPublishProfile().key;
+ const durationSeconds = Math.max(10, Math.min(600, Number(elements.benchDuration.value) || 120));
+ currentBenchRun = {
+ profile: profileKey,
+ sessionId: currentSessionId,
+ durationMs: durationSeconds * 1000,
+ startedAtMs: nowMs(),
+ endedAtMs: null,
+ samples: [],
+ prevRaw: null,
+ };
+ elements.benchStart.disabled = true;
+ elements.benchStop.disabled = false;
+ elements.benchExportAll.disabled = false;
+ elements.benchClear.disabled = false;
+ elements.benchStatusText.textContent = `running (profile=${profileKey})`;
+ elements.benchElapsedText.textContent = '0s';
+ benchPollTimer = setInterval(collectBenchSample, BENCH_POLL_INTERVAL_MS);
+ benchRunTimer = setTimeout(stopBenchmark, durationSeconds * 1000);
+ benchElapsedTimer = setInterval(() => {
+ const s = Math.floor((nowMs() - currentBenchRun.startedAtMs) / 1000);
+ elements.benchElapsedText.textContent = `${s}s / ${durationSeconds}s`;
+ }, 1000);
+ addLog(`Benchmark started: profile=${profileKey} duration=${durationSeconds}s pollInterval=${BENCH_POLL_INTERVAL_MS}ms`, 'success');
+ }
+
+ function stopBenchmark() {
+ if (!currentBenchRun) return;
+ if (benchPollTimer) { clearInterval(benchPollTimer); benchPollTimer = null; }
+ if (benchRunTimer) { clearTimeout(benchRunTimer); benchRunTimer = null; }
+ if (benchElapsedTimer) { clearInterval(benchElapsedTimer); benchElapsedTimer = null; }
+ currentBenchRun.endedAtMs = nowMs();
+ currentBenchRun.summary = computeBenchSummary(currentBenchRun);
+ benchmarkRuns.push(currentBenchRun);
+ console.log('[Benchmark] Run complete:', currentBenchRun);
+ addLog(`Benchmark stopped. profile=${currentBenchRun.profile} avgFps=${currentBenchRun.summary.fps.avg.toFixed(1)} p5Fps=${(currentBenchRun.summary.fps.p5 ?? 0).toFixed(1)}`, 'success');
+ const finished = currentBenchRun;
+ currentBenchRun = null;
+ elements.benchStart.disabled = !decartRealtime?.getVideoStats;
+ elements.benchStop.disabled = true;
+ elements.benchStatusText.textContent = `idle (last: ${finished.profile})`;
+ refreshTimeSeriesCharts();
+ refreshSummaryChart();
+ }
+
+ function sanitizeFileName(raw) {
+ const trimmed = (raw || '').trim();
+ if (!trimmed) return `decart_export_${Date.now()}`;
+ return trimmed.replace(/[\\/:*?"<>|]+/g, '_').replace(/\s+/g, '_').slice(0, 120);
+ }
+
+ function triggerDownload(blob, filename) {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }
+
+ async function exportChartsBlob() {
+ if (typeof html2canvas !== 'function') {
+ throw new Error('html2canvas not loaded');
+ }
+ const target = elements.benchDashboard;
+ if (!target) throw new Error('Benchmark dashboard element not found');
+ const canvas = await html2canvas(target, {
+ backgroundColor: '#ffffff',
+ scale: window.devicePixelRatio > 1 ? 2 : 1,
+ useCORS: true,
+ logging: false,
+ });
+ const blob = await new Promise((resolve, reject) => {
+ canvas.toBlob((b) => b ? resolve(b) : reject(new Error('toBlob returned null')), 'image/png');
+ });
+ return { blob, width: canvas.width, height: canvas.height };
+ }
+
+ function buildCsvBlob() {
+ const header = [
+ 'run', 'profile', 'sessionId', 'tMs',
+ 'outboundCodec', 'inboundCodec',
+ 'receiverFps', 'receiverKbps', 'senderKbps', 'senderFps',
+ 'outFrameWidth', 'outFrameHeight', 'inFrameWidth', 'inFrameHeight', 'inFps',
+ 'bppOut', 'bppIn',
+ 'qpSumOutTotal', 'framesEncodedTotal', 'avgQpOut',
+ 'qpSumInTotal', 'framesDecodedTotal', 'avgQpIn',
+ 'totalEncodeTimeSec', 'avgEncodeMs',
+ 'rttMs', 'packetsLostDelta', 'cumulativeLossPct',
+ 'nackOutDelta', 'nackInDelta',
+ 'keyFramesOutTotal', 'keyFramesOutDelta', 'keyFramesInTotal', 'keyFramesInDelta',
+ 'framesDroppedInTotal', 'framesDroppedInDelta',
+ 'freezeCountTotal', 'freezeCountDelta', 'totalFreezesDurationSec',
+ 'pauseCountTotal', 'pauseCountDelta',
+ 'qlReasonOut',
+ ];
+ const rows = [header.join(',')];
+ benchmarkRuns.forEach((run, idx) => {
+ run.samples.forEach((s) => {
+ const d = s.derived;
+ rows.push([
+ idx + 1,
+ run.profile,
+ run.sessionId ?? '',
+ s.tMs,
+ d.outboundCodec ?? '',
+ d.inboundCodec ?? '',
+ d.receiverFps?.toFixed(3) ?? '',
+ d.receiverBitrateBps != null ? (d.receiverBitrateBps / 1000).toFixed(2) : '',
+ d.senderBitrateBps != null ? (d.senderBitrateBps / 1000).toFixed(2) : '',
+ d.senderFps?.toFixed(3) ?? '',
+ d.outFrameWidth ?? '',
+ d.outFrameHeight ?? '',
+ d.inFrameWidth ?? '',
+ d.inFrameHeight ?? '',
+ d.inFps != null ? d.inFps.toFixed(3) : '',
+ d.bppOut != null ? d.bppOut.toFixed(6) : '',
+ d.bppIn != null ? d.bppIn.toFixed(6) : '',
+ d.qpSumOutTotal ?? '',
+ d.framesEncodedTotal ?? '',
+ d.avgQpOut != null ? d.avgQpOut.toFixed(3) : '',
+ d.qpSumInTotal ?? '',
+ d.framesDecodedTotal ?? '',
+ d.avgQpIn != null ? d.avgQpIn.toFixed(3) : '',
+ d.totalEncodeTimeSec != null ? d.totalEncodeTimeSec.toFixed(4) : '',
+ d.avgEncodeMs != null ? d.avgEncodeMs.toFixed(3) : '',
+ d.rtt?.toFixed(2) ?? '',
+ d.packetsLostDelta ?? '',
+ d.cumulativeLossPct?.toFixed(4) ?? '',
+ d.nackOutDelta ?? '',
+ d.nackInDelta ?? '',
+ d.keyFramesOutTotal ?? '',
+ d.keyFramesOutDelta ?? '',
+ d.keyFramesInTotal ?? '',
+ d.keyFramesInDelta ?? '',
+ d.framesDroppedInTotal ?? '',
+ d.framesDroppedInDelta ?? '',
+ d.freezeCountTotal ?? '',
+ d.freezeCountDelta ?? '',
+ d.totalFreezesDurationSec != null ? d.totalFreezesDurationSec.toFixed(3) : '',
+ d.pauseCountTotal ?? '',
+ d.pauseCountDelta ?? '',
+ d.qlReasonOut ?? '',
+ ].join(','));
+ });
+ });
+ const blob = new Blob([rows.join('\n')], { type: 'text/csv' });
+ return blob;
+ }
+
+ async function exportAll() {
+ const btn = elements.benchExportAll;
+ const prev = btn.textContent;
+ btn.disabled = true;
+ btn.textContent = 'Exporting…';
+ const baseName = sanitizeFileName(elements.recordingName.value);
+ const ts = Date.now();
+ try {
+ if (benchmarkRuns.length > 0) {
+ const csvBlob = buildCsvBlob();
+ triggerDownload(csvBlob, `${baseName}_${ts}.csv`);
+ addLog(`Exported ${benchmarkRuns.length} run(s) as CSV → ${baseName}_${ts}.csv`, 'success');
+ } else {
+ addLog('No benchmark runs to export as CSV', 'info');
+ }
+ try {
+ const { blob: pngBlob, width, height } = await exportChartsBlob();
+ triggerDownload(pngBlob, `${baseName}_${ts}.png`);
+ addLog(`Exported benchmark dashboard as PNG (${width}×${height}) → ${baseName}_${ts}.png`, 'success');
+ } catch (err) {
+ addLog(`Chart export failed: ${err?.message ?? err}`, 'error');
+ }
+ if (!elements.includeRecordingToggle.checked) {
+ addLog('Chain recording skipped in export (checkbox off)', 'info');
+ } else if (chainRecorder.blobUrl) {
+ const ext = chainRecorder.lastExt || 'webm';
+ const a = document.createElement('a');
+ a.href = chainRecorder.blobUrl;
+ a.download = `${baseName}_${ts}.${ext}`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ addLog(`Exported chain recording → ${baseName}_${ts}.${ext}`, 'success');
+ } else {
+ addLog('No chain recording available to export', 'info');
+ }
+ } finally {
+ btn.disabled = false;
+ btn.textContent = prev;
+ }
+ }
+
+ function clearBenchRuns() {
+ if (currentBenchRun) { addLog('Stop the current benchmark first', 'error'); return; }
+ benchmarkRuns.length = 0;
+ elements.benchExportAll.disabled = true;
+ elements.benchClear.disabled = true;
+ elements.benchLive.textContent = '';
+ elements.benchStatusText.textContent = 'idle';
+ refreshTimeSeriesCharts();
+ refreshSummaryChart();
+ addLog('Benchmark runs cleared', 'info');
+ }
+
+ elements.benchStart.addEventListener('click', startBenchmark);
+ elements.benchStop.addEventListener('click', stopBenchmark);
+ elements.benchExportAll.addEventListener('click', exportAll);
+ elements.benchClear.addEventListener('click', clearBenchRuns);
+
+ // --- MediaRecorder for the Decart remote stream ---
+ let mediaRecorder = null;
+ let recordedChunks = [];
+
+ function pickSupportedMimeType() {
+ const candidates = [
+ 'video/webm;codecs=vp9',
+ 'video/webm;codecs=vp8',
+ 'video/webm;codecs=h264',
+ 'video/webm',
+ 'video/mp4',
+ ];
+ return candidates.find((m) => window.MediaRecorder?.isTypeSupported?.(m)) || '';
+ }
+
+ function startRecording() {
+ if (mediaRecorder) return;
+ const remoteStream = elements.remoteVideo.srcObject;
+ if (!(remoteStream instanceof MediaStream) || remoteStream.getVideoTracks().length === 0) {
+ addLog('No remote stream available to record', 'error');
+ return;
+ }
+ if (!window.MediaRecorder) {
+ addLog('MediaRecorder is not supported in this browser', 'error');
+ return;
+ }
+ const mimeType = pickSupportedMimeType();
+ recordedChunks = [];
+ try {
+ mediaRecorder = new MediaRecorder(remoteStream, mimeType ? { mimeType } : undefined);
+ } catch (error) {
+ addLog(`Failed to create MediaRecorder: ${error.message}`, 'error');
+ mediaRecorder = null;
+ return;
+ }
+ mediaRecorder.ondataavailable = (event) => {
+ if (event.data && event.data.size > 0) recordedChunks.push(event.data);
+ };
+ mediaRecorder.onstop = () => {
+ const finalType = mediaRecorder?.mimeType || 'video/webm';
+ const blob = new Blob(recordedChunks, { type: finalType });
+ const ext = finalType.includes('mp4') ? 'mp4' : 'webm';
+ const sizeMb = (blob.size / 1024 / 1024).toFixed(2);
+ recordedChunks = [];
+ mediaRecorder = null;
+ const shouldDownload = window.confirm(`Download recorded remote stream? (${sizeMb} MB ${ext})`);
+ if (!shouldDownload) {
+ addLog(`Recording stopped. Discarded ${sizeMb} MB (user declined download)`, 'info');
+ return;
+ }
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `decart_remote_${Date.now()}.${ext}`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ addLog(`Recording stopped. Downloaded ${sizeMb} MB (${ext})`, 'success');
+ };
+ mediaRecorder.start(1000);
+ addLog(`Auto-recording remote stream (${mediaRecorder.mimeType || 'default'})`, 'success');
+ }
+
+ function stopRecording() {
+ if (mediaRecorder && mediaRecorder.state !== 'inactive') {
+ mediaRecorder.stop();
+ }
+ }
+
+ // --- Chain-scoped recorder: one continuous MediaRecorder for the whole chain.
+ // Mirrors the remote