# 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.5–0.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`).
