fix: top-3 review findings (decoder ordering, public-by-default, per-session state)

Frontend (app.js):
- Serialize the stateful adaptive-codec decoder through a promise chain.
  decode() awaits a real async DecompressionStream, so the previous
  concurrent .then() let a small DELTA resolve before its keyframe and
  patch a stale/null prev -> corrupt frames. Adds .catch + stale-decoder
  guard so a re-INIT drops in-flight frames from the previous segment.
- Flush frameBuffer on INIT so playlist transitions don't stall the reset
  master clock on the previous video's tail frames (or render them under
  the new renderer on a mode change).
- Request /audio?v=<idx> using the new INIT queue-index field so audio is
  correct when multiple clients are connected.

Server (stream_server.py):
- Bind 127.0.0.1 by default (--host to opt into LAN); same-origin Origin
  check before streaming (CSWSH defense that still allows LAN same-origin).
- Scope /static to an app.js/style.css/codec.js whitelist (was serving the
  whole repo: source, playlist, any local .env/notes).
- Per-session audio: INIT carries the queue index; /audio?v= reads it
  (bounds-checked) instead of the shared global current_index.
- Validate/clamp playlist+CLI mode/vol/pixel/cols/rows; guard malformed
  playlist JSON. ffmpeg gets -nostdin + terminate/kill-with-timeout.
- Re-enable WS keepalive (reap dead clients); release VideoCapture on the
  isOpened()-false path.

Adds experiments/test_decode_order.js: dependency-free regression proving
serialized decode is bit-exact + in-order and that delta-before-keyframe
throws (no video fixtures needed).

Server fixes built by Codex from a Claude spec; Claude integrated + reviewed
(tightened the Origin check to same-origin so --host 0.0.0.0 LAN mode works).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
oneshot2001 2026-06-13 13:01:27 -06:00
parent 444334cfba
commit 7fa761212e
No known key found for this signature in database
3 changed files with 266 additions and 38 deletions

31
app.js
View file

@ -21,6 +21,7 @@ let ws = null;
const frameBuffer = [];
const BUFFER_SIZE = 4;
let codecDecoder = null; // Adaptive codec decoder (codec.js)
let decodeChain = Promise.resolve(); // serializes stateful codec decodes in arrival order
let targetFps = 24;
let frameInterval = 1000 / targetFps;
let renderMode = 1;
@ -165,6 +166,9 @@ function connectWebSocket() {
frameInterval = 1000 / targetFps;
renderMode = parseInt(p[2]);
pixelMode = (p.length > 5 && parseInt(p[5]) === 1);
// 7th field (added server-side) = current queue index, so /audio
// serves THIS client's video even when other clients are connected.
const videoIndex = (p.length > 6 && !Number.isNaN(parseInt(p[6]))) ? parseInt(p[6]) : 0;
buildCanvas(parseInt(p[3]), parseInt(p[4]));
// Initialize adaptive codec decoder (pixel=3 bytes, ASCII color=4 bytes)
@ -174,6 +178,13 @@ function connectWebSocket() {
codecDecoder = null;
}
// New segment (single video, or the next entry in a playlist):
// drop frames still buffered from the previous segment. Their
// timestamps belong to the old master clock (which resets when the
// audio reloads below), so keeping them stalls A/V sync — and on a
// mode change they'd be decoded under the wrong renderer.
frameBuffer.length = 0;
// ── AUDIO READY GATE ──
// Buffer video frames but don't render until audio is ready.
// This prevents the 0.5s initial stutter.
@ -190,7 +201,7 @@ function connectWebSocket() {
if (audioEl) {
audioEl.pause();
audioEl.src = '/audio?' + Date.now();
audioEl.src = '/audio?v=' + videoIndex + '&t=' + Date.now();
audioEl.volume = volumeSlider ? volumeSlider.value : 1.0;
audioEl.load();
audioEl.play().catch(() => {});
@ -222,10 +233,20 @@ function connectWebSocket() {
} else {
// Binary Frames — decoded via adaptive codec (raw/zlib/delta)
if (codecDecoder) {
codecDecoder.decode(event.data).then(({ frameIndex, frame }) => {
const frameTime = frameIndex / targetFps;
frameBuffer.push({ data: frame, time: frameTime });
});
// The codec is STATEFUL: a DELTA frame patches the previously
// decoded frame, so decodes MUST run in arrival order. decode()
// awaits a real async DecompressionStream, so firing them
// concurrently lets a small DELTA resolve before an earlier
// keyframe and patch a stale frame. Serialize through a chain.
const data = event.data;
const dec = codecDecoder;
decodeChain = decodeChain.then(async () => {
if (dec !== codecDecoder) return; // stream re-INIT'd → drop stale frame
const { frameIndex, frame } = await dec.decode(data);
frameBuffer.push({ data: frame, time: frameIndex / targetFps });
// Cap here (not only in the sync path) since this push is async.
while (frameBuffer.length > BUFFER_SIZE * 5) frameBuffer.shift();
}).catch((err) => { console.error('ASCILINE decode error:', err); });
} else {
// Fallback: legacy 4-byte header
const buffer = event.data;