feat: server-side frame dropping under client backpressure (#30)

The live WebSocket pushed every frame on a wall-clock schedule regardless of
whether the client could keep up. On a slow device frames piled into the client
decode queue, and the client paid the inflate+delta-patch cost for each one
before dropping the excess in its render loop. CPU spent on frames never shown.

Client now reports its decoded-frame backlog (frameBuffer depth) ~4x/sec over
the existing command channel. When the backlog exceeds BACKLOG_HIGH the server
skips frames: it advances the source cheaply (grab, no decode/encode/send) so
video stays time-aligned with audio, and crucially holds prev_frame across the
gap so the next sent frame is a correct delta against the last SENT frame. No
keyframe resync needed - deltas are always relative to the last sent frame.
MAX_CONSEC_DROPS caps the gap and guarantees liveness for slow/non-reporting
clients. Fully backward compatible: a client that never reports keeps backlog=0
and behaviour is unchanged.

test/test_backpressure_gap.js encodes a keyframe + a dropped gap via codec.py
and decodes through the shipped codec.js, asserting the post-gap frame is
reconstructed bit-exact (and is a real DELTA), matching the no-drop path.
This commit is contained in:
Nate 2026-06-22 12:12:38 -04:00
parent cacf262d61
commit d9480e9f85
4 changed files with 238 additions and 0 deletions

21
app.js
View file

@ -40,6 +40,7 @@ function formatTime(seconds) {
// ── STATE ──
let state = 'IDLE'; // IDLE | PLAYING | PAUSED
let ws = null;
let bufferReportTimer = null; // periodic backlog report to the server (backpressure)
const frameBuffer = [];
const BUFFER_SIZE = 4;
let codecDecoder = null; // Adaptive codec decoder (codec.js)
@ -237,6 +238,7 @@ function connectWebSocket() {
lastRenderTime = performance.now();
lastFpsUpdate = lastRenderTime;
requestAnimationFrame(renderFrame);
startBufferReports();
};
if (audioEl) {
@ -421,8 +423,27 @@ function renderFrame(now) {
// CLEANUP
// ═══════════════════════════════════════
// ── BACKPRESSURE REPORTING ──
// Tell the server how many decoded frames are queued for render (frameBuffer
// depth). When it grows the client is behind, and the server drops frames
// server-side instead of making us decode (inflate + delta-patch) frames we
// would only drop after. ~4 Hz is plenty: the server only needs a coarse signal.
function startBufferReports() {
stopBufferReports();
bufferReportTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN && state === 'PLAYING') {
ws.send(JSON.stringify({ type: 'buffer', depth: frameBuffer.length }));
}
}, 250);
}
function stopBufferReports() {
if (bufferReportTimer) { clearInterval(bufferReportTimer); bufferReportTimer = null; }
}
function finishStream() {
state = 'IDLE';
stopBufferReports();
if (ws) { ws.onclose = null; ws.close(); ws = null; }
if (audioEl) { audioEl.pause(); audioEl.src = ''; }
ctx.clearRect(0, 0, canvas.width, canvas.height);