Files
Piik/scripts/browser-local-pool-page.js
AAA 95cc11f2c1 fix(media): restore capture recovery and forwarding cadence
Browser display capture could remain low-resolution after a temporary bandwidth reduction because sender adaptation also reduced the shared capture input. The encoded-output carrier could separately reduce frame rate after the real producer had already adapted, causing repeated queue recovery.

Keep display capture and sender feedback separate through bounded native frame streams, retain capture-owned constraints and metadata, and preserve carrier cadence. Existing WebRTC picture adaptation and transport ownership remain in place. Unsupported frame APIs retain ordinary sender tracks.

Real display acceptance also exposed an existing VP8 forwarding defect: a 15 fps L1T3 stream reached the SFU subscriber at about 4 fps because both shared forwarding owners overrode LiveKit's temporal limit with T0. Removing those overrides restores the library's layer selection for SFU and Native forwarding.

## Validation

- Web type checking, 1,186 tests and production build passed.
- Go tests, formatting, vet, Windows/Linux builds and Windows native capture checks passed; macOS packaging runs in the existing native CI job.
- H.264/VP8 real-tab first-frame, live/paused settings, source replacement, failure retirement and two-Viewer shaped-network checks passed.
- Real display through embedded SFU passed H.264 and VP8: hidden Host, two decoded spatial layers, resumed delivery, track replacement, profile updates, subscription recovery and full cleanup. VP8 decoded cadence recovered from about 4 to 15 fps. Track replacement uses the same visual source, and these checks do not measure A/V synchronization or physical mobile backgrounding.
- Shared RTP tests cover all three VP8 temporal layers through Transport and Publication; restoring the old T0 caps makes both tests fail.
- Documentation and repository hygiene checks passed.

## Release notes

### 中文

这是一次浏览器分享稳定性修复更新,重点改善网络波动后的画质恢复和编码复用时的画面流畅度。

- 修复部分 Chromium 浏览器屏幕共享降画质后,采集输入持续停留在低分辨率的问题。
- 修复编码复用路径重复降低帧率,导致画面卡顿和反复请求恢复帧的问题。
- 修复部分 VP8 画面经过媒体服务器或 App 转发时,帧率被错误限制在最低时间层的问题。
- 网页用户请在站点更新后刷新页面并重新开始分享;App 用户请更新并重启 App。

复杂画面的恢复速度和不同设备、网络下的短时清晰度波动仍在继续验证,本次更新不代表所有画质波动都已解决。

### English

This maintenance update improves browser sharing stability, focusing on picture recovery after network fluctuations and smoother delivery when encoded frames are reused.

- Fixed an issue in some Chromium browsers where display capture could remain at a low resolution after the sender reduced picture quality.
- Fixed repeated frame-rate reduction in the encoding-reuse path that could cause stuttering and repeated recovery-frame requests.
- Fixed an unintended frame-rate limit when VP8 video with temporal layers passed through the media server or Piik App's forwarding path.
- Web users should refresh the page and restart sharing after their site is updated. App users should update and restart Piik App.

Recovery with complex content and brief quality fluctuations across different devices and networks remain under investigation. This update does not claim to eliminate every quality fluctuation.
2026-09-30 20:15:52 +08:00

422 lines
24 KiB
JavaScript

