feat(sim): scrub-driven altitude transitions #28
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user