diff --git a/simulator/e2e/tests/altitude-lock.spec.ts b/simulator/e2e/tests/altitude-lock.spec.ts index f445082..e526b89 100644 --- a/simulator/e2e/tests/altitude-lock.spec.ts +++ b/simulator/e2e/tests/altitude-lock.spec.ts @@ -93,19 +93,20 @@ test("a landed clip loops from the morph-tail offset (no jump-back to frame 0)", 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. + // After landing, the dedicated loop element (#vid-loop) is armed (loopTail="1") and + // playing from the tail (~3s) — preloaded during the scrub, so the landing was a + // source swap, not a reload that restarts at 0. await page.waitForFunction( - () => { const v = document.querySelector("video") as HTMLVideoElement; return v.dataset.loopTail === "1" && v.currentTime >= 2.5; }, + () => { const v = document.querySelector("#vid-loop") 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 || "" }; + const el = document.querySelector("#vid-loop") as HTMLVideoElement; + return { t: el.currentTime, loopTail: el.dataset.loopTail, paused: el.paused }; }); - expect(v.morph).toBe(""); // on the base loop, not a morph expect(v.loopTail).toBe("1"); // tail-loop armed + expect(v.paused).toBe(false); // the loop is actually playing expect(v.t).toBeGreaterThanOrEqual(2.5); // looping from ~3s, not 0 }); diff --git a/simulator/static/app.js b/simulator/static/app.js index 35a80e2..789ddf8 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -7,8 +7,22 @@ const $ = (id) => document.getElementById(id); const vid = $("vid"), paint = $("paint"), tint = $("tint"), overlay = $("overlay"), black = $("black"), readout = $("readout"); +const loopVid = $("vid-loop"); // steady-state base loop element (double-buffer; #vid carries the scrub morphs) const affectLayer = $("affect"); +// The currently-displayed source element: the morph (#vid) while scrubbing (busy), +// else the preloaded base loop (#vid-loop). The paint shader + fallback read this so +// a landing is a source swap, never a reload of the on-screen element. +function displayVid() { return busy ? vid : loopVid; } + +// Show the active source, hide the other (matters only in the WebGL-less fallback; +// with the paint canvas it composites the active source anyway). +function showActiveSource() { + const a = displayVid(); + a.style.opacity = "1"; + (a === vid ? loopVid : vid).style.opacity = "0"; +} + // Right-dream state, read by the painterly render loop each frame. update() (debounced // on knob moves) writes these; the WebGL loop renders the live video continuously. let dreamIntensity = 0; // 0..1 — painterly strength (and dream pastel/luminous) @@ -222,31 +236,39 @@ function preloadChip() { // 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 */ } +// 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; + loopVid.dataset.clip = clip.id; + loopVid.dataset.loopTail = "1"; // arm the [LOOP_TAIL_S, duration] wrap handler + loopVid.src = mediaUrl(clip.base_file); + loopVid.loop = false; // native loop restarts at 0; we loop from the tail instead + 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 }); +} + +function playLoop() { + if (loopVid.paused) loopVid.play().catch(() => {}); } function ensureClipMedia() { const clip = activeClip(); - if (!clip || clip.id === currentClipId) return; + if (!clip) 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 = 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"; + loadLoop(clip); // idempotent: a no-op if the scrub already preloaded this clip + playLoop(); // steady state runs the loop } -// 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(); +// 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. +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 */ } } }); @@ -263,8 +285,8 @@ function applyVideoLook(tone, dream) { gradeFilter = `brightness(${bright.toFixed(3)}) saturate(${sat.toFixed(3)}) sepia(${sepia.toFixed(3)})`; dreamIntensity = dream; tint.style.opacity = (cool * 0.6).toFixed(3); - // WebGL-less fallback: the canvas is hidden, so grade the visible #vid directly. - if (!paintOK) vid.style.filter = gradeFilter; + // WebGL-less fallback: the canvas is hidden, so grade the visible source directly. + if (!paintOK) displayVid().style.filter = gradeFilter; } // --- Right dream: real-time painterly restyle (WebGL2 Kuwahara) --- @@ -352,11 +374,12 @@ function initPaint() { return true; } function paintLoop() { - if (paintOK && vid.readyState >= 2 && vid.videoWidth) { - const w = vid.videoWidth, h = vid.videoHeight; + const src = displayVid(); // morph while scrubbing, else the preloaded base loop + if (paintOK && src.readyState >= 2 && src.videoWidth) { + const w = src.videoWidth, h = src.videoHeight; if (paint.width !== w || paint.height !== h) { paint.width = w; paint.height = h; } gl.viewport(0, 0, w, h); - try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, vid); } catch (_) {} + try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, src); } catch (_) {} // No painterly restyle during a ring transition (busy) — show it raw. gl.uniform1f(uAmt, busy ? 0 : dreamIntensity); gl.uniform2f(uTexel, 1 / w, 1 / h); @@ -440,7 +463,8 @@ function boxAt(a, t) { // Loop-normalized playback time of the base video (0..1), for track interpolation. function loopT() { - return vid && vid.duration ? (vid.currentTime % vid.duration) / vid.duration : 0; + const v = displayVid(); + return v && v.duration ? (v.currentTime % v.duration) / v.duration : 0; } // --- Progressive labels & emotions (content-pipeline §11.3 / §11.4) --- @@ -591,11 +615,12 @@ async function update() { // the