Compare commits

..

2 Commits

Author SHA1 Message Date
BenStullsBets 972ad8832b spec(design): scrub-driven altitude transitions (knob position drives morph + audio)
Approved design for the next increment: continuous knob position scrubs the morph
video + crossfades audio (hold-partway, reversible, re-roll-on-fresh-approach,
wheel auto-scrubs one altitude). Includes morph all-intra re-bake for smooth
seeking. To be planned + built in a separate session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:19:32 -07:00
BenStullsBets 9b4884b466 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>
2026-06-27 14:47:19 -07:00
2 changed files with 136 additions and 9 deletions
@@ -0,0 +1,105 @@
# Scrub-driven Altitude Transitions — Design
**Status:** Approved (brainstormed 2026-06-27, session 0024). Ready for writing-plans.
## Goal
Make the altitude transition **driven by the knob position** rather than auto-played.
Turning the physical knob (or, in the simulator, dragging the Altitude dial) at any
speed scrubs the morph video and crossfades the audio in lockstep with the knob. The
knob position *is* the scene: turn slow → the morph eases slowly; turn fast → it
races; stop halfway → it holds in a mid-morph blend; turn back → it reverses exactly.
This supersedes the timed dial-needle sweep (the dial-vs-video sync problem dissolves
because the knob and the transition become the same thing).
## Core model
A continuous **altitude position** `pos` (float): integers are altitudes/detents,
fractions are mid-morph blend. `pos = 2.4` ⇒ 40 % from coast (index 2) toward reef
(index 3). The knob/needle maps directly to `pos`; the morph video's `currentTime`
and the audio crossfade are scrubbed by `frac(pos)`.
**Decisions (locked):**
- **In-between state:** hold the blend wherever the knob stops — no auto-complete
(continuous encoder model). *(Q1 → a)*
- **Turn-back:** scrubs the same morph in reverse; lands on exactly the previous
altitude's clip you started from (fully reversible).
- **Destination randomness:** the next altitude's clip is a random pick from its pool,
**fixed during a single continuous gesture**, **re-rolled on each fresh approach**.
*(Q → a)*
- **Scroll wheel:** auto-scrub one altitude over ~0.6 s, then lock. *(Q2 → a)*
- **Lock-per-altitude** still holds: a resting altitude's clip stays locked until you
commit into a different one.
## Components
### 1. Scrub controller (frontend — `simulator/static/app.js`)
Replaces the discrete `advance(delta)` "commit then play" for drag/knob input.
- Drives `pos` from the dial pointer angle: `pos = restPos + accum / dialStep`. The
needle follows continuously (the needle *is* the knob — live follow is correct here).
- For the active segment `[floor, floor+1]` in the travel direction, sets
`vid.currentTime = frac(pos) × vid.duration`, **throttled to one seek per
`requestAnimationFrame`** (avoid seek thrash).
- Crossing an integer **commits**: the crossed altitude's chosen clip becomes the
locked `activeClipId`, `ringIndex` updates, and the next segment begins (re-roll).
- Reversible: `pos` decreasing scrubs the morph backward.
- Keep the pure, testable bits separable (position→segment, `frac`→currentTime,
integer-crossing commit, re-roll-on-fresh-approach) from the DOM/event glue.
### 2. Clip selection / lock
Entering a segment toward neighbor `k±1`, pick a random clip from that scale's pool
(client-side) and resolve the directed morph via the existing
`morphByPair["<fromClip>→<toClip>"]` lookup (built from `ring.transitions`). Fixed for
the gesture; re-rolled on a fresh approach. *(This moves the random pick client-side
for responsiveness vs the current server pick in `/api/ring/advance`; acceptable for
the simulator — the Pi player can mirror the same client-side pick. `pick_clip_id` in
`player/ring.py` stays the canonical pure helper.)*
### 3. Audio crossfade (frontend)
During a segment, crossfade the two adjacent scale soundtracks by `frac(pos)` (gains
`1-frac` / `frac`); at rest only the current scale plays. Hook into the existing audio
layer (`applyAudio` / the per-scale `audio` field in the ring).
### 4. Morph media re-bake (pipeline — `simulator/build_pool_manifest.py`)
Smooth scrubbing = seeking to arbitrary frames, which needs **dense keyframes**. The
current morphs use a sparse GOP, so they scrub "steppy." Re-bake the 154 morphs
**all-intra** (e.g. x264 `-g 1` / `keyint=1`, or `-intra`) so every frame is seekable.
Files grow (~176 MB → ~0.50.9 GB) but each clip stays small (front-end upload limit is
a non-issue; that was the 68 MB *base*, unrelated). Media is committed via **git-LFS**
(see `.gitattributes`); re-push after re-baking.
**Phasing:** build the interaction first against the *current* morphs to validate the
feel (will scrub somewhat steppy), then re-bake all-intra for smoothness. Two plan
phases.
### 5. Wheel + tap
- **Wheel:** auto-scrub `pos` by ±1 over ~0.6 s (drives the same scrub path), then lock.
- **Tap a dial label:** auto-scrub to that altitude the shortest way around.
## Testing
- **Unit (pytest or JS):** the pure controller logic — position→segment mapping,
`frac``currentTime`, commit+lock on integer crossing, re-roll on fresh approach,
reverse direction.
- **E2E (Playwright, `simulator/e2e`):** drag the dial partway → assert the morph
`currentTime` and the two audio gains track the angle; drag back → values reverse;
a full turn → commits and locks the destination clip; the wheel → auto-scrubs one
altitude and locks. (Extends the existing `altitude-lock.spec.ts` tier.)
## Out of scope
- Physical hardware / encoder firmware (the simulator drag is the stand-in; the model
is designed to map 1:1 to an encoder position later — see the networked control-surface
spec).
- Changing the clip-pair morph *set* or naming (`<src>__<dst>.mp4` + `.rev`) — reused as-is.
## Key existing context (for the implementer)
- Dial + transition logic: `simulator/static/app.js` (`advance`, `playTransition`,
`onDialDown/Move/Up`, `renderDial/setNeedle`, `morphByPair`, the preload helpers).
- Ring/morph contract: `player/ring.py` (`Scale.pool`, `pick_clip_id`, `morph_for`,
`resolve_move`), `simulator/clips.py` (`ring_to_dict`, `resolved_move_to_dict`),
`/api/ring/advance` in `simulator/app.py`.
- Morph manifest + baking: `simulator/build_pool_manifest.py` (`_make_transition`,
`_make_reverse`, `generate_media`, `build_manifest`); morphs at
`simulator/sample_media/transitions/<src>__<dst>.mp4` (+ `.rev`).
- The clip-pair morph + lock feature this builds on shipped in session 0024 (plan:
`docs/superpowers/plans/2026-06-27-altitude-clip-pair-morph-lock.md`).
+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;