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:
@@ -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 950–1045)
|
||||
- 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 ~1019–1045). 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` ~766–795; `advance` ~640–679; `playTransition` ~595–636; needle/render ~739–751)
|
||||
- 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` ~681–691; `jumpToScale` ~798–805)
|
||||
|
||||
**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 2–4; 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` ~402–417, `_make_reverse` ~420–429)
|
||||
- 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.5–0.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 1–5. `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.
|
||||
Reference in New Issue
Block a user