From 9b4884b466614914e752d29c456a56e18757312b Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Sat, 27 Jun 2026 14:47:19 -0700 Subject: [PATCH] feat(sim): sweep the Altitude needle in sync with the morph (no pre-move) The dial needle now sweeps one detent per crossed altitude driven by the morph video's playback progress, landing exactly when the morph ends and the clip locks. Drops the live finger-follow during drag (which moved the needle to the target ahead of the zoom and caused a snap-back on commit) so the needle moves ONLY with the transition. Verified in Chromium + WebKit, both directions, wheel and drag. Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/static/app.js | 40 +++++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) 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;