Merge fix/video-loop-ended-safety-net: base loop never freezes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-29 21:52:55 -07:00
2 changed files with 40 additions and 0 deletions
+29
View File
@@ -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);
});
+11
View File
@@ -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