feat(sim): scrub-driven altitude transitions #28

Merged
benstull merged 14 commits from session-0026 into main 2026-06-28 01:46:55 +00:00
2 changed files with 59 additions and 1 deletions
Showing only changes of commit 614411b5e4 - Show all commits
+32
View File
@@ -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())!;
+27 -1
View File
@@ -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;