diff --git a/simulator/static/app.js b/simulator/static/app.js index 3647b2f..9627887 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -591,15 +591,20 @@ function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); } const FAST_BLEND_RATE = 2.5; // playback speed for a collapsed fast-spin pass -function playTransition(file, blended) { +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) => { - if (!file) { resolve(); return; } + 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"; @@ -608,16 +613,24 @@ function playTransition(file, blended) { vid.style.opacity = "1"; vid.src = mediaUrl(file); vid.playbackRate = blended ? FAST_BLEND_RATE : 1; - let done = false; + 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); }); } @@ -627,6 +640,10 @@ function playTransition(file, blended) { 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 @@ -637,19 +654,22 @@ async function advance(delta) { }); if (!resp.ok) return; const move = await resp.json(); - // Play each chained morph. 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. + // 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); + 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(); + 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 @@ -755,7 +775,9 @@ function onDialMove(e) { dialDrag.lastAng = a; dialDrag.accum += d; dialDrag.moved += Math.abs(d); - setNeedle(ringIndex * dialStep() + dialDrag.accum); // live feedback while turning + // No live finger-follow: the needle should move ONLY with the transition (it + // sweeps in sync with the morph on commit — see advance()), so we don't rotate + // it ahead of the zoom here. We still accumulate `accum` to score the detents. } function onDialUp(e) { if (!dialDrag) return;