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