diff --git a/simulator/e2e/tests/altitude-lock.spec.ts b/simulator/e2e/tests/altitude-lock.spec.ts index 8e0372c..1592c89 100644 --- a/simulator/e2e/tests/altitude-lock.spec.ts +++ b/simulator/e2e/tests/altitude-lock.spec.ts @@ -66,6 +66,17 @@ test("dragging the dial scrubs morph currentTime and audio gains", async ({ page await page.mouse.up(); }); +test("wheel auto-scrubs one altitude and locks", async ({ page }) => { + await boot(page); + const start = (await page.locator("#scale-name").textContent())!; + await wheelOnStage(page, 60); // wheel down = descend one altitude + await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 }); + const landed = (await page.locator("#scale-name").textContent())!; + expect(landed).toContain("orbit"); + await page.waitForTimeout(1500); + expect(await page.locator("#scale-name").textContent()).toBe(landed); // locked +}); + 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())!; diff --git a/simulator/static/app.js b/simulator/static/app.js index 85b8a5a..d52260a 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -587,95 +587,25 @@ async function update() { function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); } -// --- Scale ring (endless encoder) --- +// --- Scale ring (endless encoder, scrub-driven — see the scrub engine below) --- -const FAST_BLEND_RATE = 2.5; // playback speed for a collapsed fast-spin pass - -function playTransition(file, blended, fromDeg, toDeg) { - // Play one resolved morph start-to-finish, with the alteration layers hidden. - // The manifest already encodes direction (forward zoom-in vs `.rev` zoom-out), - // so the file is played as-is. A `blended` (fast-spin) step runs at - // FAST_BLEND_RATE so a quick encoder spin lands fast. If no morph was baked for - // the pair (file null), resolve immediately so advance() plain-cuts. Resolves on - // 'ended' (or a safety timeout that scales with playback rate). - // - // When fromDeg/toDeg are given, the Altitude needle is swept from fromDeg to - // toDeg IN SYNC with the morph's playback progress — so the dial turns AS the - // transition happens and lands exactly when the morph ends (not before). - return new Promise((resolve) => { - const sweep = typeof fromDeg === "number" && typeof toDeg === "number"; - if (!file) { if (sweep) setNeedle(toDeg); resolve(); return; } - overlay.style.opacity = "0"; - affectLayer.style.opacity = "0"; - tint.style.opacity = "0"; - vid.style.filter = "none"; - vid.loop = false; - vid.style.opacity = "1"; - vid.src = mediaUrl(file); - vid.playbackRate = blended ? FAST_BLEND_RATE : 1; - let done = false, raf = 0; - const tickNeedle = () => { - const p = vid.duration ? Math.min(vid.currentTime / vid.duration, 1) : 0; - setNeedle(fromDeg + (toDeg - fromDeg) * p); - if (!done) raf = requestAnimationFrame(tickNeedle); - }; - const finish = () => { - if (done) return; - done = true; - if (raf) cancelAnimationFrame(raf); - if (sweep) setNeedle(toDeg); // land precisely on the detent - vid.removeEventListener("ended", finish); - vid.playbackRate = 1; - resolve(); - }; - vid.addEventListener("ended", finish); - vid.play().catch(finish); - if (sweep) raf = requestAnimationFrame(tickNeedle); - setTimeout(finish, blended ? 3000 : 6000); - }); -} - -// Advance the ring `delta` detents: pick the destination clip(s), play the matching -// chained morph(s), then LOCK on the landed clip until the next altitude change. -async function advance(delta) { - if (busy || !ring || ring.scales.length < 2) return; - busy = true; - const stepDeg = dialStep(); - const dir = delta > 0 ? 1 : -1; - let curDeg = ringIndex * stepDeg; // the pre-move detent angle - setNeedle(curDeg); // reset to the start detent so the needle sweeps WITH the morph (overrides any live drag pre-move) - try { - // The server chooses the destination clip(s) FIRST (threading the currently- - // shown clip in as from_clip_id), then returns the matching member->member - // morphs to play — chained through each crossed altitude. - const resp = await fetch("/api/ring/advance", { - method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ from_index: ringIndex, delta, from_clip_id: activeClipId }), - }); - if (!resp.ok) return; - const move = await resp.json(); - // Play each chained morph, sweeping the needle one detent per step IN SYNC with - // that morph's playback. A fast spin marks every step blended (played quick); a - // slow spin plays each at 1x. Each morph already ends on its destination clip's - // frame, so there is NO secondary swap. - for (const step of move.steps) { - const toDeg = curDeg + dir * stepDeg; - // Diagnostic seam: record the logical morph path played (the resolved URL may - // be an in-memory blob, so this is the reliable record for Dev Mode + E2E). - (window.__hefMorphs || (window.__hefMorphs = [])).push(step.file); - await playTransition(step.file, step.blended, curDeg, toDeg); - curDeg = toDeg; - } - ringIndex = move.to_index; - activeClipId = move.target_clip_id; // LOCK on the landed clip — stays until the next altitude change - currentClipId = null; // force ensureClipMedia to (re)load + loop it - renderScaleReadout(); // also renderDial(): snaps needle to the exact detent + active label - } finally { - busy = false; - update(); // ensureClipMedia loads the locked clip; nothing re-rolls while parked - applyAudio(); // soundtrack follows the Altitude dial (crossfade to the new scale) - refreshReachablePreload(); // warm the morphs now reachable from the locked clip - } +// Animate `pos` to a target over `ms`, driving the same scrub path as a drag, then +// land exactly on the integer target (frac 0 settles + locks in setPos). +let autoRaf = 0; +function autoScrub(targetPos, ms = 600) { + if (!ring || ring.scales.length < 2) return; + if (autoRaf) cancelAnimationFrame(autoRaf); + const fromPos = pos, dist = targetPos - fromPos; + if (!dist) { setPos(targetPos); return; } + let startTs = null; + const tick = (ts) => { + if (startTs === null) startTs = ts; + const k = Math.min(1, (ts - startTs) / ms); + setPos(fromPos + dist * k); + if (k < 1) autoRaf = requestAnimationFrame(tick); + else { autoRaf = 0; setPos(targetPos); } // land exactly + lock (frac 0) + }; + autoRaf = requestAnimationFrame(tick); } let wheelAccum = 0, wheelTimer = null; @@ -686,7 +616,7 @@ function onWheel(e) { wheelTimer = setTimeout(() => { const detents = Math.trunc(wheelAccum / 60) || (wheelAccum > 0 ? 1 : -1); wheelAccum = 0; - if (detents) advance(detents); // wheel down = zoom inward (+) + if (detents) autoScrub(Math.round(pos) + detents); // wheel down = descend (+); auto-scrub then lock }, 90); } @@ -829,8 +759,9 @@ function setPos(next) { currentClipId = null; // force ensureClipMedia to reload + loop the locked clip setNeedle(ringIndex * dialStep()); renderScaleReadout(); - update(); + update(); // ensureClipMedia loads + loops the locked clip restAudio(ringIndex); + refreshReachablePreload(); // warm the morphs now reachable from the locked clip return; } busy = true; // mid-morph: block update() from reloading the base clip under us @@ -873,14 +804,14 @@ function onDialUp(e) { // No auto-complete: hold the blend wherever the knob stopped (continuous-encoder model). } -// Click a label → travel the SHORTEST signed way around the ring to that scale. +// Click a label → auto-scrub the SHORTEST signed way around the ring to that scale. function jumpToScale(idx) { if (!ring) return; const n = ring.scales.length; let d = (idx - ringIndex) % n; if (d > n / 2) d -= n; if (d < -n / 2) d += n; - if (d) advance(d); + if (d) autoScrub(Math.round(pos) + d); } // --- Dev Mode: pool picker + live analysis, all under one toggle ---