// Actual HostPeer/BrowserEncodingPool comparison; synthetic source and local receivers only.
import { BrowserEncodingPool as ProbePool } from '../src/client/media/browser-encoding-pool';
import { HostPeer as ProbeHostPeer } from '../src/client/webrtc/host-peer';
import { applyVideoCaptureProfile as probeApplyProfile } from '../src/client/media/quality';
import { browserDebugEnabled, exportBrowserDebug } from '../src/client/lib/debug';
const settings = new URLSearchParams(location.search);
const mode = settings.get('mode') || 'carrier', codec = settings.get('codec') || 'VP8';
const network = settings.get('network') === '1', automatic = settings.get('automatic') === '1';
const high = settings.get('high') === '1', av = settings.get('av') === '1';
const single = settings.get('single') === '1', late = settings.get('late') === '1';
const nativeSource = settings.get('nativeSource') === '1', background = settings.get('background') === '1';
const lifecycle = settings.get('lifecycle') === '1', shapedRate = Number(settings.get('rate') || 120000);
const relay = settings.get('relay') === '1';
const shortPulse = settings.get('shortPulse') === '1';
const displaySource = settings.get('displaySource') === '1';
const quietStart = settings.get('quietStart') === '1';
const selectedProfile = () => ({ resolution: high ? '1080p' : '480p', maxFramerate: 30,
maxBitrate: high ? 5000000 : 1700000,
degradationPreference: settings.get('preference') || (high ? 'maintain-resolution' : 'balanced'),
screenAudioQuality: 'music' });
const productPool = mode === 'carrier' ? new ProbePool() : null;
const productPeers = [], peers = [], tracks = [], edges = [], monitors = [], audioMonitors = [];
const errors = [], sourcePulses = [], controls = [], visibility = [], productTrace = [], sourceFrames = [];
let sourceReader, sourceReading;
const displayClones = [];
if (displaySource) {
const clone = MediaStreamTrack.prototype.clone;
MediaStreamTrack.prototype.clone = function () {
const track = clone.call(this);
if (this.getSettings().displaySurface) displayClones.push(track);
return track;
};
}
const sourceTimes = new Map(), seenProductGroups = new Map();
let rawSource, drawing, productTraceTimer, audioContext, audioTrack, audioGain, upstreamPeer;
let producing = !quietStart, sourceFrame = 0, phase = 'startup';
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const poolState = () => productPool ? {
members: [...productPool.members].map(m => ({ id: m.id, group: m.current?.producer.id,
pending: m.pending?.group.producer.id, selecting: m.pending?.selecting, budget: m.budget,
mixed: m.mixed, disabled: m.disabled, carrier: m.carrier, replacing: !!m.replacement,
output: m.output, queued: m.encoded.current?.frames.length,
waitingKey: m.encoded.current?.needKey, pendingFrames: m.encoded.pending?.queue.frames.length })),
groups: [...productPool.groups].map(g => ({ id: g.producer.id, budget: g.budget,
ready: g.ready, failed: g.failed, output: g.output }))
} : null;
window.probeProgress = () => ({ phase, sourceFrame, errors, pool: poolState(),
peers: peers.map(p => ({ connection: p.connectionState, ice: p.iceConnectionState })),
audio: audioMonitors.map(m => ({ role: m.role, attached: m.attached, pulses: m.pulses.length })) });
function watch(video, role) {
const canvas = document.createElement('canvas');
canvas.width = 640; canvas.height = 360;
const context = canvas.getContext('2d', { willReadFrequently: true }), samples = [];
let stopped = false;
const next = () => video.requestVideoFrameCallback((_time, metadata) => {
if (stopped) return;
if (nativeSource || displaySource) {
samples.push({ at: performance.now(), phase, id: metadata.presentedFrames, age: null,
width: video.videoWidth, height: video.videoHeight });
} else {
try {
context.drawImage(video, 0, 0, 640, 360);
const pixels = context.getImageData(0, 0, 640, 30).data;
let id = 0;
for (let bit = 0; bit < 12; bit++) {
const index = (15 * 640 + 20 + bit * 50) * 4;
if (pixels[index] + pixels[index + 1] + pixels[index + 2] > 384) id |= 1 << bit;
}
const now = performance.now(), created = sourceTimes.get(id);
samples.push({ at: now, phase, id, age: created === undefined ? null : now - created,
width: video.videoWidth, height: video.videoHeight });
} catch (error) { errors.push(String(error.message)); }
}
next();
});
next();
monitors.push({ role, samples, stop: () => { stopped = true; } });
}
function watchAudio(video, role) {
// Observe element-level played audio, not the earlier receiver track or physical speakers.
const played = video.captureStream();
const monitor = { role, pulses: [], attached: false, active: false,
stop() { this.active = false; played.getTracks().forEach(t => t.stop()); } };
audioMonitors.push(monitor);
const attach = () => {
if (monitor.attached || !played.getAudioTracks().length) return;
monitor.attached = monitor.active = true;
const source = audioContext.createMediaStreamSource(played), analyser = audioContext.createAnalyser();
analyser.fftSize = 512;
const silent = audioContext.createGain();
silent.gain.value = 0;
source.connect(analyser).connect(silent).connect(audioContext.destination);
const values = new Float32Array(analyser.fftSize);
let sounded = false;
const sample = () => {
if (!monitor.active) { source.disconnect(); analyser.disconnect(); silent.disconnect(); return; }
analyser.getFloatTimeDomainData(values);
const loud = values.some(value => Math.abs(value) > .01);
if (loud && !sounded) monitor.pulses.push({ at: performance.now(), phase });
sounded = loud;
requestAnimationFrame(sample);
};
sample();
};
played.addEventListener('addtrack', attach);
attach();
}
async function pair(role) {
const receive = new RTCPeerConnection({ iceServers: [] }), pending = [];
peers.push(receive);
const stream = new MediaStream([rawSource, ...(audioTrack ? [audioTrack] : [])]);
let peer;
peer = new ProbeHostPeer(role, { iceServers: [] }, stream, selectedProfile(), {
sendSignal(_id, payload) {
if (payload.kind === 'candidate' && !(network && role === 'A')) {
if (receive.remoteDescription) receive.addIceCandidate(payload.candidate).catch(error => errors.push(String(error)));
else pending.push(payload.candidate);
}
return true;
},
onUpdate(snapshot) { if (snapshot.error) errors.push(snapshot.error); }
}, { primary: codec.toLowerCase(), vp8Fallback: false }, undefined, false, role === 'U' ? null : productPool);
if (role === 'U') upstreamPeer = peer;
else productPeers.push(peer);
peers.push(peer.connection);
const video = document.createElement('video'), received = new MediaStream();
video.autoplay = video.muted = video.playsInline = true;
video.width = 320; video.srcObject = received;
if (role !== 'U') document.body.append(video);
receive.ontrack = event => { received.addTrack(event.track); video.play().catch(() => {}); };
if (av && role !== 'U') video.addEventListener('loadeddata', () => watchAudio(video, role), { once: true });
if (!(network && role === 'A')) receive.onicecandidate = event => {
if (event.candidate) void peer.acceptSignal({ kind: 'candidate',
connectionId: peer.connectionId, candidate: event.candidate.toJSON() });
};
if (!await peer.start()) throw Error('Product HostPeer failed to start');
const edge = { role, peer, send: peer.connection, receive, sender: peer.videoSender, pending, received };
edges.push(edge);
if (role !== 'U') watch(video, role);
return edge;
}
async function connect(edge) {
if (network && edge.role === 'A') {
const gathered = async pc => {
const end = performance.now() + 5000;
while (pc.iceGatheringState !== 'complete' && performance.now() < end) await wait(20);
};
const firstPort = sdp => Number(sdp.match(/^a=candidate:\S+ 1 udp \d+ \S+ (\d+)/mi)?.[1]);
const proxySDP = (description, port) => ({ ...description, sdp: description.sdp.replace(/^a=candidate:.*\r?\n/gm, '')
.replace(/^a=mid:[^\r\n]+\r?$/gm, line => line.trimEnd() + '\r\na=candidate:proxy 1 udp 2130706431 127.0.0.1 ' + port + ' typ host\r') });
const proxy = await fetch('/shaper').then(r => r.json());
await gathered(edge.send);
const offer = { type: 'offer', sdp: edge.send.localDescription.sdp };
if (!offer.sdp.includes('a=end-of-candidates')) offer.sdp += 'a=end-of-candidates\r\n';
await edge.receive.setRemoteDescription(proxySDP(offer, proxy.ports.right));
await edge.receive.setLocalDescription(await edge.receive.createAnswer());
await gathered(edge.receive);
const answer = { type: 'answer', sdp: edge.receive.localDescription.sdp };
if (!answer.sdp.includes('a=end-of-candidates')) answer.sdp += 'a=end-of-candidates\r\n';
await fetch('/shaper?left=' + firstPort(offer.sdp) + '&right=' + firstPort(answer.sdp));
await edge.peer.acceptSignal({ kind: 'description', connectionId: edge.peer.connectionId,
description: proxySDP(answer, proxy.ports.left) });
} else {
await edge.receive.setRemoteDescription(edge.send.localDescription);
for (const candidate of edge.pending.splice(0)) await edge.receive.addIceCandidate(candidate);
await edge.receive.setLocalDescription(await edge.receive.createAnswer());
await edge.peer.acceptSignal({ kind: 'description', connectionId: edge.peer.connectionId,
description: edge.receive.localDescription });
}
const deadline = performance.now() + 6000;
while (edge.send.connectionState !== 'connected' && performance.now() < deadline) await wait(30);
if (edge.send.connectionState !== 'connected') throw Error('Connection deadline');
}
const selectReport = report => Array.from(report.values())
.filter(r => ['outbound-rtp', 'inbound-rtp', 'remote-inbound-rtp', 'remote-outbound-rtp',
'candidate-pair', 'transport', 'local-candidate', 'remote-candidate'].includes(r.type))
.map(r => {
const fields = ['type', 'kind', 'id', 'localCandidateId', 'remoteCandidateId', 'selectedCandidatePairId',
'port', 'nominated', 'framesEncoded', 'framesDecoded', 'framesSent', 'framesReceived', 'bytesSent',
'bytesReceived', 'totalEncodeTime', 'totalDecodeTime', 'frameWidth', 'frameHeight', 'framesPerSecond',
'targetBitrate', 'qualityLimitationReason', 'qpSum', 'pliCount', 'nackCount', 'retransmittedBytesSent',
'packetsLost', 'jitter', 'jitterBufferDelay', 'jitterBufferEmittedCount', 'freezeCount',
'totalFreezesDuration', 'reportsSent', 'remoteTimestamp', 'currentRoundTripTime', 'availableOutgoingBitrate', 'state'];
return Object.fromEntries(fields.filter(key => r[key] !== undefined).map(key => [key, r[key]]));
});
async function statistics() {
const result = {};
for (const edge of edges) {
const metrics = edge.peer.getSnapshot().metrics;
result[edge.role] = { send: selectReport(await edge.send.getStats()),
receive: selectReport(await edge.receive.getStats()),
details: Object.fromEntries(['frameWidth', 'frameHeight', 'framesPerSecond', 'bitrateKbps',
'qualityLimitationReason', 'nativeEdgeQualityState', 'intervalFramesSent', 'intervalFramesEncoded']
.map(key => [key, metrics[key]])) };
}
if (productPool) {
for (const group of productPool.groups) if (!seenProductGroups.has(group.producer.id))
seenProductGroups.set(group.producer.id, { name: 'G' + seenProductGroups.size, group });
for (const entry of seenProductGroups.values()) {
const { name, group } = entry;
const report = await group.producer.report().catch(() => entry.report ?? group.report);
if (report) {
entry.report = report;
result[name] = { send: selectReport(report), receive: [], retired: !productPool.groups.has(group) };
}
}
}
return result;
}
async function snapshot() {
return { phase, at: performance.now(), stats: await statistics(),
process: await fetch('/process').then(r => r.json()),
...(network ? { shaper: await fetch('/shaper').then(r => r.json()) } : {}) };
}
async function run() {
if (av) {
audioContext = new AudioContext({ sampleRate: 48000 });
await audioContext.resume();
const destination = audioContext.createMediaStreamDestination(), tone = audioContext.createOscillator();
tone.frequency.value = 1000;
audioGain = audioContext.createGain(); audioGain.gain.value = 0;
tone.connect(audioGain).connect(destination); tone.start();
audioTrack = destination.stream.getAudioTracks()[0]; tracks.push(audioTrack);
}
const source = document.createElement('canvas');
source.width = high ? 1920 : 640; source.height = high ? 1080 : 360;
if (!nativeSource) document.body.prepend(source);
const ctx = source.getContext('2d');
if (high) ctx.scale(3, 3);
if (displaySource) {
document.body.style.margin = '0';
source.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;z-index:10';
}
rawSource = displaySource ? await new Promise((resolve, reject) => {
window.acquireDisplay = () => navigator.mediaDevices.getDisplayMedia({ audio: false, preferCurrentTab: true,
selfBrowserSurface: 'include', video: { width: source.width, height: source.height, frameRate: 30 } })
.then(stream => resolve(stream.getVideoTracks()[0]), reject);
}) : nativeSource ? (await navigator.mediaDevices.getUserMedia({
video: { width: source.width, height: source.height, frameRate: 30 }, audio: false
})).getVideoTracks()[0] : source.captureStream(0).getVideoTracks()[0];
rawSource.contentHint = 'motion'; tracks.push(rawSource);
if (displaySource) {
sourceReader = new MediaStreamTrackProcessor({ track: rawSource, maxBufferSize: 1 }).readable.getReader();
sourceReading = (async () => { for (;;) {
const { done, value } = await sourceReader.read(); if (done) break;
sourceFrames.push({ at: performance.now(), phase, width: value.displayWidth, height: value.displayHeight }); value.close();
} })().catch(error => errors.push(String(error)));
}
await probeApplyProfile(rawSource, selectedProfile());
const main = rawSource;
const connectChildren = async () => {
for (const role of single ? ['A'] : ['A', 'B']) await pair(role);
for (const edge of edges) if (edge.role !== 'U' && (!late || edge.role !== 'B')) await connect(edge);
};
if (!relay) await connectChildren();
if (!nativeSource) drawing = setInterval(() => {
if (!producing) return;
const id = sourceFrame++ % 4096;
sourceTimes.set(id, performance.now());
if (audioGain) {
audioGain.gain.setValueAtTime(id % 60 < 6 ? .1 : 0, audioContext.currentTime);
if (id % 60 === 0) sourcePulses.push({ id, at: performance.now() });
}
ctx.fillStyle = '#172b3a'; ctx.fillRect(0, 0, 640, 360);
ctx.fillStyle = '#48c98b'; ctx.fillRect((sourceFrame * 11) % 540, 65, 100, 220);
ctx.fillStyle = '#f7b744'; ctx.font = '40px sans-serif'; ctx.fillText(String(id), 30, 335);
for (let bit = 0; bit < 12; bit++) {
ctx.fillStyle = id & (1 << bit) ? '#fff' : '#000'; ctx.fillRect(bit * 50, 0, 50, 30);
}
(relay ? main : rawSource).requestFrame?.();
}, 1000 / (lifecycle ? 60 : 30));
if (productPool) productTraceTimer = setInterval(() => productTrace.push({ at: performance.now(), phase, ...poolState() }), 500);
if (relay) {
const upstream = await pair('U');
await connect(upstream);
rawSource = upstream.received.getVideoTracks()[0];
audioTrack = upstream.received.getAudioTracks()[0];
if (!rawSource || av && !audioTrack) throw Error('Relay upstream did not supply its tracks');
rawSource.contentHint = 'motion';
await connectChildren();
}
if (late) { await wait(1000); await connect(edges.find(e => e.role === 'B')); }
await wait(1900);
if (productPool) {
const deadline = performance.now() + 12000;
let activated = null;
for (;;) {
const members = [...productPool.members];
const sharing = members.length === productPeers.length && members.every(m => m.current && m.carrier && !m.replacement) &&
new Set(members.map(m => m.current)).size === 1;
if (sharing) activated ??= performance.now();
else activated = null;
if (activated !== null && monitors.every(m => {
const last = m.samples.at(-1);
return last?.width > 16 && (nativeSource || displaySource ? last.at >= activated :
last.age !== null && (sourceTimes.get(last.id) ?? -1) >= activated);
})) break;
if (performance.now() >= deadline) throw Error('Product shared output did not become fresh');
await wait(30);
}
}
const descriptions = edges.map(e => ({ role: e.role,
extensions: e.send.localDescription?.sdp.match(/^a=extmap:.*$/gm),
parameters: e.sender.getParameters().encodings }));
const snapshots = [await snapshot()];
if (quietStart) {
const deadline = performance.now() + 10000;
while (!monitors.every(m => m.samples.some(s => s.width > 16))) {
if (performance.now() >= deadline) throw Error('Static display produced no first frame');
await wait(30);
}
controls.push({ action: 'static-first-frame', at: performance.now(), sourceFrame });
producing = true;
}
phase = 'healthy'; await wait(shortPulse ? 45000 : 6000); snapshots.push(await snapshot());
if (lifecycle) {
let appliedProfile = selectedProfile();
const apply = async profile => {
const at = performance.now();
await probeApplyProfile(rawSource, profile);
const applied = await Promise.all(productPeers.map(peer => peer.updateCaptureProfile(profile)));
controls.push({ phase, profile, elapsedMs: performance.now() - at, applied });
if (!applied.every(Boolean)) throw Error('Product profile update failed');
appliedProfile = profile;
};
const observe = async (name, duration = 6000) => {
phase = name; await wait(duration); snapshots.push(await snapshot());
if (name.startsWith('quiet-') || name.startsWith('paused-')) return;
for (const monitor of monitors) {
const last = monitor.samples.at(-1), ceiling = { '480p': 480, '720p': 720, '1080p': 1080 }[appliedProfile.resolution];
if (last?.phase !== name || (!displaySource && (last.age === null || last.age >= 1000)) ||
performance.now() - last.at >= 1000 || last.height > ceiling)
throw Error(name + ': receiver did not deliver current frames within the requested ceiling');
}
};
const p60 = { ...selectedProfile(), resolution: '720p', maxFramerate: 60, maxBitrate: 5000000 };
phase = 'profile-720p60'; await apply(p60); await observe(phase);
producing = false; phase = 'quiet-profile-480p30';
await apply({ ...p60, resolution: '480p', maxFramerate: 30, maxBitrate: 3000000 });
await observe(phase, 2500);
producing = true; await observe('resumed-480p30');
rawSource.enabled = false; productPeers.forEach(peer => peer.setPaused(true));
phase = 'paused-profile-720p30'; await apply({ ...p60, maxFramerate: 30 }); await observe(phase, 2000);
rawSource.enabled = true; productPeers.forEach(peer => peer.setPaused(false));
await observe('resumed-720p30');
const oldSource = rawSource;
rawSource = source.captureStream(0).getVideoTracks()[0];
rawSource.contentHint = 'motion'; tracks.push(rawSource);
await probeApplyProfile(rawSource, appliedProfile);
const stream = new MediaStream([rawSource, ...(audioTrack ? [audioTrack] : [])]);
const replaced = await Promise.all(productPeers.map(peer => peer.replaceStream(stream)));
controls.push({ action: 'source-replaced', replaced });
if (!replaced.every(Boolean)) throw Error('Product source replacement failed');
oldSource.stop(); await observe('replaced-source');
if (productPool && [...productPool.groups].some(group => group.source !== rawSource)) throw Error('Old source group survived replacement');
if (productPool) {
for (const group of [...productPool.groups]) group.producer.fail();
await observe('ordinary-after-producer-failure');
if (productPool.groups.size || productPeers.some(peer => !peer.isConnected())) throw Error('Ordinary recovery did not retire the pool');
}
}
if (background) {
phase = 'hidden'; await fetch('/visibility?hidden=1');
visibility.push({ at: performance.now(), requested: 'hidden', actual: document.visibilityState });
if (document.visibilityState !== 'hidden') throw Error('Page was not actually hidden');
await wait(10000); snapshots.push(await snapshot());
phase = 'visible-again'; await fetch('/visibility?hidden=0');
visibility.push({ at: performance.now(), requested: 'visible', actual: document.visibilityState });
if (document.visibilityState !== 'visible') throw Error('Page was not actually restored');
await wait(6000); snapshots.push(await snapshot());
}
if (network) {
await fetch('/shaper?rate=' + shapedRate);
phase = 'limited'; await wait(shortPulse ? 1000 : 14000); snapshots.push(await snapshot());
await fetch('/shaper?rate=0');
// --auto extends recovery observation; adaptation always belongs to the product.
phase = 'released'; await wait(automatic ? 40000 : 10000); snapshots.push(await snapshot());
if (displaySource) {
const healthy = sourceFrames.findLast(frame => frame.phase === 'healthy');
if (!healthy || sourceFrames.some(frame => ['limited', 'released'].includes(frame.phase) &&
(frame.width < healthy.width - 2 || frame.height < healthy.height - 2)))
throw Error('Sender adaptation reduced its display-capture input');
}
}
return { descriptions, snapshots, browser: navigator.userAgent, sourcePulses, sourceFrames,
audioMonitors: audioMonitors.map(({ role, pulses, attached }) => ({ role, pulses, attached })),
monitors: monitors.map(({ role, samples }) => ({ role, samples })) };
}
const configuration = { mode, codec, product: true, network, automatic, av, high, single, late, shortPulse,
background, nativeSource, displaySource, quietStart, lifecycle, relay, shapedRate, profile: selectedProfile() };
try {
window.probeResult = { ...configuration, ...await run(), controls, visibility, productTrace, errors };
if (browserDebugEnabled) {
const report = JSON.parse(await exportBrowserDebug());
window.probeResult.debug = report;
if (productPool && (!report.events.some(e => e.scope === 'encoding-pool' && e.event === 'sample') ||
!report.events.some(e => e.scope === 'webrtc' && e.event === 'stats' && e.details.role === 'pool-producer')))
throw Error('Debug report omitted the pool or producer evidence');
}
} catch (error) {
window.probeResult = { ...configuration, error: String(error?.stack || error?.message || error),
progress: window.probeProgress(), controls, visibility, productTrace, errors };
} finally {
clearInterval(drawing); clearInterval(productTraceTimer);
monitors.forEach(m => m.stop()); audioMonitors.forEach(m => m.stop());
productPeers.forEach(peer => peer.dispose()); productPool?.dispose();
upstreamPeer?.dispose();
await sourceReader?.cancel();
await sourceReading;
tracks.forEach(t => t.stop()); peers.forEach(pc => pc.close());
await audioContext?.close();
if (displayClones.some(track => track.readyState !== 'ended')) errors.push('Sender retirement left a live display-capture clone');
window.probeDone = true;
}