Merge pull request 'fix(sim): kill the reverse-landing frame jump (D3)' (#30) from fix/d3-reverse-landing-jump into main
This commit was merged in pull request #30.
This commit is contained in:
@@ -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())!;
|
||||
|
||||
+49
-20
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user