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) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-27 14:47:19 -07:00
parent d7e9fea1d3
commit 9b4884b466
+31 -9
View File
@@ -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;