Thumbnails stay on by default. --no-thumbnails skips building the preview sprite, so the server does no extra work and the rest of the player still works. The frontend already shows no preview when the sprite is unavailable.
Self-contained: generates a tiny video, checks the sprite grid math, the JPEG output, and the /scrub + /scrub_sprite endpoints. The ffmpeg parts skip if ffmpeg is missing.
Builds on the existing live seek/play/volume. Adds a polished, responsive control bar with play/pause, +/-10s skip, a played-progress fill, and a YouTube-style hover thumbnail preview on the seek bar. Thumbnails come from a small lazy /scrub endpoint that builds an in-memory sprite once per video with a single ffmpeg pass (no disk cache); easy to point at the static compiler's sprite instead.
Cold-start root cause: on a slow first load the <audio> element starts late, so
the audio-ready gate's wall-clock fallback has already advanced playback a second
or two by the time audio finally begins at currentTime≈0. The master clock then
snapped back toward 0, every buffered frame read as "in the future", and
renderFrame() deadlocked until audio caught up — the freeze. A refresh warms the
cache, audio starts immediately, the gap (and the freeze) vanish — which is why
reloading "fixes" it.
Fix: the first time audio is genuinely playing, capture the offset between the
wall clock and the audio clock and add it back, so the master clock follows
audio's *rate* without ever moving backward. When audio starts promptly the
offset is ~0, so normal playback is unchanged.
experiments/freeze_repro.js models a 2s-late audio start with a realistic jitter
buffer + 60fps render loop: the original code and a naive `currentTime > 0` guard
render 0-1 of ~96 frames after audio starts (frozen ~4s); the anchored clock
renders 96/96 smooth. Real-browser regression (muted Chrome): normal playback
unaffected at ~29 fps.
The binary protocol re-sent the full grid every frame. This adds an opt-in
per-frame codec that picks the smallest of three encodings and tags it in a
1-byte header, without changing the rendered output:
0 RAW framebuffer as-is (legacy)
1 ZLIB zlib(framebuffer)
2 DELTA only the cells changed since the previous frame, patched on top
Clients opt in via /ws?codec=adaptive; omitting it yields the original protocol
byte-for-byte, so existing clients are unaffected. A keyframe is forced
periodically for resync. codec.js is shared by the browser and the Node test,
so the shipped decode path is the tested one.
Optional --quality {lossless,high,balanced,low} enables lossy temporal delta
(conditional replenishment): a colour cell is only re-sent once it drifts past a
tolerance from what the viewer already sees; the character plane stays exact.
Default lossless = bit-exact.
Measured wire savings (mode 5, 200x80): static screen 0.3% of legacy (~375x),
pixel mode 11.6%, high-motion 63% (never worse). Encoder tuned (zlib level 3,
smart candidate selection) to stay well under the frame budget.
Verified bit-exact two independent ways: Python->Node vectors and a live
adaptive-vs-legacy WebSocket diff. (A fuller mutation + Autobahn conformance
harness exists on request.)