The gap test proves codec correctness across a drop; this one runs the real
server loop and proves the drop mechanism fires: a client reporting a high
decode backlog gets a stream with skipped frame indices (maxGap ~= MAX_CONSEC_DROPS),
while a client reporting zero backlog gets every frame in order. Spawns
stream_server.py against an ffmpeg-generated clip; keeps stdin open because the
server's command loop runs on the main thread and dies on EOF.
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.
Address PR #27 review:
- download() now parses the video id straight from the URL and returns a
cached file without calling _probe_remote(), so cached reruns no longer
hit YouTube on every startup. Unrecognized URLs fall through to the probe.
- expand_playlist() passes --yes-playlist so a watch?v=...&list=... URL
expands the full list instead of queuing a single video.
Pass any yt-dlp-supported URL where a video file goes. A single video is
downloaded (<=480p, ASCILINE only needs a tiny grid), normalized to
H.264/AAC/CFR mp4, and cached in videos/ by id so replays and --loop are
instant. A playlist/channel URL expands into one queue entry per video,
each fetched lazily as it plays so a long playlist starts immediately.
Also fixes the playlist.json eager-download bug Yusuf reported: URL entries
in a JSON playlist are now left unresolved by load_playlist and fetched
on demand by the playback loop, instead of synchronously downloading every
link before the server starts.
Ported onto the new thread-pool/zero-copy main; all integration points
(resolve_video_path, load_playlist, build_queue, websocket loop) updated.
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.