feat(media): share Browser encoding and complete local diagnostics
* fix: allow Linux interface discovery in embedded media service * feat: expand local diagnostic reports * research: validate independent Browser encoding producers * feat: prototype Browser pool ownership and record remaining media gates * feat: refine Browser shared encoders and native output reuse * Refine carrier costs and simplify release entry points * Close media phase and prepare piik design handoff
This commit is contained in:
1 parent
cfc323adce
commit
c9a6642ac1
79 files changed
+3955
-1176
No files matched your search
@@ -0,0 +1,375 @@
|
||||
// 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 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 = [];
|
||||
const sourceTimes = new Map(), seenProductGroups = new Map();
|
||||
let rawSource, drawing, productTraceTimer, audioContext, audioTrack, audioGain, upstreamPeer;
|
||||
let producing = true, 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) {
|
||||
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);
|
||||
rawSource = 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);
|
||||
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 ? 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()];
|
||||
phase = 'healthy'; await wait(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 || 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(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());
|
||||
}
|
||||
return { descriptions, snapshots, browser: navigator.userAgent, sourcePulses,
|
||||
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,
|
||||
background, nativeSource, 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();
|
||||
tracks.forEach(t => t.stop()); peers.forEach(pc => pc.close());
|
||||
await audioContext?.close();
|
||||
window.probeDone = true;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { build } from 'esbuild';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { CdpConnection, cleanupRun, createPage, evaluate, launchChrome, reservePort, waitForSample, waitForVersion } from './browser-gate-harness';
|
||||
import { createPoolShaper } from './browser-pool-shaper.mjs';
|
||||
|
||||
const mode = process.argv[2] || 'carrier', args = process.argv.slice(3);
|
||||
const flags = new Set(['--product', '--1080', '--h264', '--single', '--av', '--late', '--background',
|
||||
'--native-source', '--lifecycle', '--network', '--auto', '--relay', '--debug']);
|
||||
if (!['ordinary', 'carrier'].includes(mode)) throw Error('Expected ordinary|carrier');
|
||||
for (const arg of args) if (!flags.has(arg) && !arg.startsWith('--rate=') && !arg.startsWith('--preference='))
|
||||
throw Error('Unsupported option: ' + arg);
|
||||
const codec = args.includes('--h264') ? 'H264' : 'VP8', network = args.includes('--network');
|
||||
const automatic = args.includes('--auto'), high = args.includes('--1080'), av = args.includes('--av');
|
||||
const single = args.includes('--single'), late = args.includes('--late'), background = args.includes('--background');
|
||||
const nativeSource = background || args.includes('--native-source'), lifecycle = args.includes('--lifecycle');
|
||||
const relay = args.includes('--relay');
|
||||
const rate = Number(args.find(arg => arg.startsWith('--rate='))?.split('=')[1] || 120000);
|
||||
const preference = args.find(arg => arg.startsWith('--preference='))?.split('=')[1] || (high ? 'maintain-resolution' : 'balanced');
|
||||
if (!['balanced', 'maintain-resolution', 'maintain-framerate'].includes(preference)) throw Error('Unsupported degradation preference');
|
||||
if (!Number.isInteger(rate) || rate <= 0) throw Error('Rate must be a positive integer in bps');
|
||||
if (lifecycle && (nativeSource || network || relay)) throw Error('Lifecycle uses direct canvas capture without network shaping');
|
||||
if (single && late) throw Error('Single-viewer control cannot also late-join B');
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
const bundle = await build({ entryPoints: [join(root, 'scripts/browser-local-pool-page.js')],
|
||||
bundle: true, write: false, format: 'esm', platform: 'browser', logLevel: 'error' });
|
||||
const body = Buffer.from(bundle.outputFiles[0].contents);
|
||||
let cdp: CdpConnection | null = null;
|
||||
let page: Awaited<ReturnType<typeof createPage>> | undefined;
|
||||
let originalTarget: string | undefined, backgroundTarget: string | undefined;
|
||||
const shaper = network ? await createPoolShaper() : null;
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url!, 'http://127.0.0.1');
|
||||
if (url.pathname === '/visibility' && cdp && page) {
|
||||
void (async () => {
|
||||
originalTarget ??= (await cdp!.call<{ targetInfo: { targetId: string } }>(
|
||||
'Target.getTargetInfo', {}, page!.sessionId, Date.now() + 3000)).targetInfo.targetId;
|
||||
backgroundTarget ??= (await cdp!.call<{ targetId: string }>(
|
||||
'Target.createTarget', { url: 'about:blank', background: true }, undefined, Date.now() + 3000)).targetId;
|
||||
await cdp!.call('Target.activateTarget', { targetId: url.searchParams.get('hidden') === '1' ? backgroundTarget : originalTarget },
|
||||
undefined, Date.now() + 3000);
|
||||
response.end('{}');
|
||||
})().catch(() => { response.statusCode = 500; response.end('{}'); });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/process' && cdp) {
|
||||
void cdp.call<{ processInfo: Array<{ cpuTime: number }> }>('SystemInfo.getProcessInfo', {}, undefined, Date.now() + 3000)
|
||||
.then(info => response.end(JSON.stringify({ cpuSeconds: info.processInfo.reduce((sum, p) => sum + p.cpuTime, 0) })),
|
||||
() => response.end('{}'));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/shaper' && shaper) {
|
||||
if (url.searchParams.has('left')) shaper.setTargets(Number(url.searchParams.get('left')), Number(url.searchParams.get('right')));
|
||||
if (url.searchParams.has('rate')) shaper.setRate(Number(url.searchParams.get('rate')));
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify({ ports: shaper.ports, stats: shaper.stats() }));
|
||||
return;
|
||||
}
|
||||
response.setHeader('Content-Type', url.pathname === '/probe.js' ? 'text/javascript' : 'text/html');
|
||||
response.end(url.pathname === '/probe.js' ? body :
|
||||
'<!doctype html><meta charset=utf-8><title>Browser local pool</title><script type=module src=/probe.js></script>');
|
||||
});
|
||||
await new Promise<void>(r => server.listen(0, '127.0.0.1', r));
|
||||
const port = (server.address() as { port: number }).port, debugPort = await reservePort();
|
||||
const profile = await mkdtemp(join(tmpdir(), 'screener-client-media-'));
|
||||
const chrome = launchChrome(process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe', debugPort, profile,
|
||||
['--headless=new', '--no-first-run', '--autoplay-policy=no-user-gesture-required',
|
||||
...(background ? [] : ['--disable-background-timer-throttling', '--disable-renderer-backgrounding']),
|
||||
...(nativeSource ? ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'] : [])]);
|
||||
chrome.stdout.resume(); chrome.stderr.resume();
|
||||
let result: Record<string, unknown> = { mode, codec, product: true, network, automatic, high, av, single, late,
|
||||
background, nativeSource, lifecycle, relay, shapedRate: rate, preference };
|
||||
try {
|
||||
const version = await waitForVersion(debugPort, chrome);
|
||||
cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 5000);
|
||||
page = await createPage(cdp, 'about:blank');
|
||||
const query = new URLSearchParams({ mode, codec, rate: String(rate), preference });
|
||||
if (process.argv.includes('--debug')) query.set('debug', '1');
|
||||
for (const [key, value] of Object.entries({ network, automatic, high, av, single, late, background, nativeSource, lifecycle, relay }))
|
||||
query.set(key, value ? '1' : '0');
|
||||
await cdp.call('Page.navigate', { url: 'http://127.0.0.1:' + port + '/?' + query }, page.sessionId, Date.now() + 5000);
|
||||
await waitForSample(() => evaluate<boolean>(cdp!, page!, 'window.probeDone===true', Date.now() + 2000),
|
||||
Boolean, automatic || lifecycle ? 110000 : network || background ? 60000 : 25000);
|
||||
result = await evaluate<Record<string, unknown>>(cdp, page, 'window.probeResult', Date.now() + 3000);
|
||||
} catch (error) {
|
||||
result.error = String(error);
|
||||
if (cdp && page) result.partial = await evaluate(cdp, page, 'window.probeResult || window.probeProgress?.()', Date.now() + 3000).catch(() => null);
|
||||
} finally {
|
||||
const clean = await cleanupRun({ cdp, native: null, chrome,
|
||||
server: { close: () => new Promise<void>((resolve, reject) => server.close(e => e ? reject(e) : resolve())) },
|
||||
profile, ports: [port, debugPort] });
|
||||
if (shaper) { result.shaper = shaper.stats(); await shaper.close(); }
|
||||
result.probeSha256 = createHash('sha256').update(body).digest('hex');
|
||||
result.cleanup = clean;
|
||||
await mkdir(join(root, 'build/browser-local-pool'), { recursive: true });
|
||||
const name = [mode, codec.toLowerCase(), network && 'network', automatic && 'auto', high && '1080',
|
||||
single && 'single', late && 'late', background && 'background', lifecycle && 'lifecycle', relay && 'relay', Date.now()].filter(Boolean).join('-');
|
||||
const output = join(root, 'build/browser-local-pool', name + '.json');
|
||||
await writeFile(output, JSON.stringify(result, null, 2) + '\n');
|
||||
console.log(JSON.stringify({ mode, codec, network, error: result.error ?? null, output, cleanup: clean }));
|
||||
if (result.error || (result.errors as string[] | undefined)?.length || Object.values(clean).some(v => !v)) process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
for (const filename of process.argv.slice(2)) {
|
||||
const result = JSON.parse(await readFile(filename, 'utf8'));
|
||||
const completed = Array.isArray(result.snapshots) && !result.error && !result.errors?.length &&
|
||||
result.cleanup && Object.values(result.cleanup).every(Boolean);
|
||||
const configuration = { file: filename, probeSha256: result.probeSha256, mode: result.mode, codec: result.codec,
|
||||
product: result.product, high: result.high, single: result.single, late: result.late, network: result.network,
|
||||
automatic: result.automatic, av: result.av, background: result.background, nativeSource: result.nativeSource,
|
||||
lifecycle: result.lifecycle, relay: result.relay, shapedRate: result.shapedRate, profile: result.profile,
|
||||
completed: !!completed, error: result.error, errors: result.errors, cleanup: result.cleanup };
|
||||
if (!completed) process.exitCode = 1;
|
||||
if (!result.snapshots) {
|
||||
console.log(JSON.stringify({ ...configuration, progress: result.progress, partial: result.partial }));
|
||||
continue;
|
||||
}
|
||||
const phases = [];
|
||||
for (let i = 1; i < result.snapshots.length; i++) {
|
||||
const prev = result.snapshots[i - 1], next = result.snapshots[i], seconds = (next.at - prev.at) / 1000;
|
||||
const row = { phase: next.phase, seconds, browserCpuSeconds: next.process && prev.process ? next.process.cpuSeconds - prev.process.cpuSeconds : null, edges: {}, viewers: {} };
|
||||
for (const role of Object.keys(next.stats)) {
|
||||
const before = prev.stats[role], after = next.stats[role];
|
||||
const outbound = s => s?.send.find(r => r.type === 'outbound-rtp' && r.kind === 'video') || {};
|
||||
const inbound = s => s?.receive.find(r => r.type === 'inbound-rtp' && r.kind === 'video') || {};
|
||||
const a = outbound(before), b = outbound(after), c = inbound(before), d = inbound(after);
|
||||
row.edges[role] = { encoded: (b.framesEncoded || 0) - (a.framesEncoded || 0), encodeMs: 1000 * ((b.totalEncodeTime || 0) - (a.totalEncodeTime || 0)),
|
||||
decoded: (d.framesDecoded || 0) - (c.framesDecoded || 0), decodeMs: 1000 * ((d.totalDecodeTime || 0) - (c.totalDecodeTime || 0)),
|
||||
sentFrames: (b.framesSent || 0) - (a.framesSent || 0), kbps: 8 * ((b.bytesSent || 0) - (a.bytesSent || 0)) / seconds / 1000,
|
||||
output: [b.frameWidth, b.frameHeight], receive: [d.frameWidth, d.frameHeight], reason: b.qualityLimitationReason, target: b.targetBitrate,
|
||||
retired: after.retired ?? false, details: after.details,
|
||||
meanQp: b.qpSum !== undefined && b.framesEncoded > (a.framesEncoded || 0) ? (b.qpSum - (a.qpSum || 0)) / (b.framesEncoded - (a.framesEncoded || 0)) : null,
|
||||
senderReports: after.receive.filter(r => r.type === 'remote-outbound-rtp').reduce((s, r) => s + (r.reportsSent || 0), 0) - (before?.receive || []).filter(r => r.type === 'remote-outbound-rtp').reduce((s, r) => s + (r.reportsSent || 0), 0), pli: (d.pliCount || 0) - (c.pliCount || 0) };
|
||||
}
|
||||
for (const monitor of result.monitors) {
|
||||
const samples = monitor.samples.filter(s => s.phase === next.phase), ages = samples.filter(s => s.age !== null).map(s => s.age).sort((a, b) => a - b);
|
||||
let backwards = 0, duplicates = 0, maxGapMs = samples[0] ? samples[0].at - prev.at : seconds * 1000;
|
||||
for (let j = 1; j < samples.length; j++) {
|
||||
maxGapMs = Math.max(maxGapMs, samples[j].at - samples[j - 1].at);
|
||||
if (samples[j].id < samples[j - 1].id)
|
||||
backwards++;
|
||||
if (samples[j].id === samples[j - 1].id)
|
||||
duplicates++;
|
||||
}
|
||||
if (samples.length) maxGapMs = Math.max(maxGapMs, next.at - samples.at(-1).at);
|
||||
row.viewers[monitor.role] = { rendered: samples.length, fresh: result.nativeSource ? null : samples.filter(s => s.age !== null && s.age < 1000).length, backwards, duplicates, maxGapMs,
|
||||
ageP50: ages[Math.floor(ages.length * .5)] ?? null, ageP95: ages[Math.floor(ages.length * .95)] ?? null };
|
||||
if (result.av) {
|
||||
const sound = result.audioMonitors.find(m => m.role === monitor.role);
|
||||
const offsets = [];
|
||||
let missed = 0;
|
||||
for (const pulse of result.sourcePulses.filter(p => p.at >= prev.at && p.at < next.at - 500)) {
|
||||
const video = samples.find(s => s.id >= pulse.id && s.id < pulse.id + 6);
|
||||
const audio = sound?.pulses.find(p => p.at >= pulse.at && p.at < pulse.at + 1500);
|
||||
if (video && audio) offsets.push(video.at - audio.at);
|
||||
else missed++;
|
||||
}
|
||||
row.viewers[monitor.role].av = { attached: sound?.attached ?? false, videoMinusAudioMs: offsets, missed };
|
||||
}
|
||||
}
|
||||
phases.push(row);
|
||||
}
|
||||
console.log(JSON.stringify({ ...configuration, phases, controls: result.controls, visibility: result.visibility,
|
||||
shaper: result.shaper ?? null }));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createSocket } from 'node:dgram';
|
||||
// Test-only UDP bridge. Both ICE descriptions point at it, so the recorded
|
||||
// packet counters prove the tested media path crossed the shaping boundary.
|
||||
export async function createPoolShaper() {
|
||||
const left = createSocket('udp4'), right = createSocket('udp4');
|
||||
await Promise.all([left, right].map(s => new Promise(r => s.bind(0, '127.0.0.1', r))));
|
||||
let targetLeft = 0, targetRight = 0, rate = 0, next = 0, closed = false, timer = null;
|
||||
const queue = [];
|
||||
const counters = { forwardedBytes: 0, droppedBytes: 0, packets: 0, reverseBytes: 0, maxQueueBytes: 0 };
|
||||
let queuedBytes = 0;
|
||||
function flush() {
|
||||
timer = null;
|
||||
if (closed)
|
||||
return;
|
||||
const now = performance.now();
|
||||
while (queue.length && queue[0].at <= now) {
|
||||
const packet = queue.shift();
|
||||
queuedBytes -= packet.data.length;
|
||||
right.send(packet.data, targetRight, '127.0.0.1');
|
||||
counters.forwardedBytes += packet.data.length;
|
||||
counters.packets++;
|
||||
}
|
||||
if (queue.length)
|
||||
timer = setTimeout(flush, Math.max(1, queue[0].at - performance.now()));
|
||||
}
|
||||
left.on('message', data => {
|
||||
if (!targetRight || closed)
|
||||
return;
|
||||
if (!rate) {
|
||||
right.send(data, targetRight, '127.0.0.1');
|
||||
counters.forwardedBytes += data.length;
|
||||
counters.packets++;
|
||||
return;
|
||||
}
|
||||
const now = performance.now();
|
||||
if (next - now > 250) {
|
||||
counters.droppedBytes += data.length;
|
||||
return;
|
||||
}
|
||||
next = Math.max(now, next) + data.length * 8 * 1000 / rate;
|
||||
queue.push({ at: next, data });
|
||||
queuedBytes += data.length;
|
||||
counters.maxQueueBytes = Math.max(counters.maxQueueBytes, queuedBytes);
|
||||
if (!timer)
|
||||
flush();
|
||||
});
|
||||
right.on('message', data => { if (targetLeft && !closed) {
|
||||
left.send(data, targetLeft, '127.0.0.1');
|
||||
counters.reverseBytes += data.length;
|
||||
} });
|
||||
return {
|
||||
ports: { left: left.address().port, right: right.address().port },
|
||||
setTargets(a, b) { targetLeft = a; targetRight = b; },
|
||||
setRate(value) { rate = value; if (!rate) {
|
||||
next = 0;
|
||||
for (const item of queue)
|
||||
item.at = 0;
|
||||
if (timer)
|
||||
clearTimeout(timer);
|
||||
flush();
|
||||
} },
|
||||
stats() { return { ...counters, queuedBytes, rate }; },
|
||||
async close() { closed = true; if (timer)
|
||||
clearTimeout(timer); queue.length = 0; await Promise.all([left, right].map(s => new Promise(r => s.close(r)))); }
|
||||
};
|
||||
}
|
||||
@@ -766,7 +766,7 @@ async function main(): Promise<void> {
|
||||
stage = "host-page";
|
||||
const host = await createPage(
|
||||
cdp,
|
||||
"http://localhost:" + appPort + "/#" + hostBootstrap,
|
||||
"http://localhost:" + appPort + "/?debug=1#" + hostBootstrap,
|
||||
CODEC_PROBE,
|
||||
true,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user