Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0752746f7b | |||
| 614411b5e4 |
@@ -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())!;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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