feat(sim): wheel + label-tap auto-scrub then lock; retire discrete advance()
Per plan Task 4. onWheel + jumpToScale now animate pos via autoScrub() (drives the same setPos scrub path, ~600ms, lands exactly on the integer + locks). Removes the now-dead advance()/playTransition()/FAST_BLEND_RATE (the server /api/ring/advance round-trip is no longer used by the client). Post-landing morph warm-up folded into setPos's settle branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+23
-92
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user