From 614411b5e46c9ee85bf058f1ce3aec5dacbddfca Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Sat, 27 Jun 2026 16:29:22 -0700 Subject: [PATCH] fix(sim): loop base from morph-tail offset so landed morphs don't jitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator eyeball feedback: landing on an altitude jump-cut the loop back to frame 0. Each morph reproduces the destination clip's first 3s (pipeline trim=0:3) and ends on dst@~3s, so the steady-state loop now starts at LOOP_TAIL_S=3.0 and loops within [3.0, duration] — the morph's last frame hands straight off to the loop, and the morph-covered intro never re-shows at rest (removes the overlapping 'altitude video that is part of the morph'). E2E asserts a landed clip loops from ~3s, not 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/e2e/tests/altitude-lock.spec.ts | 32 +++++++++++++++++++++++ simulator/static/app.js | 28 +++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/simulator/e2e/tests/altitude-lock.spec.ts b/simulator/e2e/tests/altitude-lock.spec.ts index 048db1c..f445082 100644 --- a/simulator/e2e/tests/altitude-lock.spec.ts +++ b/simulator/e2e/tests/altitude-lock.spec.ts @@ -39,6 +39,16 @@ async function enableAudio(page: Page) { }); } +// The Video toggle gates the base-clip loop (update() only loads media when video is +// shown). Same hidden-checkbox pattern. +async function enableVideo(page: Page) { + await page.evaluate(() => { + const c = document.querySelector("#visual") as HTMLInputElement; + c.checked = true; + c.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + test("two audio elements exist for crossfade", async ({ page }) => { await boot(page); const n = await page.evaluate(() => document.querySelectorAll("audio#aud, audio#aud-b").length); @@ -77,6 +87,28 @@ test("wheel auto-scrubs one altitude and locks", async ({ page }) => { expect(await page.locator("#scale-name").textContent()).toBe(landed); // locked }); +test("a landed clip loops from the morph-tail offset (no jump-back to frame 0)", async ({ page }) => { + await boot(page); + await enableVideo(page); // base loop only loads when video is shown + const start = (await page.locator("#scale-name").textContent())!; + await wheelOnStage(page, 60); + await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 }); + // After landing, the base loop is armed (loopTail="1") and seeked to the tail + // (~3s) so it continues the morph's last frame instead of restarting at 0. + await page.waitForFunction( + () => { const v = document.querySelector("video") as HTMLVideoElement; return v.dataset.loopTail === "1" && v.currentTime >= 2.5; }, + null, + { timeout: 10000 }, + ); + const v = await page.evaluate(() => { + const el = document.querySelector("video") as HTMLVideoElement; + return { t: el.currentTime, loopTail: el.dataset.loopTail, morph: el.dataset.morph || "" }; + }); + expect(v.morph).toBe(""); // on the base loop, not a morph + expect(v.loopTail).toBe("1"); // tail-loop armed + expect(v.t).toBeGreaterThanOrEqual(2.5); // looping from ~3s, not 0 +}); + test("zoom in plays the matching morph, lands locked, and stays put while parked", async ({ page }) => { await boot(page); const start = (await page.locator("#scale-name").textContent())!; diff --git a/simulator/static/app.js b/simulator/static/app.js index 1885c4b..35a80e2 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -214,17 +214,42 @@ function preloadChip() { // Load the active scale's BASE footage into the video (the painterly canvas reads // its frames live). The Right knob no longer swaps the source — it only drives the // deterministic dream restyle — so media reloads only when the SCALE changes. +// Each baked morph reproduces the first LOOP_TAIL_S seconds of the destination clip +// (pipeline `trim=0:3` in build_pool_manifest._make_transition), ending on that +// clip's frame at ~LOOP_TAIL_S. So the STEADY-STATE loop must begin there, not at 0 +// — otherwise a landed morph (dst@~3s) jump-cuts back to dst@0 (the jitter). We loop +// the base within [LOOP_TAIL_S, duration]: the morph's last frame hands straight off +// to the loop's first frame, and the morph-covered intro never re-shows at rest. +const LOOP_TAIL_S = 3.0; + +function seekToLoopStart() { + try { vid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable yet */ } +} + function ensureClipMedia() { const clip = activeClip(); if (!clip || clip.id === currentClipId) return; currentClipId = clip.id; + delete vid.dataset.morph; // this is a base loop, not a morph + vid.dataset.loopTail = "1"; // arm the [LOOP_TAIL_S, duration] loop handler vid.src = mediaUrl(clip.base_file); - vid.loop = true; + vid.loop = false; // native loop restarts at 0; we loop from the tail instead vid.muted = true; + if (vid.readyState >= 1) seekToLoopStart(); + else vid.addEventListener("loadedmetadata", seekToLoopStart, { once: true }); vid.play().catch(() => {}); vid.style.opacity = "1"; } +// The custom loop: when a base loop reaches the end, jump back to the tail offset +// (not 0). Inert during a morph (loopTail cleared in rebuildSegment), where the +// scrub drives currentTime directly. +vid.addEventListener("timeupdate", () => { + if (vid.dataset.loopTail === "1" && vid.duration && vid.currentTime >= vid.duration - 0.08) { + seekToLoopStart(); + } +}); + // 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 @@ -730,6 +755,7 @@ function rebuildSegment(lo, enteredFrom) { tint.style.opacity = "0"; vid.style.filter = "none"; vid.loop = false; + vid.dataset.loopTail = "0"; // morph scrub drives currentTime; disarm the base loop handler vid.style.opacity = "1"; if (vid.dataset.morph !== file) { vid.dataset.morph = file;