Improve frame sync telemetry
This commit is contained in:
170
public/app.js
170
public/app.js
@@ -36,6 +36,8 @@ const elements = {
|
||||
|
||||
const context = elements.canvas.getContext('2d', { alpha: false, desynchronized: true });
|
||||
const FRAME_LATE_GRACE_SECONDS = 0.25;
|
||||
const FRAME_CLOCK_SEND_INTERVAL_MS = 250;
|
||||
const CLIENT_TELEMETRY_INTERVAL_MS = 5000;
|
||||
const MAX_PENDING_FRAME_QUEUE_SECONDS = 2;
|
||||
const MAX_DECODED_FRAME_QUEUE_SECONDS = 3;
|
||||
const MIN_PENDING_FRAME_QUEUE = 12;
|
||||
@@ -73,8 +75,11 @@ const state = {
|
||||
frameWatchdogTimer: 0,
|
||||
lastFramePacketAt: 0,
|
||||
lastFramePaintedAt: 0,
|
||||
lastFrameClockSentAt: 0,
|
||||
lastClientTelemetrySentAt: 0,
|
||||
lastPlaybackRestartAt: 0,
|
||||
lastRenderErrorAt: 0,
|
||||
clientTelemetry: createClientTelemetry(),
|
||||
};
|
||||
|
||||
void loadEntryLists();
|
||||
@@ -259,6 +264,7 @@ elements.audio.addEventListener('pause', () => {
|
||||
});
|
||||
|
||||
elements.audio.addEventListener('playing', () => {
|
||||
noteClientTelemetry('audioPlaying');
|
||||
elements.loader.hidden = state.frameCount > 0;
|
||||
clearPlayerMessage();
|
||||
scheduleControlsHide();
|
||||
@@ -266,6 +272,7 @@ elements.audio.addEventListener('playing', () => {
|
||||
|
||||
elements.audio.addEventListener('waiting', () => {
|
||||
if (state.session) {
|
||||
noteClientTelemetry('audioWaiting');
|
||||
elements.loader.hidden = false;
|
||||
}
|
||||
});
|
||||
@@ -275,12 +282,15 @@ elements.audio.addEventListener('timeupdate', () => {
|
||||
});
|
||||
|
||||
elements.audio.addEventListener('ended', () => {
|
||||
noteClientTelemetry('audioEnded');
|
||||
syncPlayhead();
|
||||
setControlsVisible(true);
|
||||
});
|
||||
|
||||
elements.audio.addEventListener('error', () => {
|
||||
if (state.session) {
|
||||
noteClientTelemetry('audioErrors');
|
||||
sendClientTelemetry({ force: true, reason: 'audio_error' });
|
||||
showPlayerMessage('Audio failed');
|
||||
setControlsVisible(true);
|
||||
}
|
||||
@@ -335,6 +345,7 @@ function connectPlaybackStreams() {
|
||||
state.streamGeneration = streamGeneration;
|
||||
state.lastPlaybackRestartAt = now;
|
||||
state.lastFramePacketAt = now;
|
||||
state.lastFrameClockSentAt = 0;
|
||||
|
||||
if (state.websocket) {
|
||||
state.websocket.close(1000, 'client reconnecting');
|
||||
@@ -446,6 +457,8 @@ function restartPlaybackStreams(reason = 'frame_stalled') {
|
||||
}
|
||||
|
||||
console.warn(`Restarting playback streams: ${reason}`);
|
||||
noteClientTelemetry('playbackRestarts');
|
||||
sendClientTelemetry({ force: true, reason });
|
||||
|
||||
if (state.seekable && Number.isFinite(state.duration) && state.duration > 0) {
|
||||
state.lastPlaybackRestartAt = Date.now();
|
||||
@@ -491,6 +504,12 @@ function handleFramePacket(packet, streamGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLateFrame(timestamp)) {
|
||||
noteLateFrameTelemetry('lateFramePackets', timestamp);
|
||||
state.lastFramePacketAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
state.lastFramePacketAt = Date.now();
|
||||
state.pendingFrames.push({ timestamp, jpeg: new Uint8Array(packet, 8), streamGeneration });
|
||||
trimPendingFrameQueue();
|
||||
@@ -521,6 +540,70 @@ function handleControlMessage(rawMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
function sendFrameClock() {
|
||||
if (!state.session || !state.websocket || state.websocket.readyState !== WebSocket.OPEN || elements.audio.readyState === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (now - state.lastFrameClockSentAt < FRAME_CLOCK_SEND_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = Number.isFinite(elements.audio.currentTime) ? elements.audio.currentTime : 0;
|
||||
|
||||
state.lastFrameClockSentAt = now;
|
||||
|
||||
try {
|
||||
state.websocket.send(JSON.stringify({ type: 'clock', currentTime }));
|
||||
} catch {
|
||||
state.lastFrameClockSentAt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function sendClientTelemetry({ force = false, reason = 'periodic' } = {}) {
|
||||
if (!state.websocket || state.websocket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (!force && now - state.lastClientTelemetrySentAt < CLIENT_TELEMETRY_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.lastClientTelemetrySentAt = now;
|
||||
|
||||
const currentTime = Number.isFinite(elements.audio.currentTime) ? elements.audio.currentTime : 0;
|
||||
const telemetry = state.clientTelemetry;
|
||||
const payload = {
|
||||
type: 'telemetry',
|
||||
reason,
|
||||
currentTime,
|
||||
paused: elements.audio.paused,
|
||||
readyState: elements.audio.readyState,
|
||||
pendingFrames: state.pendingFrames.length,
|
||||
decodedFrames: state.frames.length,
|
||||
paintedFrames: state.frameCount,
|
||||
lastFramePacketAgeMs: state.lastFramePacketAt > 0 ? now - state.lastFramePacketAt : null,
|
||||
lastFramePaintAgeMs: state.lastFramePaintedAt > 0 ? now - state.lastFramePaintedAt : null,
|
||||
hidden: document.hidden,
|
||||
online: navigator.onLine,
|
||||
counters: telemetry.counters,
|
||||
max: telemetry.max,
|
||||
};
|
||||
|
||||
state.clientTelemetry = createClientTelemetry();
|
||||
|
||||
try {
|
||||
state.websocket.send(JSON.stringify(payload));
|
||||
} catch {
|
||||
state.clientTelemetry = mergeClientTelemetry(state.clientTelemetry, telemetry);
|
||||
state.lastClientTelemetrySentAt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function startRenderLoop() {
|
||||
if (state.raf) {
|
||||
return;
|
||||
@@ -528,6 +611,8 @@ function startRenderLoop() {
|
||||
|
||||
const render = () => {
|
||||
try {
|
||||
sendFrameClock();
|
||||
sendClientTelemetry();
|
||||
drawReadyFrames();
|
||||
syncPlayhead();
|
||||
} catch (error) {
|
||||
@@ -549,7 +634,7 @@ function drawReadyFrames() {
|
||||
return;
|
||||
}
|
||||
|
||||
dropLateDecodedFrames({ keepNewest: true });
|
||||
dropLateDecodedFrames();
|
||||
|
||||
const frameLeadSeconds = 1 / Math.max(1, state.session.options.fps);
|
||||
const targetTime = elements.audio.currentTime + frameLeadSeconds;
|
||||
@@ -559,6 +644,7 @@ function drawReadyFrames() {
|
||||
const frame = state.frames.shift();
|
||||
|
||||
if (frameToDraw) {
|
||||
noteClientTelemetry('readyFramesSkippedForNewer');
|
||||
releaseImage(frameToDraw.bitmap);
|
||||
}
|
||||
|
||||
@@ -575,6 +661,8 @@ function drawReadyFrames() {
|
||||
|
||||
state.currentBitmap = frameToDraw.bitmap;
|
||||
drawBitmap(frameToDraw.bitmap);
|
||||
noteClientTelemetry('framesPainted');
|
||||
noteFrameLagTelemetry('paintLagMs', frameToDraw.timestamp);
|
||||
state.lastFramePaintedAt = Date.now();
|
||||
elements.loader.hidden = true;
|
||||
clearPlayerMessage();
|
||||
@@ -601,7 +689,7 @@ async function pumpFrameDecodeQueue() {
|
||||
|
||||
try {
|
||||
while (state.pendingFrames.length > 0) {
|
||||
dropLatePendingFrames({ keepNewest: true });
|
||||
dropLatePendingFrames();
|
||||
|
||||
const frame = state.pendingFrames.shift();
|
||||
|
||||
@@ -610,10 +698,12 @@ async function pumpFrameDecodeQueue() {
|
||||
}
|
||||
|
||||
if (frame.streamGeneration !== state.streamGeneration) {
|
||||
noteClientTelemetry('staleGenerationPendingFrames');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isLateFrame(frame.timestamp) && state.pendingFrames.length > 0) {
|
||||
if (isLateFrame(frame.timestamp)) {
|
||||
noteLateFrameTelemetry('latePendingFramesBeforeDecode', frame.timestamp);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -621,16 +711,19 @@ async function pumpFrameDecodeQueue() {
|
||||
|
||||
try {
|
||||
bitmap = await decodeImageWithTimeout(new Blob([frame.jpeg], { type: 'image/jpeg' }));
|
||||
} catch {
|
||||
} catch (error) {
|
||||
noteClientTelemetry(error.message === 'Image decode timed out.' ? 'imageDecodeTimeouts' : 'imageDecodeFailures');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frame.streamGeneration !== state.streamGeneration) {
|
||||
noteClientTelemetry('staleGenerationDecodedFrames');
|
||||
releaseImage(bitmap);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isLateFrame(frame.timestamp) && (state.pendingFrames.length > 0 || state.frames.length > 0)) {
|
||||
if (isLateFrame(frame.timestamp)) {
|
||||
noteLateFrameTelemetry('latePendingFramesAfterDecode', frame.timestamp);
|
||||
releaseImage(bitmap);
|
||||
continue;
|
||||
}
|
||||
@@ -651,18 +744,20 @@ async function pumpFrameDecodeQueue() {
|
||||
}
|
||||
|
||||
function trimPendingFrameQueue() {
|
||||
dropLatePendingFrames({ keepNewest: true });
|
||||
dropLatePendingFrames();
|
||||
|
||||
const maxQueuedFrames = getFrameQueueLimit(MAX_PENDING_FRAME_QUEUE_SECONDS, MIN_PENDING_FRAME_QUEUE);
|
||||
const overflow = state.pendingFrames.length - maxQueuedFrames;
|
||||
|
||||
if (overflow > 0) {
|
||||
noteClientTelemetry('pendingQueueOverflowFrames', overflow);
|
||||
noteClientTelemetryMax('pendingQueuePeakFrames', state.pendingFrames.length);
|
||||
state.pendingFrames.splice(0, overflow);
|
||||
}
|
||||
}
|
||||
|
||||
function trimFrameQueue() {
|
||||
dropLateDecodedFrames({ keepNewest: true });
|
||||
dropLateDecodedFrames();
|
||||
|
||||
const maxQueuedFrames = getFrameQueueLimit(MAX_DECODED_FRAME_QUEUE_SECONDS, MIN_DECODED_FRAME_QUEUE);
|
||||
const overflow = state.frames.length - maxQueuedFrames;
|
||||
@@ -672,6 +767,8 @@ function trimFrameQueue() {
|
||||
}
|
||||
|
||||
const removed = state.frames.splice(0, overflow);
|
||||
noteClientTelemetry('decodedQueueOverflowFrames', overflow);
|
||||
noteClientTelemetryMax('decodedQueuePeakFrames', state.frames.length + overflow);
|
||||
|
||||
for (const frame of removed) {
|
||||
releaseImage(frame.bitmap);
|
||||
@@ -687,6 +784,8 @@ function dropLatePendingFrames({ keepNewest = false } = {}) {
|
||||
}
|
||||
|
||||
if (removeCount > 0) {
|
||||
noteClientTelemetry('latePendingFrames', removeCount);
|
||||
noteFrameLagTelemetry('latePendingFramesMaxLagMs', state.pendingFrames[0]?.timestamp);
|
||||
state.pendingFrames.splice(0, removeCount);
|
||||
}
|
||||
}
|
||||
@@ -704,6 +803,8 @@ function dropLateDecodedFrames({ keepNewest = false } = {}) {
|
||||
}
|
||||
|
||||
const removed = state.frames.splice(0, removeCount);
|
||||
noteClientTelemetry('lateDecodedFrames', removeCount);
|
||||
noteFrameLagTelemetry('lateDecodedFramesMaxLagMs', removed[0]?.timestamp);
|
||||
|
||||
for (const frame of removed) {
|
||||
releaseImage(frame.bitmap);
|
||||
@@ -724,7 +825,57 @@ function isLateFrame(timestamp) {
|
||||
return timestamp < currentTime - FRAME_LATE_GRACE_SECONDS;
|
||||
}
|
||||
|
||||
function noteLateFrameTelemetry(name, timestamp, count = 1) {
|
||||
noteClientTelemetry(name, count);
|
||||
noteFrameLagTelemetry(`${name}MaxLagMs`, timestamp);
|
||||
}
|
||||
|
||||
function noteFrameLagTelemetry(name, timestamp) {
|
||||
if (!Number.isFinite(timestamp) || elements.audio.readyState === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = Number.isFinite(elements.audio.currentTime) ? elements.audio.currentTime : 0;
|
||||
noteClientTelemetryMax(name, Math.max(0, currentTime - timestamp) * 1000);
|
||||
}
|
||||
|
||||
function createClientTelemetry() {
|
||||
return {
|
||||
counters: {},
|
||||
max: {},
|
||||
};
|
||||
}
|
||||
|
||||
function noteClientTelemetry(name, count = 1) {
|
||||
if (!state.clientTelemetry || !Number.isFinite(count) || count <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.clientTelemetry.counters[name] = (state.clientTelemetry.counters[name] ?? 0) + count;
|
||||
}
|
||||
|
||||
function noteClientTelemetryMax(name, value) {
|
||||
if (!state.clientTelemetry || !Number.isFinite(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.clientTelemetry.max[name] = Math.max(state.clientTelemetry.max[name] ?? 0, Math.round(value));
|
||||
}
|
||||
|
||||
function mergeClientTelemetry(target, source) {
|
||||
for (const [name, count] of Object.entries(source.counters)) {
|
||||
target.counters[name] = (target.counters[name] ?? 0) + count;
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(source.max)) {
|
||||
target.max[name] = Math.max(target.max[name] ?? 0, value);
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
function stopSession({ showEntry: shouldShowEntry = true } = {}) {
|
||||
sendClientTelemetry({ force: true, reason: 'stop_session' });
|
||||
state.generation += 1;
|
||||
state.streamGeneration += 1;
|
||||
state.session = null;
|
||||
@@ -736,6 +887,9 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) {
|
||||
state.seekPreviewTime = 0;
|
||||
state.lastFramePacketAt = 0;
|
||||
state.lastFramePaintedAt = 0;
|
||||
state.lastFrameClockSentAt = 0;
|
||||
state.lastClientTelemetrySentAt = 0;
|
||||
state.clientTelemetry = createClientTelemetry();
|
||||
clearHideControlsTimer();
|
||||
clearFrameWatchdog();
|
||||
|
||||
@@ -808,7 +962,7 @@ async function decodeImageWithTimeout(blob) {
|
||||
window.clearTimeout(timeoutId);
|
||||
|
||||
if (!image) {
|
||||
throw new Error('Image decode failed.');
|
||||
throw new Error(timedOut ? 'Image decode timed out.' : 'Image decode failed.');
|
||||
}
|
||||
|
||||
return image;
|
||||
|
||||
268
server/index.js
268
server/index.js
@@ -34,6 +34,8 @@ const FAVORITES_PATH = process.env.FAVORITES_PATH ?? path.join(__dirname, '..',
|
||||
const FAVORITES_LIMIT = clampInteger(process.env.FAVORITES_LIMIT, 50, 1, 200);
|
||||
const SESSION_TTL_MS = 60 * 60 * 1000;
|
||||
const PLAYBACK_READY_TIMEOUT_MS = 15 * 1000;
|
||||
const CLIENT_CLOCK_FRAME_LATE_GRACE_SECONDS = 0.25;
|
||||
const FRAME_CONDITION_LOG_INTERVAL_MS = 5000;
|
||||
const MAX_WS_BUFFER_BYTES = clampInteger(process.env.MAX_WS_BUFFER_BYTES, 2 * 1024 * 1024, 128 * 1024, 64 * 1024 * 1024);
|
||||
const MAX_AUDIO_QUEUE_BYTES = clampInteger(process.env.MAX_AUDIO_QUEUE_BYTES, 4 * 1024 * 1024, 256 * 1024, 128 * 1024 * 1024);
|
||||
const MAX_RELAY_BRANCH_QUEUE_BYTES = clampInteger(process.env.MAX_RELAY_BRANCH_QUEUE_BYTES, 8 * 1024 * 1024, 512 * 1024, 256 * 1024 * 1024);
|
||||
@@ -818,6 +820,8 @@ function streamSplitFrames(websocket, session) {
|
||||
let skippedFrames = 0;
|
||||
let stopReason = 'process_exit';
|
||||
let serverClosingWebSocket = false;
|
||||
let clientAudioTime = null;
|
||||
const frameConditions = createFrameConditionLogger(label);
|
||||
const frameSender = createLatestFrameSender(websocket, {
|
||||
onError: () => {
|
||||
stopReason = 'websocket_send_error';
|
||||
@@ -826,6 +830,9 @@ function streamSplitFrames(websocket, session) {
|
||||
onSkip: () => {
|
||||
skippedFrames += 1;
|
||||
},
|
||||
onQueue: (event) => {
|
||||
frameConditions.senderQueue(event);
|
||||
},
|
||||
});
|
||||
const frameParser = createJpegFrameParser(handleJpegFrame);
|
||||
|
||||
@@ -883,6 +890,7 @@ function streamSplitFrames(websocket, session) {
|
||||
}
|
||||
|
||||
logInfo(`frames closed kind=${label} reason=${stopReason} frames=${frameIndex} skippedFrames=${skippedFrames}`);
|
||||
frameConditions.flush('close', true);
|
||||
});
|
||||
|
||||
websocket.on('close', () => {
|
||||
@@ -899,6 +907,10 @@ function streamSplitFrames(websocket, session) {
|
||||
stopProcess(ffmpeg);
|
||||
});
|
||||
|
||||
websocket.on('message', (data, isBinary) => {
|
||||
clientAudioTime = handleFrameClientMessage(data, isBinary, label, clientAudioTime);
|
||||
});
|
||||
|
||||
function handleFrameData(chunk) {
|
||||
frameParser.write(chunk);
|
||||
}
|
||||
@@ -907,6 +919,12 @@ function streamSplitFrames(websocket, session) {
|
||||
const timestamp = frameIndex / fps;
|
||||
frameIndex += 1;
|
||||
|
||||
if (isFrameLateForClient(timestamp, clientAudioTime)) {
|
||||
skippedFrames += 1;
|
||||
frameConditions.clientClockSkip(timestamp, clientAudioTime);
|
||||
return true;
|
||||
}
|
||||
|
||||
const sendResult = frameSender.send(timestamp, jpeg);
|
||||
|
||||
if (sendResult === 'closed') {
|
||||
@@ -943,9 +961,11 @@ function createRelayPlayback(session) {
|
||||
let frameExitSignal = null;
|
||||
let frameEndSent = false;
|
||||
let frameSender = null;
|
||||
let clientAudioTime = null;
|
||||
let audioResponseFinished = false;
|
||||
let serverClosingWebSocket = false;
|
||||
let readyTimer = null;
|
||||
const frameConditions = createFrameConditionLogger(label);
|
||||
const audioStderr = createFfmpegStderrTail();
|
||||
const frameStderr = createFfmpegStderrTail();
|
||||
const frameParser = createJpegFrameParser(handleJpegFrame);
|
||||
@@ -1012,6 +1032,9 @@ function createRelayPlayback(session) {
|
||||
onSkip: () => {
|
||||
skippedFrames += 1;
|
||||
},
|
||||
onQueue: (event) => {
|
||||
frameConditions.senderQueue(event);
|
||||
},
|
||||
});
|
||||
|
||||
sendJson(websocket, {
|
||||
@@ -1037,6 +1060,10 @@ function createRelayPlayback(session) {
|
||||
}
|
||||
});
|
||||
|
||||
websocket.on('message', (data, isBinary) => {
|
||||
clientAudioTime = handleFrameClientMessage(data, isBinary, label, clientAudioTime);
|
||||
});
|
||||
|
||||
maybeStart();
|
||||
},
|
||||
};
|
||||
@@ -1187,6 +1214,12 @@ function createRelayPlayback(session) {
|
||||
const timestamp = frameIndex / fps;
|
||||
frameIndex += 1;
|
||||
|
||||
if (isFrameLateForClient(timestamp, clientAudioTime)) {
|
||||
skippedFrames += 1;
|
||||
frameConditions.clientClockSkip(timestamp, clientAudioTime);
|
||||
return true;
|
||||
}
|
||||
|
||||
const sendResult = frameSender?.send(timestamp, jpeg) ?? 'closed';
|
||||
|
||||
if (sendResult === 'closed') {
|
||||
@@ -1278,6 +1311,7 @@ function createRelayPlayback(session) {
|
||||
}
|
||||
|
||||
logInfo(`relay closed kind=${label} reason=${stopReason} sourceBytes=${sourceBytes} frames=${frameIndex} skippedFrames=${skippedFrames} audioInputPeakBytes=${audioInput?.peakBytes ?? 0} frameInputPeakBytes=${frameInput?.peakBytes ?? 0}`);
|
||||
frameConditions.flush('close', true);
|
||||
}
|
||||
|
||||
function clearReadyTimer() {
|
||||
@@ -1310,6 +1344,8 @@ function createPlayback(session) {
|
||||
let closed = false;
|
||||
let readyTimer = null;
|
||||
let frameSender = null;
|
||||
let clientAudioTime = null;
|
||||
const frameConditions = createFrameConditionLogger(label);
|
||||
const stderrTail = createFfmpegStderrTail();
|
||||
const frameParser = createJpegFrameParser(handleJpegFrame);
|
||||
|
||||
@@ -1371,6 +1407,9 @@ function createPlayback(session) {
|
||||
onSkip: () => {
|
||||
skippedFrames += 1;
|
||||
},
|
||||
onQueue: (event) => {
|
||||
frameConditions.senderQueue(event);
|
||||
},
|
||||
});
|
||||
|
||||
sendJson(websocket, {
|
||||
@@ -1396,6 +1435,10 @@ function createPlayback(session) {
|
||||
}
|
||||
});
|
||||
|
||||
websocket.on('message', (data, isBinary) => {
|
||||
clientAudioTime = handleFrameClientMessage(data, isBinary, label, clientAudioTime);
|
||||
});
|
||||
|
||||
maybeStart();
|
||||
},
|
||||
};
|
||||
@@ -1525,6 +1568,12 @@ function createPlayback(session) {
|
||||
const timestamp = frameIndex / fps;
|
||||
frameIndex += 1;
|
||||
|
||||
if (isFrameLateForClient(timestamp, clientAudioTime)) {
|
||||
skippedFrames += 1;
|
||||
frameConditions.clientClockSkip(timestamp, clientAudioTime);
|
||||
return true;
|
||||
}
|
||||
|
||||
const sendResult = frameSender?.send(timestamp, jpeg) ?? 'closed';
|
||||
|
||||
if (sendResult === 'closed') {
|
||||
@@ -1575,6 +1624,7 @@ function createPlayback(session) {
|
||||
}
|
||||
|
||||
logInfo(`playback closed kind=${label} reason=${stopReason} frames=${frameIndex} skippedFrames=${skippedFrames} audioQueuePeakBytes=${audioQueuePeakBytes} audioBackpressureCount=${audioBackpressureCount} audioDrainCount=${audioDrainCount}`);
|
||||
frameConditions.flush('close', true);
|
||||
}
|
||||
|
||||
function clearReadyTimer() {
|
||||
@@ -1678,7 +1728,7 @@ function createJpegFrameParser(onFrame) {
|
||||
}
|
||||
}
|
||||
|
||||
function createLatestFrameSender(websocket, { onError = () => {}, onSkip = () => {} } = {}) {
|
||||
function createLatestFrameSender(websocket, { onError = () => {}, onSkip = () => {}, onQueue = () => {} } = {}) {
|
||||
let sending = false;
|
||||
let latestFrame = null;
|
||||
let pumpTimer = null;
|
||||
@@ -1691,8 +1741,21 @@ function createLatestFrameSender(websocket, { onError = () => {}, onSkip = () =>
|
||||
|
||||
const packetBytes = 8 + getJpegFrameByteLength(jpeg);
|
||||
|
||||
if (sending || isWebSocketOverFrameBudget(websocket, packetBytes)) {
|
||||
queueLatestFrame(timestamp, jpeg);
|
||||
if (sending) {
|
||||
queueLatestFrame(timestamp, jpeg, {
|
||||
reason: 'send_in_progress',
|
||||
packetBytes,
|
||||
bufferedAmount: websocket.bufferedAmount,
|
||||
});
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
if (isWebSocketOverFrameBudget(websocket, packetBytes)) {
|
||||
queueLatestFrame(timestamp, jpeg, {
|
||||
reason: 'websocket_buffer_budget',
|
||||
packetBytes,
|
||||
bufferedAmount: websocket.bufferedAmount,
|
||||
});
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
@@ -1700,12 +1763,15 @@ function createLatestFrameSender(websocket, { onError = () => {}, onSkip = () =>
|
||||
},
|
||||
};
|
||||
|
||||
function queueLatestFrame(timestamp, jpeg) {
|
||||
if (latestFrame) {
|
||||
function queueLatestFrame(timestamp, jpeg, event) {
|
||||
const replacing = Boolean(latestFrame);
|
||||
|
||||
if (replacing) {
|
||||
onSkip();
|
||||
}
|
||||
|
||||
latestFrame = { timestamp, jpeg };
|
||||
onQueue({ ...event, replacing });
|
||||
schedulePump();
|
||||
}
|
||||
|
||||
@@ -1774,6 +1840,198 @@ function createLatestFrameSender(websocket, { onError = () => {}, onSkip = () =>
|
||||
}
|
||||
}
|
||||
|
||||
function handleFrameClientMessage(data, isBinary, label, currentClientAudioTime) {
|
||||
const message = parseFrameClientMessage(data, isBinary);
|
||||
|
||||
if (!message) {
|
||||
return currentClientAudioTime;
|
||||
}
|
||||
|
||||
if (message.type === 'clock') {
|
||||
return message.currentTime;
|
||||
}
|
||||
|
||||
if (message.type === 'telemetry') {
|
||||
logClientTelemetry(label, message);
|
||||
}
|
||||
|
||||
return currentClientAudioTime;
|
||||
}
|
||||
|
||||
function parseFrameClientMessage(data, isBinary) {
|
||||
if (isBinary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let message;
|
||||
|
||||
try {
|
||||
const raw = typeof data === 'string' ? data : data.toString('utf8');
|
||||
message = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (message?.type === 'clock') {
|
||||
const currentTime = Number(message.currentTime);
|
||||
|
||||
if (!Number.isFinite(currentTime)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'clock',
|
||||
currentTime: clampNumber(currentTime, 0, Number.MAX_SAFE_INTEGER),
|
||||
};
|
||||
}
|
||||
|
||||
if (message?.type === 'telemetry') {
|
||||
return {
|
||||
type: 'telemetry',
|
||||
reason: sanitizeTelemetryText(message.reason, 'periodic'),
|
||||
currentTime: finiteTelemetryNumber(message.currentTime),
|
||||
paused: Boolean(message.paused),
|
||||
readyState: finiteTelemetryNumber(message.readyState),
|
||||
pendingFrames: finiteTelemetryNumber(message.pendingFrames),
|
||||
decodedFrames: finiteTelemetryNumber(message.decodedFrames),
|
||||
paintedFrames: finiteTelemetryNumber(message.paintedFrames),
|
||||
lastFramePacketAgeMs: finiteTelemetryNumber(message.lastFramePacketAgeMs),
|
||||
lastFramePaintAgeMs: finiteTelemetryNumber(message.lastFramePaintAgeMs),
|
||||
hidden: Boolean(message.hidden),
|
||||
online: Boolean(message.online),
|
||||
counters: sanitizeTelemetryMap(message.counters),
|
||||
max: sanitizeTelemetryMap(message.max),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFrameLateForClient(timestamp, clientAudioTime) {
|
||||
return Number.isFinite(clientAudioTime) && timestamp < clientAudioTime - CLIENT_CLOCK_FRAME_LATE_GRACE_SECONDS;
|
||||
}
|
||||
|
||||
function createFrameConditionLogger(label) {
|
||||
let counters = {};
|
||||
let max = {};
|
||||
let lastLoggedAt = 0;
|
||||
|
||||
return {
|
||||
senderQueue(event) {
|
||||
incrementCounter(`senderQueue_${event.reason}`);
|
||||
|
||||
if (event.replacing) {
|
||||
incrementCounter('senderReplacedLatest');
|
||||
}
|
||||
|
||||
recordMax('wsBufferedBytes', event.bufferedAmount);
|
||||
recordMax('framePacketBytes', event.packetBytes);
|
||||
flush('periodic');
|
||||
},
|
||||
clientClockSkip(timestamp, clientAudioTime) {
|
||||
incrementCounter('clientClockSkips');
|
||||
recordMax('clientClockLagMs', (clientAudioTime - timestamp) * 1000);
|
||||
flush('periodic');
|
||||
},
|
||||
flush,
|
||||
};
|
||||
|
||||
function incrementCounter(name, count = 1) {
|
||||
counters[name] = (counters[name] ?? 0) + count;
|
||||
}
|
||||
|
||||
function recordMax(name, value) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
max[name] = Math.max(max[name] ?? 0, Math.round(value));
|
||||
}
|
||||
|
||||
function flush(reason = 'periodic', force = false) {
|
||||
const now = Date.now();
|
||||
|
||||
if (!force && now - lastLoggedAt < FRAME_CONDITION_LOG_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(counters).length === 0 && Object.keys(max).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logInfo(`frame conditions kind=${label} reason=${reason} counters=${formatTelemetryMap(counters)} max=${formatTelemetryMap(max)}`);
|
||||
counters = {};
|
||||
max = {};
|
||||
lastLoggedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
function logClientTelemetry(label, telemetry) {
|
||||
logInfo(
|
||||
`client telemetry kind=${label}` +
|
||||
` reason=${telemetry.reason}` +
|
||||
` currentTime=${formatTelemetryNumber(telemetry.currentTime)}` +
|
||||
` paused=${telemetry.paused}` +
|
||||
` readyState=${formatTelemetryNumber(telemetry.readyState)}` +
|
||||
` pendingFrames=${formatTelemetryNumber(telemetry.pendingFrames)}` +
|
||||
` decodedFrames=${formatTelemetryNumber(telemetry.decodedFrames)}` +
|
||||
` paintedFrames=${formatTelemetryNumber(telemetry.paintedFrames)}` +
|
||||
` lastFramePacketAgeMs=${formatTelemetryNumber(telemetry.lastFramePacketAgeMs)}` +
|
||||
` lastFramePaintAgeMs=${formatTelemetryNumber(telemetry.lastFramePaintAgeMs)}` +
|
||||
` hidden=${telemetry.hidden}` +
|
||||
` online=${telemetry.online}` +
|
||||
` counters=${formatTelemetryMap(telemetry.counters)}` +
|
||||
` max=${formatTelemetryMap(telemetry.max)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeTelemetryMap(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const sanitized = {};
|
||||
|
||||
for (const [key, rawValue] of Object.entries(value).slice(0, 40)) {
|
||||
const name = sanitizeTelemetryText(key, '').replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 80);
|
||||
const number = finiteTelemetryNumber(rawValue);
|
||||
|
||||
if (name && number !== null) {
|
||||
sanitized[name] = number;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function sanitizeTelemetryText(value, fallback) {
|
||||
const text = typeof value === 'string' ? value : fallback;
|
||||
return oneLine(text).replace(/"/g, '').slice(0, 120);
|
||||
}
|
||||
|
||||
function finiteTelemetryNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function formatTelemetryMap(value) {
|
||||
const entries = Object.entries(value);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
return entries.map(([key, entryValue]) => `${key}=${formatTelemetryNumber(entryValue)}`).join(',');
|
||||
}
|
||||
|
||||
function formatTelemetryNumber(value) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function isWebSocketOverFrameBudget(websocket, packetBytes) {
|
||||
return websocket.bufferedAmount > 0 && websocket.bufferedAmount + packetBytes > MAX_WS_BUFFER_BYTES;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user