From ec660c4880d649ba1ff29ad5b2c906de3e85657d Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Mon, 29 Jun 2026 21:52:50 -0700 Subject: [PATCH] fix(sim): add ended safety net so the base loop never freezes Root cause: the steady-state loop (#vid-loop) restarts only via a narrow timeupdate window (last 0.08s -> seek to LOOP_TAIL_S), with native loop off. Headless Chromium reliably lands a final timeupdate in that window, but under real-browser decode/GPU load a timeupdate can skip past it; the clip then fires 'ended' and freezes on its last frame ('videos aren't looping'). There was no recovery path. Add an 'ended' listener that seeks back to the tail and resumes, guaranteeing the loop regardless of the timeupdate race. No change to normal playback. Proven by a Playwright regression test that forces the ended state. Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/e2e/tests/loop-recovery.spec.ts | 29 +++++++++++++++++++++++ simulator/static/app.js | 11 +++++++++ 2 files changed, 40 insertions(+) create mode 100644 simulator/e2e/tests/loop-recovery.spec.ts diff --git a/simulator/e2e/tests/loop-recovery.spec.ts b/simulator/e2e/tests/loop-recovery.spec.ts new file mode 100644 index 0000000..5455612 --- /dev/null +++ b/simulator/e2e/tests/loop-recovery.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from "@playwright/test"; + +// Regression: the steady-state loop (#vid-loop) restarts from the morph-tail +// offset via a narrow timeupdate window. If that window is ever missed the clip +// fires `ended` and — without a safety net — freezes on its last frame ("videos +// aren't looping"). This asserts the `ended` safety net recovers playback. +test("loop recovers when a clip reaches `ended` (no freeze on last frame)", async ({ page }) => { + await page.goto("/"); + await page.waitForFunction( + () => document.querySelector("#loading")?.classList.contains("done"), + null, + { timeout: 45_000 }, + ); + + const r = await page.evaluate(async () => { + const lv = document.getElementById("vid-loop") as HTMLVideoElement; + // Reproduce the stuck state: parked on the last frame, as after a missed wrap. + lv.pause(); + lv.currentTime = Math.max(0, lv.duration - 0.001); + // The browser emits `ended` here in real life; emit it deterministically. + lv.dispatchEvent(new Event("ended")); + await new Promise((res) => setTimeout(res, 800)); + return { dur: +lv.duration.toFixed(2), ct: +lv.currentTime.toFixed(2), paused: lv.paused }; + }); + + // Recovered: playing again, and seeked back near the loop tail (not stuck at end). + expect(r.paused).toBe(false); + expect(r.ct).toBeLessThan(5); +}); diff --git a/simulator/static/app.js b/simulator/static/app.js index 8c0b7e9..dbe005e 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -274,6 +274,17 @@ loopVid.addEventListener("timeupdate", () => { } }); +// Safety net: the timeupdate wrap above is a narrow window (the last 0.08 s). Under +// real-browser decode/GPU load a `timeupdate` can skip past it, the clip fires +// `ended`, and — with native loop off — freezes on its last frame ("not looping"). +// `ended` GUARANTEES recovery: seek back to the tail and resume. Headless rarely +// hits this; loaded real machines do. +loopVid.addEventListener("ended", () => { + if (loopVid.dataset.loopTail !== "1") return; + try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable */ } + loopVid.play().catch(() => {}); +}); + // Compose the video look. The Right DREAM is now a painterly WebGL restyle of the // live frames (see the render loop below) — so here we only stash its intensity for // that loop, and build the Dark/Light mood grade as a CSS filter. The grade rides