Embed STUN and SFU in the shared Go process, reuse native encoding pipelines with independent adaptation, and complete Client lifecycle, settings, diagnostics and packaging fixes. Retain standard Browser senders after measured encoded-reuse limitations.
222 lines
9.9 KiB
JavaScript
222 lines
9.9 KiB
JavaScript
// Isolated component experiment; no application transport, feedback or policy.
|
|
const profiles = [[480, 270, 312500], [960, 540, 1250000], [1920, 1080, 5000000]];
|
|
const fps = 30;
|
|
const trace = [[2, 2], [2, 0], [0, 0], [1, 1], [2, 2], [2, 0], [2, 2]];
|
|
const phaseFrames = 32;
|
|
const now = () => performance.now();
|
|
const assert = (condition, message) => { if (!condition) throw Error(message); };
|
|
const summarize = (values) => {
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
return { mean: values.reduce((a, b) => a + b, 0) / values.length, p95: sorted[Math.ceil(sorted.length * .95) - 1], max: sorted.at(-1) };
|
|
};
|
|
|
|
function makeInput() {
|
|
return Array.from({ length: 32 }, (_, frame) => {
|
|
const data = new Uint8Array(1920 * 1080 * 3 / 2).fill(128);
|
|
for (let y = 0; y < 1080; y++) for (let x = 0; x < 1920; x++) {
|
|
const u = (x + frame * 4) % 128;
|
|
const v = y % 128;
|
|
data[y * 1920 + x] = 32 + ((u >> 3) ^ (v >> 3)) * 8 + ((u ^ v) & 31) * 2;
|
|
}
|
|
return data;
|
|
});
|
|
}
|
|
|
|
function config(codec, layer) {
|
|
const [width, height, bitrate] = profiles[layer];
|
|
return { codec, width, height, bitrate, framerate: fps, latencyMode: "realtime",
|
|
bitrateMode: "constant", hardwareAcceleration: "prefer-software",
|
|
...(codec.startsWith("avc") ? { avc: { format: "annexb" } } : {}) };
|
|
}
|
|
|
|
function encoder(codec, layer) {
|
|
let result;
|
|
let failure;
|
|
const value = new VideoEncoder({ output: (chunk, metadata) => { result = { chunk, metadata }; }, error: (error) => { failure = error; } });
|
|
value.configure(config(codec, layer));
|
|
return {
|
|
value,
|
|
async encode(frame, keyFrame) {
|
|
result = undefined;
|
|
value.encode(frame, { keyFrame });
|
|
await value.flush();
|
|
if (failure) throw failure;
|
|
assert(result?.chunk.timestamp === frame.timestamp, "Missing/out-of-order encoded frame");
|
|
if (keyFrame) assert(result.chunk.type === "key", "Missing switch keyframe");
|
|
return result;
|
|
},
|
|
};
|
|
}
|
|
|
|
function decoder(codec) {
|
|
let complete;
|
|
let reject;
|
|
let layer = -1;
|
|
let failure;
|
|
const value = new VideoDecoder({
|
|
output: (frame) => { const accept = complete; complete = undefined; if (accept) accept(frame); else frame.close(); },
|
|
error: (error) => { failure = error; reject?.(error); },
|
|
});
|
|
return {
|
|
value,
|
|
async decode(packet, nextLayer) {
|
|
if (layer !== nextLayer) {
|
|
const [codedWidth, codedHeight] = profiles[nextLayer];
|
|
value.configure({ codec, codedWidth, codedHeight, optimizeForLatency: true, hardwareAcceleration: "prefer-software" });
|
|
layer = nextLayer;
|
|
}
|
|
if (failure) throw failure;
|
|
const promise = new Promise((resolve, rejectPromise) => { complete = resolve; reject = rejectPromise; });
|
|
value.decode(packet);
|
|
const frame = await promise;
|
|
assert(frame.displayWidth === profiles[nextLayer][0] && frame.displayHeight === profiles[nextLayer][1], "Wrong decoded dimensions");
|
|
return frame;
|
|
},
|
|
};
|
|
}
|
|
|
|
function rawFrame(input, frame) {
|
|
return new VideoFrame(input[frame % input.length], { format: "NV12", codedWidth: 1920, codedHeight: 1080,
|
|
timestamp: Math.round(frame * 1e6 / fps), duration: Math.round(1e6 / fps) });
|
|
}
|
|
|
|
async function prepareIngress(codec, input) {
|
|
const source = encoder(codec, 2);
|
|
const packets = [];
|
|
try {
|
|
for (let i = 0; i < input.length; i++) {
|
|
const frame = rawFrame(input, i);
|
|
try {
|
|
const { chunk } = await source.encode(frame, i === 0);
|
|
const data = new Uint8Array(chunk.byteLength);
|
|
chunk.copyTo(data);
|
|
packets.push({ data, type: chunk.type });
|
|
} finally { frame.close(); }
|
|
}
|
|
return packets;
|
|
} finally { source.value.close(); }
|
|
}
|
|
|
|
async function run(codec, policy, role, input, ingress) {
|
|
const encoders = [null, null, null];
|
|
const receivers = [decoder(codec), decoder(codec)];
|
|
const inputDecoder = role === "relay" ? decoder(codec) : null;
|
|
let previous = [-1, -1];
|
|
const encoded = [0, 0, 0];
|
|
const generatedBytes = [0, 0, 0];
|
|
const selectedBytes = [0, 0];
|
|
const decoded = [0, 0];
|
|
const switches = [];
|
|
const phases = [];
|
|
let creates = 0;
|
|
let ingressDecodes = 0;
|
|
const metrics = { management: [], input: [], encodeReady: [], allReceivers: [], processing: [] };
|
|
const start = now();
|
|
try {
|
|
for (let index = 0; index < trace.length * phaseFrames; index++) {
|
|
const phase = Math.floor(index / phaseFrames);
|
|
const demand = trace[phase];
|
|
const tick = now();
|
|
const demanded = new Set(demand);
|
|
const active = policy === "exact" ? demanded : new Set(Array.from({ length: Math.max(...demand) + 1 }, (_, i) => i));
|
|
// A relay already has H. It never re-encodes H or starts lower encoders just for H/H.
|
|
if (role === "relay") {
|
|
active.delete(2);
|
|
if (!demand.some((i) => i < 2)) active.clear();
|
|
}
|
|
const switched = demand.map((layer, child) => previous[child] !== layer);
|
|
const keyframes = new Set(demand.filter((_, child) => switched[child]));
|
|
for (let layer = 0; layer < 3; layer++) {
|
|
if (active.has(layer) && !encoders[layer]) { encoders[layer] = encoder(codec, layer); creates++; keyframes.add(layer); }
|
|
if (!active.has(layer) && encoders[layer]) { encoders[layer].value.close(); encoders[layer] = null; }
|
|
}
|
|
const afterManagement = now();
|
|
let sourceFrame;
|
|
const outputs = new Map();
|
|
const deliveries = [];
|
|
const deliver = async (layer, child, packetPromise) => {
|
|
const packet = await packetPromise;
|
|
assert(packet, "Missing demanded encoding");
|
|
if (switched[child]) assert(packet.type === "key", "Switch did not start at a recovery frame");
|
|
const frame = await receivers[child].decode(packet, layer);
|
|
assert(frame.timestamp === Math.round(index * 1e6 / fps), "Wrong decoded timestamp");
|
|
frame.close();
|
|
decoded[child]++;
|
|
selectedBytes[child] += packet.byteLength;
|
|
const delivered = now();
|
|
if (switched[child] && index > 0) switches.push({ index, child, layer, ms: delivered - tick, bytes: packet.byteLength });
|
|
return delivered;
|
|
};
|
|
if (role === "relay") {
|
|
const sourcePacket = ingress[index % ingress.length];
|
|
const packet = new EncodedVideoChunk({ ...sourcePacket, timestamp: Math.round(index * 1e6 / fps) });
|
|
outputs.set(2, Promise.resolve(packet));
|
|
demand.forEach((layer, child) => { if (layer === 2) deliveries[child] = deliver(layer, child, outputs.get(layer)); });
|
|
if (active.size) {
|
|
sourceFrame = await inputDecoder.decode(packet, 2);
|
|
ingressDecodes++;
|
|
}
|
|
// Demand phases align with input GOPs; real upstream PLI/network delay is not measured.
|
|
} else {
|
|
sourceFrame = rawFrame(input, index);
|
|
}
|
|
const afterInput = now();
|
|
try {
|
|
for (const layer of active) {
|
|
outputs.set(layer, encoders[layer].encode(sourceFrame, keyframes.has(layer)).then(({ chunk }) => {
|
|
encoded[layer]++;
|
|
generatedBytes[layer] += chunk.byteLength;
|
|
return chunk;
|
|
}));
|
|
}
|
|
demand.forEach((layer, child) => { deliveries[child] ??= deliver(layer, child, outputs.get(layer)); });
|
|
await Promise.all(outputs.values());
|
|
} finally { sourceFrame?.close(); }
|
|
const afterEncode = now();
|
|
const decodedAt = await Promise.all(deliveries);
|
|
const afterDecode = Math.max(...decodedAt);
|
|
const row = { management: afterManagement - tick, input: afterInput - afterManagement,
|
|
encodeReady: afterEncode - afterInput, allReceivers: afterDecode - tick, processing: now() - tick };
|
|
for (const key of Object.keys(metrics)) metrics[key].push(row[key]);
|
|
if (!phases[phase]) phases[phase] = { demand, frames: 0, work: [] };
|
|
phases[phase].frames++;
|
|
phases[phase].work.push(row.processing);
|
|
previous = demand;
|
|
const delay = start + (index + 1) * 1000 / fps - now();
|
|
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
}
|
|
const expected = role === "host" ? policy === "exact" ? [96, 32, 160] : [224, 192, 160]
|
|
: policy === "exact" ? [96, 32, 0] : [128, 96, 0];
|
|
assert(encoded.every((count, layer) => count === expected[layer]), "Unexpected encode count");
|
|
assert(decoded.every((count) => count === 224), "Missing receiver frames");
|
|
assert(ingressDecodes === (role === "relay" ? 128 : 0), "Repeated or unnecessary input decode");
|
|
return { codec, policy, role, encoded, decoded, ingressDecodes, creates, generatedBytes, selectedBytes, switches,
|
|
elapsedMs: now() - start, metrics: Object.fromEntries(Object.entries(metrics).map(([key, values]) => [key, summarize(values)])),
|
|
overFrameBudget: metrics.processing.filter((value) => value > 1000 / fps).length,
|
|
phases: phases.map(({ demand, frames, work }) => ({ demand, frames, work: summarize(work) })) };
|
|
} finally {
|
|
for (const slot of [...encoders, ...receivers, inputDecoder]) if (slot && slot.value.state !== "closed") slot.value.close();
|
|
}
|
|
}
|
|
|
|
self.onmessage = async () => {
|
|
try {
|
|
const input = makeInput();
|
|
const results = [];
|
|
const support = {};
|
|
for (const codec of ["vp8", "avc1.420028"]) {
|
|
support[codec] = (await VideoEncoder.isConfigSupported(config(codec, 2))).supported;
|
|
if (!support[codec]) continue;
|
|
const ingress = await prepareIngress(codec, input);
|
|
for (let round = 1; round <= 2; round++) {
|
|
for (const role of ["host", "relay"]) for (const policy of round === 1 ? ["exact", "envelope"] : ["envelope", "exact"]) {
|
|
self.postMessage({ progress: `${codec} ${round} ${role} ${policy}` });
|
|
results.push({ round, ...await run(codec, policy, role, input, ingress) });
|
|
}
|
|
}
|
|
}
|
|
self.postMessage({ completed: true, scope: "WebCodecs software preference; independent child decode; no RTP, network, hardware or perceptual acceptance",
|
|
userAgent: navigator.userAgent, support, profiles, fps, phaseFrames, trace, results });
|
|
} catch (error) { self.postMessage({ error: String(error.stack ?? error) }); }
|
|
};
|