docs(plan): scrub-driven altitude transitions implementation plan

Plan for the approved design at
docs/superpowers/specs/2026-06-27-scrub-driven-altitude-transitions-design.md.
Two phases: (1) scrub interaction against current morphs, (2) all-intra re-bake.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-27 15:35:23 -07:00
parent 6781e40c94
commit 937461e3ba
@@ -0,0 +1,803 @@
# Scrub-driven Altitude Transitions Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the Altitude knob position *continuously* drive the scale transition — dragging the dial scrubs the morph video's `currentTime` and crossfades the two adjacent scale soundtracks by knob angle, holding wherever the knob stops, fully reversible.
**Architecture:** Introduce a continuous float **position** `pos` (integers = altitudes/detents, fractions = mid-morph blend). A small pure module (`scrub.js`) owns the math (position→segment, frac→currentTime, audio gains, integer-crossing detection); `app.js` becomes the DOM/event glue that drives `pos` from the dial, seeks the morph `<video>`, crossfades two `<audio>` elements, and commits/locks/re-rolls on integer crossings. Phase 1 builds the interaction against the *current* (sparse-GOP) morphs to validate feel; Phase 2 re-bakes all 154 morphs all-intra for smooth seeking.
**Tech Stack:** Vanilla JS (no framework, plain `<script>`), Node built-in `node --test` for pure-logic unit tests, Playwright for E2E, Python + ffmpeg/x264 for the morph re-bake (`simulator/build_pool_manifest.py`), git-LFS for media.
## Global Constraints
- **Spec contract:** `docs/superpowers/specs/2026-06-27-scrub-driven-altitude-transitions-design.md` — implement its locked decisions verbatim.
- **In-between state:** hold the blend wherever the knob stops — **no auto-complete** (continuous-encoder model).
- **Turn-back:** scrubs the **same** segment morph in reverse; landing back on the start integer re-locks **exactly** the clip you left (fully reversible).
- **Destination randomness:** the destination altitude's clip is a random pick from its pool, **fixed for a single continuous gesture**, **re-rolled on each fresh approach** (each fresh entry into a segment from an integer).
- **Scroll wheel:** auto-scrub one altitude over ~0.6 s, then lock. **Tap a dial label:** auto-scrub to that altitude the shortest way around.
- **Lock-per-altitude** still holds: a resting altitude's clip stays locked until you commit into a different one.
- **Canonical segment file (locked interpretation):** for the segment between altitude indices `lo` and `lo+1`, always use the **descend/forward** morph `morphByPair["<clip@lo>→<clip@lo+1>"]`, with `currentTime = frac × duration`. Reverse travel seeks the **same** file backward. `.rev` files are not used by the scrub interaction (they remain baked for back-compat).
- **Git transport:** SSH only (`ssh://git@git.benstull.org/...`). No inline trailing comments on shell/CLI command lines.
- **Pipeline:** ship via §9 — localhost + E2E green → PPE + E2E green → prod (prod human-gated). Every UI change carries its E2E browser tests as first-class tasks.
---
## File Structure
- **Create** `simulator/static/scrub.js` — pure scrub math, UMD (browser global `HEFScrub` + CommonJS `module.exports`). No DOM.
- **Create** `simulator/unit/scrub.test.js``node --test` unit suite for `scrub.js`.
- **Create** `simulator/unit/README.md` — one-liner on running the unit suite.
- **Modify** `simulator/static/index.html` — load `scrub.js` before `app.js`; add a second `<audio id="aud-b" loop preload="auto">`.
- **Modify** `simulator/static/app.js` — replace the discrete drag/wheel/tap `advance()` flow with the scrub engine (`setPos`, `rebuildSegment`, `commitCrossings`, auto-scrub animator); two-element audio crossfade; client-side clip pick from `scale.pool`.
- **Modify** `simulator/e2e/tests/altitude-lock.spec.ts` (or add `scrub.spec.ts`) — assert `currentTime` + the two audio gains track the dial angle and reverse on turn-back; full turn commits+locks; wheel auto-scrubs one altitude and locks.
- **Modify** `simulator/build_pool_manifest.py``_make_transition` / `_make_reverse` emit **all-intra** H.264 (`-g 1 -keyint_min 1 -sc_threshold 0`) for seekable frames.
- **Modify** `tests/test_build_pool_manifest.py` — assert the ffmpeg arg list carries the all-intra flags.
---
## Phase 1 — Scrub interaction against current morphs
### Task 1: Pure scrub math module (`scrub.js`) + Node unit tests
**Files:**
- Create: `simulator/static/scrub.js`
- Create: `simulator/unit/scrub.test.js`
- Create: `simulator/unit/README.md`
**Interfaces:**
- Produces (all pure, no DOM), exported on `HEFScrub` and `module.exports`:
- `clamp01(x: number): number`
- `wrapIndex(i: number, n: number): number``((i % n) + n) % n`
- `segmentOf(pos: number): { lo: number, frac: number }``lo = floor(pos)`, `frac = pos - lo`
- `accumToPos(restPos: number, accumDeg: number, stepDeg: number): number``restPos + accumDeg/stepDeg`
- `fracToTime(frac: number, duration: number): number``clamp01(frac) * (duration || 0)`
- `crossfadeGains(frac: number): { from: number, to: number }``{ from: 1-clamp01(frac), to: clamp01(frac) }`
- `integerCrossings(prevPos: number, pos: number): Array<{ index: number, dir: number }>` — integers strictly crossed going `prevPos → pos`, in travel order, `dir` = `+1` descending (pos increasing) / `-1` ascending. Landing exactly on an integer counts as crossing it.
- [ ] **Step 1: Write the failing unit suite**
Create `simulator/unit/scrub.test.js`:
```js
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const S = require("../static/scrub.js");
test("clamp01 bounds to [0,1]", () => {
assert.equal(S.clamp01(-0.2), 0);
assert.equal(S.clamp01(0.4), 0.4);
assert.equal(S.clamp01(1.5), 1);
});
test("wrapIndex wraps both directions", () => {
assert.equal(S.wrapIndex(0, 5), 0);
assert.equal(S.wrapIndex(5, 5), 0);
assert.equal(S.wrapIndex(-1, 5), 4);
assert.equal(S.wrapIndex(7, 5), 2);
});
test("segmentOf splits floor and fraction", () => {
assert.deepEqual(S.segmentOf(2), { lo: 2, frac: 0 });
const s = S.segmentOf(2.4);
assert.equal(s.lo, 2);
assert.ok(Math.abs(s.frac - 0.4) < 1e-9);
});
test("accumToPos maps knob degrees to position", () => {
assert.equal(S.accumToPos(2, 0, 72), 2);
assert.equal(S.accumToPos(2, 72, 72), 3);
assert.equal(S.accumToPos(2, -36, 72), 1.5);
});
test("fracToTime clamps and scales by duration", () => {
assert.equal(S.fracToTime(0, 1.5), 0);
assert.equal(S.fracToTime(0.5, 1.5), 0.75);
assert.equal(S.fracToTime(1.2, 1.5), 1.5);
assert.equal(S.fracToTime(0.5, 0), 0);
});
test("crossfadeGains splits energy by fraction", () => {
assert.deepEqual(S.crossfadeGains(0), { from: 1, to: 0 });
assert.deepEqual(S.crossfadeGains(1), { from: 0, to: 1 });
assert.deepEqual(S.crossfadeGains(0.25), { from: 0.75, to: 0.25 });
});
test("integerCrossings: none while inside a segment", () => {
assert.deepEqual(S.integerCrossings(2.1, 2.9), []);
assert.deepEqual(S.integerCrossings(2.0, 2.5), []);
assert.deepEqual(S.integerCrossings(3.0, 2.5), []);
});
test("integerCrossings: ascending and descending single crossings", () => {
assert.deepEqual(S.integerCrossings(2.4, 3.0), [{ index: 3, dir: 1 }]);
assert.deepEqual(S.integerCrossings(2.0, 3.0), [{ index: 3, dir: 1 }]);
assert.deepEqual(S.integerCrossings(3.4, 3.0), [{ index: 3, dir: -1 }]);
});
test("integerCrossings: multiple crossings in travel order", () => {
assert.deepEqual(S.integerCrossings(2.4, 4.1), [{ index: 3, dir: 1 }, { index: 4, dir: 1 }]);
assert.deepEqual(S.integerCrossings(3.2, 1.8), [{ index: 3, dir: -1 }, { index: 2, dir: -1 }]);
});
```
Create `simulator/unit/README.md`:
```markdown
# Simulator unit tests
Pure-logic JS unit tests (no browser), run with Node's built-in test runner:
node --test simulator/unit/
`scrub.test.js` covers `simulator/static/scrub.js` — the pure altitude-scrub math
(position→segment, frac→currentTime, audio gains, integer-crossing detection).
```
- [ ] **Step 2: Run the suite to verify it fails**
Run: `node --test simulator/unit/scrub.test.js`
Expected: FAIL — `Cannot find module '../static/scrub.js'`.
- [ ] **Step 3: Write `scrub.js` to pass**
Create `simulator/static/scrub.js`:
```js
// Pure altitude-scrub math — no DOM. A continuous `pos` (float) is the knob:
// integers are altitudes/detents, fractions are mid-morph blend. UMD so the
// browser gets a `HEFScrub` global and `node --test` can require() it.
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFScrub = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
const clamp01 = (x) => Math.max(0, Math.min(1, x));
const wrapIndex = (i, n) => ((i % n) + n) % n;
function segmentOf(pos) {
const lo = Math.floor(pos);
return { lo, frac: pos - lo };
}
function accumToPos(restPos, accumDeg, stepDeg) {
return restPos + accumDeg / stepDeg;
}
function fracToTime(frac, duration) {
return clamp01(frac) * (duration || 0);
}
function crossfadeGains(frac) {
const f = clamp01(frac);
return { from: 1 - f, to: f };
}
// Integers strictly crossed moving prevPos -> pos, in travel order. Landing
// exactly on an integer counts as crossing it (it commits that altitude).
function integerCrossings(prevPos, pos) {
const out = [];
if (pos > prevPos) {
for (let k = Math.floor(prevPos) + 1; k <= pos + 1e-9; k++) out.push({ index: k, dir: 1 });
} else if (pos < prevPos) {
for (let k = Math.ceil(prevPos) - 1; k >= pos - 1e-9; k--) out.push({ index: k, dir: -1 });
}
return out;
}
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, integerCrossings };
});
```
- [ ] **Step 4: Run the suite to verify it passes**
Run: `node --test simulator/unit/scrub.test.js`
Expected: PASS — all tests, 0 failures.
- [ ] **Step 5: Commit**
```bash
git add simulator/static/scrub.js simulator/unit/scrub.test.js simulator/unit/README.md
git commit -m "feat(sim): pure altitude-scrub math module + node unit suite"
```
---
### Task 2: Two-element audio crossfade
**Files:**
- Modify: `simulator/static/index.html` (add `<audio id="aud-b">`; load `scrub.js`)
- Modify: `simulator/static/app.js` (audio layer ~lines 9501045)
- Test: `simulator/unit/scrub.test.js` (gains already covered in Task 1 — no new pure logic)
**Interfaces:**
- Consumes: `HEFScrub.crossfadeGains`, `soundtrackUrl()`-style per-scale URL resolution.
- Produces:
- `scaleAudioUrl(index: number): string|null` — the soundtrack URL for ring scale `index` (wrapped), using the ring `audio` field then `SCALE_AUDIO_FALLBACK`.
- `blendAudio(loIndex: number, frac: number): void` — element A plays scale `loIndex`, element B plays scale `loIndex+1` (wrapped), gains `crossfadeGains(frac)`; loads each element's src on first use per gesture. No-op when Audio toggle is off.
- `restAudio(index: number): void` — settle to a single scale at rest (full gain on the element holding `index`, fade the other to 0). Replaces the tail of `applyAudio()` for the at-rest case.
- [ ] **Step 1: Add the second audio element + scrub.js script tag**
In `simulator/static/index.html`, beside the existing `<audio id="aud" loop preload="auto"></audio>` (line ~21), add:
```html
<audio id="aud" loop preload="auto"></audio>
<audio id="aud-b" loop preload="auto"></audio>
```
And load the pure module before `app.js` (line ~112):
```html
<script src="/scrub.js"></script>
<script src="/app.js"></script>
```
- [ ] **Step 2: Write the failing E2E expectation (gains exist on two elements)**
Add to `simulator/e2e/tests/altitude-lock.spec.ts` a focused check (full assertions land in Task 5; this verifies the element + hook exist):
```ts
test("two audio elements exist for crossfade", async ({ page }) => {
await boot(page);
const n = await page.evaluate(() => document.querySelectorAll("audio#aud, audio#aud-b").length);
expect(n).toBe(2);
});
```
Run: `cd simulator/e2e && npx playwright test -g "two audio elements"`
Expected: FAIL until the markup change is loaded (and PASS once the running server serves the new `index.html`).
- [ ] **Step 3: Implement the crossfade audio layer in `app.js`**
Replace the single-element logic around `playUrl`/`applyAudio` (app.js ~10191045). Keep `aud` as element A and add `audB` as element B; generalize URL resolution by index:
```js
const aud = $("aud");
const audB = $("aud-b");
let audLastErr = "";
// Soundtrack URL for ring scale `index` (wrapped), ring `audio` field then fallback.
function scaleAudioUrl(index) {
if (!ring || !ring.scales.length) return null;
const s = ring.scales[HEFScrub.wrapIndex(index, ring.scales.length)];
const a = s.audio || SCALE_AUDIO_FALLBACK[s.id];
return a ? "/media/audio/" + a : null;
}
// Start (once) and set the volume of one element to a given url. Safari needs the
// first play() inside a user gesture; later programmatic plays reuse the element.
function ensurePlaying(el, url, vol) {
if (!url) { el.volume = 0; if (!el.paused) el.pause(); return; }
if (el.dataset.url !== url) {
el.dataset.url = url;
el.src = mediaUrl(url);
}
if (el.paused) {
const pr = el.play();
if (pr) pr.then(() => { audLastErr = ""; updateAudioStatus(); })
.catch((e) => { audLastErr = "play BLOCKED: " + ((e && e.name) || e); updateAudioStatus(); });
}
el.volume = HEFScrub.clamp01(vol);
}
// Mid-segment: A = scale `loIndex`, B = scale loIndex+1, gains by frac.
function blendAudio(loIndex, frac) {
if (!$("audio").checked) return;
const g = HEFScrub.crossfadeGains(frac);
ensurePlaying(aud, scaleAudioUrl(loIndex), g.from);
ensurePlaying(audB, scaleAudioUrl(loIndex + 1), g.to);
updateAudioStatus();
}
// At rest on a single altitude: the element already holding it stays at full gain,
// the other fades to 0. Picks whichever element currently carries `index`'s url.
function restAudio(index) {
if (!$("audio").checked) { fadeVolume(aud, 0, FADE_MS, () => aud.pause()); fadeVolume(audB, 0, FADE_MS, () => audB.pause()); return; }
const url = scaleAudioUrl(index);
const onB = audB.dataset.url === url && url;
const active = onB ? audB : aud;
const idle = onB ? aud : audB;
ensurePlaying(active, url, 1);
fadeVolume(idle, 0, FADE_MS);
}
```
Then make the Audio toggle and altitude-rest paths call `restAudio(ringIndex)` instead of the old `applyAudio()` swap. Keep `applyAudio()` as a thin shim that calls `restAudio(ringIndex)` so existing call sites (e.g. the toggle handler at ~1073, `update()`) keep working:
```js
function applyAudio() { restAudio(ringIndex); }
```
- [ ] **Step 4: Run unit suite + the focused E2E**
Run: `node --test simulator/unit/scrub.test.js`
Expected: PASS.
Run: `cd simulator/e2e && npx playwright test -g "two audio elements"` (with the dev server running)
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add simulator/static/index.html simulator/static/app.js simulator/e2e/tests/altitude-lock.spec.ts
git commit -m "feat(sim): two-element audio crossfade keyed by scrub fraction"
```
---
### Task 3: Scrub engine — drag drives `pos`, seeks the morph, commits/locks/re-rolls
**Files:**
- Modify: `simulator/static/app.js` (`onDialDown/Move/Up` ~766795; `advance` ~640679; `playTransition` ~595636; needle/render ~739751)
- Test: `simulator/unit/scrub.test.js` (pure parts covered; engine glue is DOM, covered by E2E in Task 5)
**Interfaces:**
- Consumes: `HEFScrub.*`, `morphByPair`, `scale.pool` (client ring), `mediaUrl`, `setNeedle`, `dialStep`, `ensureClipMedia`/`update` (base-clip loop), `blendAudio`/`restAudio`.
- Produces:
- `pos` (module-level float; rest value = `ringIndex`).
- `pickPoolClip(index: number): string` — random `clip_id` from ring scale `index`'s pool (wrapped).
- `rebuildSegment(lo: number, enteredFrom: number): void` — sets `activeSeg = { lo, clipLo, clipHi, file }`; the side equal to the just-rested altitude takes `activeClipId`, the other is a fresh `pickPoolClip` (re-roll); `file = morphByPair["<clipLo>→<clipHi>"]` (descend canonical).
- `setPos(next: number, opts?: { commit?: boolean }): void` — clamps thrash via one seek per rAF; seeks `vid.currentTime = fracToTime(frac, dur)`; `setNeedle(next * dialStep())`; `blendAudio(lo, frac)`; processes `integerCrossings(pos, next)` to commit/lock/re-roll; updates `pos`.
- `commitTo(index: number): void``ringIndex = wrapIndex(index,n)`, `activeClipId = clip at that index for the active segment`, settle base loop + `restAudio` when frac becomes 0.
- [ ] **Step 1: Write the failing E2E (drag partway tracks currentTime + gains)**
Add to the E2E spec (full version in Task 5; this drives the engine):
```ts
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
await boot(page);
// Drag the dial ~40% of one detent (clockwise = descend).
const box = await page.locator("#dial").boundingBox();
const cx = box!.x + box!.width / 2, cy = box!.y + box!.height / 2;
await page.mouse.move(cx, cy - 30);
await page.mouse.down();
await page.mouse.move(cx + 18, cy - 24, { steps: 8 }); // partial turn
const state = await page.evaluate(() => ({
t: (document.querySelector("video") as HTMLVideoElement).currentTime,
ga: (document.querySelector("#aud") as HTMLAudioElement).volume,
gb: (document.querySelector("#aud-b") as HTMLAudioElement).volume,
}));
expect(state.t).toBeGreaterThan(0); // morph scrubbed off frame 0
expect(state.gb).toBeGreaterThan(0); // next scale audio fading in
expect(state.ga).toBeLessThan(1); // current scale fading out
await page.mouse.up();
});
```
Run: `cd simulator/e2e && npx playwright test -g "scrubs morph currentTime"`
Expected: FAIL (engine not built yet).
- [ ] **Step 2: Implement the scrub engine in `app.js`**
Add the position state + segment + setPos near the dial logic, and rewrite `onDialMove`/`onDialUp` to drive it live:
```js
let pos = 0; // continuous knob position; rest value == ringIndex
let activeSeg = null; // { lo, clipLo, clipHi, file }
let seekPending = false; // throttle: one currentTime seek per rAF
function pickPoolClip(index) {
const n = ring.scales.length;
const s = ring.scales[HEFScrub.wrapIndex(index, n)];
const pool = (s.pool && s.pool.length) ? s.pool : [{ clip_id: s.clip_id }];
return pool[Math.floor(Math.random() * pool.length)].clip_id;
}
// Build (or re-roll) the segment [lo, lo+1]. The side equal to the rested altitude
// keeps the locked clip; the other end is a fresh random pick (re-roll).
function rebuildSegment(lo, enteredFrom) {
const n = ring.scales.length;
const loId = (enteredFrom === HEFScrub.wrapIndex(lo, n)) ? activeClipId : pickPoolClip(lo);
const hiId = (enteredFrom === HEFScrub.wrapIndex(lo + 1, n)) ? activeClipId : pickPoolClip(lo + 1);
const file = morphByPair[`${loId}${hiId}`] || null;
activeSeg = { lo, clipLo: loId, clipHi: hiId, file };
if (file) {
overlay.style.opacity = "0"; affectLayer.style.opacity = "0"; tint.style.opacity = "0";
vid.style.filter = "none"; vid.loop = false; vid.style.opacity = "1";
if (vid.dataset.morph !== file) { vid.dataset.morph = file; vid.src = mediaUrl(file); vid.pause(); }
(window.__hefMorphs || (window.__hefMorphs = [])).push(file);
}
}
function setPos(next) {
if (!ring || ring.scales.length < 2) return;
const n = ring.scales.length;
// Commit every integer crossed between the old and new position.
for (const c of HEFScrub.integerCrossings(pos, next)) {
ringIndex = HEFScrub.wrapIndex(c.index, n);
// The clip at the crossed integer is whichever segment end matches it.
if (activeSeg) activeClipId = (HEFScrub.wrapIndex(activeSeg.lo, n) === ringIndex) ? activeSeg.clipLo : activeSeg.clipHi;
activeSeg = null; // force a fresh-approach re-roll for the next segment
}
pos = next;
const { lo, frac } = HEFScrub.segmentOf(pos);
if (frac === 0) { // settled on an altitude
currentClipId = null; // force ensureClipMedia to reload + loop the locked clip
update();
restAudio(ringIndex);
setNeedle(ringIndex * dialStep());
return;
}
if (!activeSeg || activeSeg.lo !== lo) rebuildSegment(lo, ringIndex);
setNeedle(pos * dialStep());
blendAudio(lo, frac);
if (activeSeg.file && !seekPending) { // throttle seeks to one per frame
seekPending = true;
requestAnimationFrame(() => {
seekPending = false;
if (vid.dataset.morph === activeSeg.file) vid.currentTime = HEFScrub.fracToTime(frac, vid.duration);
});
}
}
```
Rewrite the drag handlers to drive `pos` live (replacing the no-live-follow `onDialMove` and the commit-on-release `onDialUp`):
```js
function onDialDown(e) {
if (!ring || ring.scales.length < 2) return;
e.preventDefault();
dialDrag = { lastAng: dialAngle(e), accum: 0, moved: 0, target: e.target, restPos: ringIndex };
}
function onDialMove(e) {
if (!dialDrag) return;
const a = dialAngle(e);
dialDrag.accum += angDelta(a, dialDrag.lastAng);
dialDrag.lastAng = a;
dialDrag.moved += Math.abs(angDelta(a, dialDrag.lastAng));
setPos(HEFScrub.accumToPos(dialDrag.restPos, dialDrag.accum, dialStep())); // LIVE scrub
}
function onDialUp(e) {
if (!dialDrag) return;
const { moved, target } = dialDrag;
dialDrag = null;
if (moved < 6) { // a tap, not a turn
if (target && target.classList && target.classList.contains("dial-label")) jumpToScale(+target.getAttribute("data-index"));
return;
}
// No auto-complete: hold wherever the knob stopped (continuous-encoder model).
}
```
Keep `renderDial()` reading `pos` for the needle when mid-segment, else `ringIndex`. Retire `advance()`'s body (or leave it unused) — wheel/tap use the auto-scrub in Task 4.
- [ ] **Step 3: Run the focused E2E**
Run: `cd simulator/e2e && npx playwright test -g "scrubs morph currentTime"` (dev server running)
Expected: PASS.
- [ ] **Step 4: Run the unit suite (no regressions)**
Run: `node --test simulator/unit/scrub.test.js`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add simulator/static/app.js simulator/e2e/tests/altitude-lock.spec.ts
git commit -m "feat(sim): scrub engine — drag drives pos, seeks morph, commits+locks+re-rolls"
```
---
### Task 4: Wheel + tap become auto-scrubs, then lock
**Files:**
- Modify: `simulator/static/app.js` (`onWheel` ~681691; `jumpToScale` ~798805)
**Interfaces:**
- Consumes: `setPos`, `ringIndex`, `pos`, `HEFScrub.wrapIndex`.
- Produces:
- `autoScrub(targetPos: number, ms = 600): void` — rAF-animates `pos` from its current value to `targetPos` (calling `setPos` each frame), landing exactly on the integer target, then locks (frac 0 settles in `setPos`).
- [ ] **Step 1: Write the failing E2E (wheel auto-scrubs one altitude then locks)**
Add to the E2E spec:
```ts
test("wheel auto-scrubs one altitude and locks", async ({ page }) => {
await boot(page);
const start = (await page.locator("#scale-name").textContent())!;
await page.locator("#stage").dispatchEvent("wheel", { deltaY: 60 });
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
const landed = (await page.locator("#scale-name").textContent())!;
expect(landed).toContain("orbit");
// Mid-flight currentTime moved; after landing the morph is settled (frac 0).
await page.waitForTimeout(1500);
expect(await page.locator("#scale-name").textContent()).toBe(landed); // locked
});
```
Run: `cd simulator/e2e && npx playwright test -g "wheel auto-scrubs"`
Expected: FAIL.
- [ ] **Step 2: Implement `autoScrub` and rewire wheel + tap**
```js
let autoRaf = 0;
function autoScrub(targetPos, ms = 600) {
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;
function onWheel(e) {
e.preventDefault();
wheelAccum += e.deltaY;
clearTimeout(wheelTimer);
wheelTimer = setTimeout(() => {
const detents = Math.trunc(wheelAccum / 60) || (wheelAccum > 0 ? 1 : -1);
wheelAccum = 0;
if (detents) autoScrub(ringIndex + detents);
}, 90);
}
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) autoScrub(ringIndex + d);
}
```
- [ ] **Step 3: Run the focused E2E**
Run: `cd simulator/e2e && npx playwright test -g "wheel auto-scrubs"` (dev server running)
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add simulator/static/app.js
git commit -m "feat(sim): wheel + label-tap auto-scrub one/path of altitudes, then lock"
```
---
### Task 5: Full E2E tier for scrub-driven transitions
**Files:**
- Modify: `simulator/e2e/tests/altitude-lock.spec.ts`
**Interfaces:**
- Consumes: the running simulator, `window.__hefMorphs`, `#scale-name`, `#dial`, `video`, `#aud`/`#aud-b`.
- [ ] **Step 1: Replace the `.rev`-asserting reverse test with a scrub-reverse test**
The old "zoom back out plays a reverse morph" test asserted a `.rev.mp4` file. Under the scrub model, turn-back seeks the **same** segment morph backward. Replace it:
```ts
test("turn-back scrubs the same morph in reverse and re-locks the start clip", async ({ page }) => {
await boot(page);
const box = await page.locator("#dial").boundingBox();
const cx = box!.x + box!.width / 2, cy = box!.y + box!.height / 2;
const read = () => page.evaluate(() => ({
t: (document.querySelector("video") as HTMLVideoElement).currentTime,
ga: (document.querySelector("#aud") as HTMLAudioElement).volume,
gb: (document.querySelector("#aud-b") as HTMLAudioElement).volume,
name: document.querySelector("#scale-name")?.textContent,
}));
await page.mouse.move(cx, cy - 30);
await page.mouse.down();
await page.mouse.move(cx + 22, cy - 20, { steps: 10 }); // descend partway
const fwd = await read();
await page.mouse.move(cx + 4, cy - 30, { steps: 10 }); // turn back toward start
const back = await read();
await page.mouse.up();
expect(back.t).toBeLessThan(fwd.t); // morph seeking backward
expect(back.gb).toBeLessThan(fwd.gb); // next-scale audio receding
expect(back.name).toContain("cosmos"); // re-locked the altitude we left
});
```
- [ ] **Step 2: Add the full-turn commit+lock test**
```ts
test("a full turn commits and locks the destination clip", async ({ page }) => {
await boot(page);
const start = (await page.locator("#scale-name").textContent())!;
const box = await page.locator("#dial").boundingBox();
const cx = box!.x + box!.width / 2, cy = box!.y + box!.height / 2;
await page.mouse.move(cx, cy - 30);
await page.mouse.down();
await page.mouse.move(cx + 30, cy, { steps: 6 });
await page.mouse.move(cx, cy + 30, { steps: 6 }); // ~full detent clockwise
await page.mouse.up();
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
const landed = (await page.locator("#scale-name").textContent())!;
expect(landed).toContain("orbit");
const played = await page.evaluate(() => (window as any).__hefMorphs || []);
expect(played[0]).toMatch(/^transitions\/cosmos.*\.mp4$/);
await page.waitForTimeout(2000);
expect(await page.locator("#scale-name").textContent()).toBe(landed); // locked
});
```
- [ ] **Step 3: Keep the wheel + drag tests from Tasks 24; run the whole tier**
Run: `cd simulator/e2e && npx playwright test`
Expected: PASS — all scrub tests green (drag-scrub, turn-back, full-turn, wheel, two-audio-elements).
- [ ] **Step 4: Commit**
```bash
git add simulator/e2e/tests/altitude-lock.spec.ts
git commit -m "test(e2e): scrub tier asserts currentTime + audio gains track the dial, reverse, commit-lock, wheel"
```
---
### Task 6: Phase-1 localhost verification
**Files:** none (verification task).
- [ ] **Step 1: Run the unit suite**
Run: `node --test simulator/unit/`
Expected: PASS.
- [ ] **Step 2: Run the full Python test suite (no regressions)**
Run: `python -m pytest -q`
Expected: PASS (existing tally; no new failures).
- [ ] **Step 3: Boot the simulator and run E2E against it**
Run (server): start the simulator dev server per `simulator/` README on its configured port with the venv python.
Run (tests): `cd simulator/e2e && npx playwright test`
Expected: all PASS.
- [ ] **Step 4: Eyeball the feel**
Confirm by hand: slow drag eases the morph slowly; fast drag races it; stopping mid-drag holds a blended frame with mixed audio; turning back reverses; the wheel auto-scrubs one altitude and locks; a label tap travels the shortest way. Note any steppiness (expected on sparse-GOP morphs — Phase 2 fixes it).
- [ ] **Step 5: Checkpoint the transcript**
Re-publish the in-progress transcript (no commit needed beyond what tasks already pushed).
---
## Phase 2 — All-intra morph re-bake for smooth seeking
### Task 7: Re-bake the 154 morphs all-intra (dense keyframes)
**Files:**
- Modify: `simulator/build_pool_manifest.py` (`_make_transition` ~402417, `_make_reverse` ~420429)
- Test: `tests/test_build_pool_manifest.py`
**Interfaces:**
- Produces: every baked morph encoded all-intra (each frame a keyframe) so arbitrary-frame seeking is smooth.
- [ ] **Step 1: Write the failing arg-builder test**
`_make_transition`/`_make_reverse` currently call `subprocess.run` directly, so factor the ffmpeg command into a pure builder to test it. Add to `simulator/build_pool_manifest.py`:
```python
ALL_INTRA = ["-c:v", "libx264", "-g", "1", "-keyint_min", "1", "-sc_threshold", "0", "-pix_fmt", "yuv420p"]
```
Add a pure helper and a test. In `tests/test_build_pool_manifest.py`:
```python
from simulator import build_pool_manifest as B
def test_transition_cmd_is_all_intra():
cmd = B.transition_cmd("ffmpeg", "/a/base.mp4", "/b/base.mp4", "/out/x__y.mp4")
assert "-g" in cmd and cmd[cmd.index("-g") + 1] == "1"
assert "-keyint_min" in cmd and cmd[cmd.index("-keyint_min") + 1] == "1"
assert "-sc_threshold" in cmd and cmd[cmd.index("-sc_threshold") + 1] == "0"
def test_reverse_cmd_is_all_intra():
cmd = B.reverse_cmd("ffmpeg", "/out/x__y.mp4", "/out/x__y.rev.mp4")
assert "-g" in cmd and cmd[cmd.index("-g") + 1] == "1"
assert "reverse" in " ".join(cmd)
```
Run: `python -m pytest tests/test_build_pool_manifest.py -q`
Expected: FAIL — `transition_cmd`/`reverse_cmd` not defined.
- [ ] **Step 2: Extract `transition_cmd`/`reverse_cmd` and apply all-intra flags**
```python
def transition_cmd(ff, a, b, out):
norm = "trim=0:3,setpts=PTS-STARTPTS,scale=1280:720,fps=25,setsar=1,format=yuv420p"
return [
ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex",
f"[0:v]{norm}[a];[1:v]{norm}[b];"
"[a][b]xfade=transition=zoomin:duration=1.5:offset=0.75,format=yuv420p[v]",
"-map", "[v]", "-an", *ALL_INTRA, str(out),
]
def reverse_cmd(ff, forward, out):
return [ff, "-y", "-i", str(forward), "-vf", "reverse", "-an", *ALL_INTRA, str(out)]
```
Then make `_make_transition`/`_make_reverse` call these builders inside `subprocess.run(..., check=True, capture_output=True)`.
- [ ] **Step 3: Run the arg-builder test**
Run: `python -m pytest tests/test_build_pool_manifest.py -q`
Expected: PASS.
- [ ] **Step 4: Regenerate the morph media**
Run: `python simulator/build_pool_manifest.py` (or its documented `generate_media` entrypoint) to re-bake all 154 morphs all-intra. Confirm files regrew (sparse → dense keyframes; expect ~0.50.9 GB total, each clip well under the 25 MB LFS/proxy ceiling noted in memory).
- [ ] **Step 5: Commit the re-baked media via git-LFS**
```bash
git add simulator/sample_media/transitions
git commit -m "feat(pipeline): re-bake 154 morphs all-intra for smooth scrub seeking (LFS)"
```
Confirm they are LFS pointers: `git lfs ls-files | head`.
---
### Task 8: Phase-2 verification — smooth scrub on dense keyframes
**Files:** none (verification task).
- [ ] **Step 1: Re-run the unit + E2E suites against re-baked media**
Run: `node --test simulator/unit/` → PASS.
Run (server up): `cd simulator/e2e && npx playwright test` → PASS.
- [ ] **Step 2: Eyeball smoothness**
Confirm the scrub now seeks smoothly frame-to-frame (no stepping) on a slow drag across a full segment.
- [ ] **Step 3: Push the branch**
```bash
git push -u origin session-0026
```
---
## Ship via §9 (execution, not a code task)
After Phase 1 + Phase 2 are green on localhost (unit + E2E), ship through the mandatory pipeline:
1. **localhost + E2E green** (Tasks 6 + 8).
2. **PPE deploy + E2E green** (flotilla; provision PPE if it doesn't exist yet). Stamp the `release/<YYYY-MM-DDTHH-MM>` tag.
3. **prod is human-gated** — the operator promotes the validated tag; the session stops at PPE-green.
---
## Self-Review
**Spec coverage:**
- Core model (continuous `pos`, frac drives currentTime + audio) → Tasks 1, 3, 2.
- In-between hold / no auto-complete → Task 3 (`onDialUp` holds).
- Turn-back reversible / re-lock start clip → Tasks 3, 5.
- Destination randomness fixed-per-gesture, re-roll on fresh approach → Task 3 (`rebuildSegment`, re-roll on `activeSeg` reset at crossing).
- Scroll wheel auto-scrub then lock; tap shortest way → Task 4.
- Lock-per-altitude preserved → Task 3 (`commit` sets `activeClipId`; frac 0 settles base loop).
- Audio crossfade two adjacent soundtracks by frac → Task 2.
- All-intra re-bake (154 morphs, LFS) → Task 7.
- Unit tests (position→segment, frac→time, commit on crossing, re-roll, reverse) → Task 1 (pure) + E2E for glue.
- Playwright E2E (currentTime + gains track angle, reverse, full-turn commit-lock, wheel) → Task 5.
- Phasing (interaction first, re-bake second) → Phase 1 then Phase 2.
**Placeholder scan:** none — every code step carries real code and exact commands.
**Type consistency:** `setPos`, `rebuildSegment`, `activeSeg {lo,clipLo,clipHi,file}`, `pickPoolClip`, `blendAudio(loIndex,frac)`, `restAudio(index)`, `scaleAudioUrl(index)`, `autoScrub(targetPos,ms)`, `HEFScrub.*` names are used consistently across Tasks 15. `transition_cmd`/`reverse_cmd`/`ALL_INTRA` consistent in Tasks 7.
**Open interpretation (logged as deferred decision):** the canonical-segment-file choice (always the descend/forward morph, scrubbed bidirectionally; `.rev` unused by scrub) reinterprets the spec's literal `morphByPair["<from>→<to>"]` directed lookup in favor of its "scrub the same morph in reverse" + full-reversibility requirements. Surfaced for operator awareness at finalize.