From c13ce36787f1f8afb74c6b1afca6fe4d5aabc0c5 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 07:16:47 -0700 Subject: [PATCH] fix(sim): kill the reverse-landing frame jump (D3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ascending the altitude dial and landing on the higher-altitude clip jumped ~3 seconds. A baked morph `transitions/{h}__{l}.mp4` spans src@0 (frac 0) -> dst@~LOOP_TAIL_S (frac 1) and is scrubbed bidirectionally, but the steady loop element always seeked LOOP_TAIL_S (3.0s) on landing. Descending lands on dst at its tail (matches the seek — the s0026 loop-from-tail fix); ascending lands on src at the morph's HEAD (src@0), so seeking the tail was a 3s jump. Make the loop landing frame direction-aware: pure `HEFScrub.loopLandFrame(dir, loopTailS)` returns 0 when ascending, loopTailS otherwise. `loadLoop` takes a `landFrame`, stores it as `dataset.loopStart`, and the timeupdate/ended wrap handlers honor it (the base clips are already seamless crossfade-loops, so a reverse landing wraps [0,dur] cleanly). The settle path lands explicitly on the direction's frame so a fast ascent never jumps even if the heading clip wasn't preloaded in time. App-side only — no 154-morph LFS re-bake. Verify: node unit (loopLandFrame) + a new reverse-landing e2e asserting the loop anchors at the head (loopStart "0"), and the existing forward "loops from tail" e2e still green (12/12 altitude-lock). Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/e2e/tests/altitude-lock.spec.ts | 32 +++++++++++ simulator/static/app.js | 69 ++++++++++++++++------- simulator/static/scrub.js | 14 ++++- simulator/unit/scrub.test.js | 14 +++++ 4 files changed, 108 insertions(+), 21 deletions(-) diff --git a/simulator/e2e/tests/altitude-lock.spec.ts b/simulator/e2e/tests/altitude-lock.spec.ts index a6da1d3..253a1cd 100644 --- a/simulator/e2e/tests/altitude-lock.spec.ts +++ b/simulator/e2e/tests/altitude-lock.spec.ts @@ -164,6 +164,38 @@ test("a landed clip loops from the morph-tail offset (no jump-back to frame 0)", expect(v.t).toBeGreaterThanOrEqual(2.5); // looping from ~3s, not 0 }); +test("ascending re-lands anchored at the clip head (no reverse-landing jump)", async ({ page }) => { + // D3: a morph spans src@0 (frac 0) -> dst@~3s (frac 1). Descending lands on dst at its + // tail (~3s); ASCENDING lands on src at its HEAD (~0s). The steady loop must continue + // from 0 there — seeking to the tail (~3s) was the ~3s reverse-landing jump. + await boot(page); + await enableVideo(page); // base loop only loads when video is shown + // Descend one altitude (cosmos -> orbit), then ascend back (orbit -> cosmos): the + // ascent is the reverse landing under test. + const start = (await page.locator("#scale-name").textContent())!; + await wheelOnStage(page, 60); // descend + await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 }); + const mid = (await page.locator("#scale-name").textContent())!; + expect(mid).toContain("orbit"); + await wheelOnStage(page, -60); // ascend back + await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, mid, { timeout: 20000 }); + expect(await page.locator("#scale-name").textContent()).toContain("cosmos"); + // The loop element is now anchored at the HEAD (loopStart "0"), playing — not jumped to + // the ~3s tail the descending case uses. + await page.waitForFunction( + () => { const v = document.querySelector("#vid-loop") as HTMLVideoElement; return v.dataset.loopTail === "1" && v.dataset.loopStart === "0"; }, + null, + { timeout: 10000 }, + ); + const v = await page.evaluate(() => { + const el = document.querySelector("#vid-loop") as HTMLVideoElement; + return { loopStart: el.dataset.loopStart, loopTail: el.dataset.loopTail, paused: el.paused }; + }); + expect(v.loopTail).toBe("1"); // tail-loop machinery armed + expect(v.loopStart).toBe("0"); // anchored at the head, not the morph tail + expect(v.paused).toBe(false); // the loop is actually playing +}); + 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 7fe28d1..b8d7489 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -238,20 +238,42 @@ function preloadChip() { // to the loop's first frame, and the morph-covered intro never re-shows at rest. const LOOP_TAIL_S = 3.0; -// Load a clip's base into the LOOP element (#vid-loop), seeked to the tail and PAUSED -// on that exact frame. Idempotent per clip. Called during a scrub to PRELOAD the clip -// we're about to land on, so settle just plays an already-decoded element at the morph's -// last frame — no reload, no seek, no stall. `playLoop()` starts it at landing. -function loadLoop(clip) { - if (!clip || loopVid.dataset.clip === clip.id) return; +// The frame the current loop both LANDS on and WRAPS back to — stored per load so the +// wrap handlers honor a direction-aware landing (forward=LOOP_TAIL_S, reverse=0). +function loopStartFrame() { + const s = parseFloat(loopVid.dataset.loopStart); + return Number.isFinite(s) ? s : LOOP_TAIL_S; +} + +// Load a clip's base into the LOOP element (#vid-loop), seeked to `landFrame` and PAUSED +// on that exact frame. `landFrame` is the frame the morph last showed for THIS landing: +// LOOP_TAIL_S when descending (lands on the morph's dst-tail), 0 when ascending (lands on +// the morph's src-head — fixes the D3 reverse-landing jump). Omitted → LOOP_TAIL_S, the +// forward default for non-scrub paths (initial load, knob changes). Idempotent per clip; +// an explicit `landFrame` re-seeks the already-loaded clip if the travel direction flipped +// the landing phase, but a bare call (ensureClipMedia) never clobbers a phase a scrub set. +// Called during a scrub to PRELOAD the clip we're about to land on, so settle just plays +// an already-decoded element at the right frame — no reload, no seek, no stall. +function loadLoop(clip, landFrame) { + if (!clip) return; + const seekTo = (t) => { try { loopVid.currentTime = t; } catch (e) { /* not seekable yet */ } }; + if (loopVid.dataset.clip === clip.id) { + if (landFrame != null && loopVid.dataset.loopStart !== String(landFrame)) { + loopVid.dataset.loopStart = String(landFrame); + if (loopVid.readyState >= 1) seekTo(landFrame); + else loopVid.addEventListener("loadedmetadata", () => seekTo(landFrame), { once: true }); + } + return; + } + const lf = landFrame == null ? LOOP_TAIL_S : landFrame; loopVid.dataset.clip = clip.id; - loopVid.dataset.loopTail = "1"; // arm the [LOOP_TAIL_S, duration] wrap handler + loopVid.dataset.loopTail = "1"; // arm the [loopStart, duration] wrap handler + loopVid.dataset.loopStart = String(lf); loopVid.src = mediaUrl(clip.base_file); - loopVid.loop = false; // native loop restarts at 0; we loop from the tail instead + loopVid.loop = false; // native loop restarts at 0; we loop from loopStart loopVid.muted = true; - const seek = () => { try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable yet */ } }; - if (loopVid.readyState >= 1) seek(); - else loopVid.addEventListener("loadedmetadata", seek, { once: true }); + if (loopVid.readyState >= 1) seekTo(lf); + else loopVid.addEventListener("loadedmetadata", () => seekTo(lf), { once: true }); } function playLoop() { @@ -267,21 +289,22 @@ function ensureClipMedia() { } // The custom loop on the LOOP element: when a base loop reaches the end, jump back to -// the tail offset (not 0). The morph element (#vid) is driven directly by the scrub. +// its landing offset (LOOP_TAIL_S forward, 0 reverse — see loopStartFrame), not a fixed +// tail. The morph element (#vid) is driven directly by the scrub. loopVid.addEventListener("timeupdate", () => { if (loopVid.dataset.loopTail === "1" && loopVid.duration && loopVid.currentTime >= loopVid.duration - 0.08) { - try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable */ } + try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ } } }); // 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. +// `ended` GUARANTEES recovery: seek back to the landing offset 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 */ } + try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ } loopVid.play().catch(() => {}); }); @@ -835,7 +858,12 @@ function setPos(next) { busy = false; delete vid.dataset.morph; // clear so re-entering the same segment reloads the morph (not the base) currentClipId = null; // force ensureClipMedia to reload + loop the locked clip - playLoop(); // the loop element was preloaded to this clip @ tail during the scrub → instant, no reload + // Land the loop on the frame the morph last showed for THIS travel direction: + // descending lands on the morph's dst-tail (LOOP_TAIL_S), ascending on its src-head + // (0). Explicit (not relying on the scrub's preload) so a reverse landing never + // jumps even if the heading clip wasn't warmed in time. (D3 fix.) + loadLoop(activeClip(), HEFScrub.loopLandFrame(scrubDir, LOOP_TAIL_S)); + playLoop(); // the loop element was preloaded to this clip during the scrub → instant, no reload showActiveSource(); setNeedle(ringIndex * dialStep()); renderScaleReadout(); @@ -846,10 +874,11 @@ function setPos(next) { } busy = true; // mid-morph: block update() from reloading the base clip under us if (!activeSeg || activeSeg.lo !== lo) rebuildSegment(lo, ringIndex); - // Preload the clip we're heading toward into the LOOP element (paused @ tail) so the - // landing is a swap, not a reload+seek. dir>0 lands on clipHi, dir<0 on clipLo. + // Preload the clip we're heading toward into the LOOP element (paused on its landing + // frame) so the landing is a swap, not a reload+seek. dir>0 lands on clipHi at the + // morph's dst-tail (LOOP_TAIL_S); dir<0 lands on clipLo at the morph's src-head (0). const headingId = dir > 0 ? activeSeg.clipHi : activeSeg.clipLo; - loadLoop(clipsById[headingId]); + loadLoop(clipsById[headingId], HEFScrub.loopLandFrame(dir, LOOP_TAIL_S)); showActiveSource(); // morph element is the live source while scrubbing setNeedle(pos * dialStep()); blendAudio(lo, frac); diff --git a/simulator/static/scrub.js b/simulator/static/scrub.js index f7f19cb..171de04 100644 --- a/simulator/static/scrub.js +++ b/simulator/static/scrub.js @@ -29,6 +29,18 @@ return { from: 1 - f, to: f }; } + // The base-loop frame a landing must continue from so the steady loop picks up + // the EXACT frame the morph last showed — no jump. A morph spans src@0 (frac 0) + // -> dst@loopTailS (frac 1), scrubbed bidirectionally. Descending (dir>0) lands + // on dst at frac 1, whose frame is dst@loopTailS, so the loop resumes at loopTailS + // (and the morph-covered intro never re-shows). Ascending (dir<0) lands on src at + // frac 0, whose frame is src@0, so the loop must resume at 0 — seeking to loopTailS + // there is the ~3s reverse-landing jump (D3). dir 0 defaults to the forward/tail + // case (matches the loadLoop default used by non-scrub paths). + function loopLandFrame(dir, loopTailS) { + return dir < 0 ? 0 : loopTailS; + } + // Integers strictly crossed moving prevPos -> pos, in travel order. Landing // exactly on an integer counts as crossing it (it commits that altitude). function integerCrossings(prevPos, pos) { @@ -41,5 +53,5 @@ return out; } - return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, integerCrossings }; + return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, integerCrossings }; }); diff --git a/simulator/unit/scrub.test.js b/simulator/unit/scrub.test.js index 039120d..709c5f5 100644 --- a/simulator/unit/scrub.test.js +++ b/simulator/unit/scrub.test.js @@ -58,3 +58,17 @@ test("integerCrossings: multiple crossings in travel order", () => { assert.deepEqual(S.integerCrossings(2.4, 4.1), [{ index: 3, dir: 1 }, { index: 4, dir: 1 }]); assert.deepEqual(S.integerCrossings(3.2, 1.8), [{ index: 3, dir: -1 }, { index: 2, dir: -1 }]); }); + +test("loopLandFrame: descend lands on the morph's tail frame, ascend on its head", () => { + // A morph spans src@0 (frac 0) -> dst@loopTailS (frac 1). Descending (dir>0) lands + // on dst at frac 1 -> the base loop must continue from loopTailS. Ascending (dir<0) + // lands on src at frac 0 -> the loop must continue from 0 (NOT loopTailS — that was + // the D3 reverse-landing jump). + assert.equal(S.loopLandFrame(1, 3), 3); // descend -> tail + assert.equal(S.loopLandFrame(-1, 3), 0); // ascend -> head + // dir 0 (no travel) is treated as a forward/tail landing (the default everywhere else). + assert.equal(S.loopLandFrame(0, 3), 3); + // honors any tail value + assert.equal(S.loopLandFrame(1, 2.5), 2.5); + assert.equal(S.loopLandFrame(-1, 2.5), 0); +});