Compare commits

...

7 Commits

Author SHA1 Message Date
BenStullsBets 70fc9a46ab feat(pipeline): re-bake 154 morphs all-intra for smooth scrub seeking (LFS)
Per plan Task 7. Every frame now a keyframe (verified 93/93 I-frames on a sample),
so the scrub interaction seeks to arbitrary frames smoothly. Total transitions
173M -> 504M; largest clip ~5.8M (well under the 25MB LFS/proxy ceiling).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:51:53 -07:00
BenStullsBets f2f242a411 feat(pipeline): all-intra ffmpeg builders for seekable morph re-bake
Per plan Task 7. Extracts transition_cmd/reverse_cmd argv builders and adds the
ALL_INTRA flag set (-g 1 -keyint_min 1 -sc_threshold 0) so every baked frame is a
keyframe — smooth arbitrary-frame seeking for the scrub interaction. Unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:50:31 -07:00
BenStullsBets 31a3fd733f test(e2e): scrub tier — currentTime + gains track dial, reverse, commit-lock, wheel
Per plan Task 5. Replaces the .rev-asserting zoom-out test with a turn-back test
(same forward morph seeked backward, audio recedes, re-locks the start clip); adds
a detent-crossing commit+lock test via a new window.__hefState diagnostic seam.
Full tier (6 tests) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:47:25 -07:00
BenStullsBets 8458ab59eb feat(sim): wheel + label-tap auto-scrub then lock; retire discrete advance()
Per plan Task 4. onWheel + jumpToScale now animate pos via autoScrub() (drives the
same setPos scrub path, ~600ms, lands exactly on the integer + locks). Removes the
now-dead advance()/playTransition()/FAST_BLEND_RATE (the server /api/ring/advance
round-trip is no longer used by the client). Post-landing morph warm-up folded into
setPos's settle branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:45:37 -07:00
BenStullsBets 20f49e936a feat(sim): scrub engine — drag drives pos, seeks morph, commits+locks+re-rolls
Per plan Task 3. A continuous float pos is the knob: setPos() seeks the segment's
morph currentTime by frac (throttled one seek/rAF), crossfades audio, sweeps the
needle live, and commits+locks+re-rolls on each integer crossing. Drag live-scrubs
(no more commit-on-release); release holds the blend (no auto-complete). Client-side
pool pick replaces the server round-trip for drag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:43:48 -07:00
BenStullsBets 0548e9a82b feat(sim): two-element audio crossfade keyed by scrub fraction
Per plan Task 2. Adds <audio id=aud-b>; blendAudio(lo,frac) mixes the two
adjacent scale soundtracks by knob fraction (element reuse across crossings so
only the genuinely-new scale reloads); restAudio settles a single scale at rest.
Diagnostic readout now reports whichever element is audible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:40:04 -07:00
BenStullsBets 3dc3e3491f feat(sim): pure altitude-scrub math module + node unit suite
Per plan Task 1. position->segment, frac->currentTime, audio gains, and
integer-crossing detection; 9 node --test cases green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:37:27 -07:00
162 changed files with 751 additions and 480 deletions
+24 -10
View File
@@ -399,6 +399,28 @@ def _adjacent_edges() -> list[tuple[str, str]]:
return [(RING_ORDER[i], RING_ORDER[(i + 1) % n]) for i in range(n)]
# All-intra H.264: every frame a keyframe, so the scrub interaction can seek to an
# arbitrary frame smoothly (a sparse GOP scrubs "steppy"). Files grow, but each clip
# stays well under the LFS/proxy ceiling. (Scrub-driven-transitions design §4.)
ALL_INTRA = ["-c:v", "libx264", "-g", "1", "-keyint_min", "1", "-sc_threshold", "0", "-pix_fmt", "yuv420p"]
def transition_cmd(ff: str, a: str, b: str, out: str) -> list[str]:
"""The ffmpeg argv for a forward (zoom-in) member-pair morph, baked all-intra."""
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: str, forward: str, out: str) -> list[str]:
"""The ffmpeg argv for the zoom-OUT companion (forward played backward), all-intra."""
return [ff, "-y", "-i", str(forward), "-vf", "reverse", "-an", *ALL_INTRA, str(out)]
def _make_transition(ff: str, src_clip: str, dst_clip: str) -> Path:
"""A zoom/warp morph between two CLIP MEMBERS' base footage (the well-liked
orbit-coast recipe), keyed by clip id so every adjacent member pair gets its
@@ -407,13 +429,7 @@ def _make_transition(ff: str, src_clip: str, dst_clip: str) -> Path:
b = MEDIA / dst_clip / "base.mp4"
out = MEDIA / "transitions" / f"{src_clip}__{dst_clip}.mp4"
out.parent.mkdir(parents=True, exist_ok=True)
norm = "trim=0:3,setpts=PTS-STARTPTS,scale=1280:720,fps=25,setsar=1,format=yuv420p"
subprocess.run([
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", str(out),
], check=True, capture_output=True)
subprocess.run(transition_cmd(ff, str(a), str(b), str(out)), check=True, capture_output=True)
return out
@@ -423,9 +439,7 @@ def _make_reverse(ff: str, forward: Path) -> Path:
crosses its edge `reversed`; the renderer then plays this file, so zooming out
recedes from the current scale back to the higher one instead of zooming in."""
out = forward.with_suffix(".rev.mp4")
subprocess.run([
ff, "-y", "-i", str(forward), "-vf", "reverse", "-an", str(out),
], check=True, capture_output=True)
subprocess.run(reverse_cmd(ff, str(forward), str(out)), check=True, capture_output=True)
return out
+97 -27
View File
@@ -29,6 +29,54 @@ async function wheelOnStage(page: Page, deltaY: number) {
await page.locator("#stage").dispatchEvent("wheel", { deltaY });
}
// The Audio toggle is a visually-hidden checkbox styled as a switch; set it + fire
// `change` directly (headless Chromium relaxes the autoplay gesture requirement).
async function enableAudio(page: Page) {
await page.evaluate(() => {
const c = document.querySelector("#audio") as HTMLInputElement;
c.checked = true;
c.dispatchEvent(new Event("change", { bubbles: true }));
});
}
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);
});
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
await boot(page);
await enableAudio(page); // hidden custom toggle → set + dispatch change
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 clockwise turn (~0.5 detent)
// The currentTime seek is throttled to one rAF; wait for it to land.
await page.waitForFunction(() => (document.querySelector("video") as HTMLVideoElement).currentTime > 0, null, { timeout: 5000 });
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 fading in
expect(state.ga).toBeLessThan(1); // current scale fading out
await page.mouse.up();
});
test("wheel auto-scrubs one altitude and locks", async ({ page }) => {
await boot(page);
const start = (await page.locator("#scale-name").textContent())!;
await wheelOnStage(page, 60); // wheel down = descend one altitude
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");
await page.waitForTimeout(1500);
expect(await page.locator("#scale-name").textContent()).toBe(landed); // locked
});
test("zoom in plays the matching morph, lands locked, and stays put while parked", async ({ page }) => {
await boot(page);
const start = (await page.locator("#scale-name").textContent())!;
@@ -57,35 +105,57 @@ test("zoom in plays the matching morph, lands locked, and stays put while parked
expect((await morphs(page)).length).toBe(countAfterLanding); // no extra swap
});
test("zoom back out plays a reverse morph and lands locked", async ({ page }) => {
test("turn-back scrubs the same morph in reverse and re-locks the start clip", async ({ page }) => {
await boot(page);
const start = (await page.locator("#scale-name").textContent())!;
await enableAudio(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,
morphs: ((window as any).__hefMorphs || []).slice(),
}));
// Zoom in one detent to get off cosmos (-> orbit)...
await wheelOnStage(page, 60);
await page.waitForFunction(
(s) => document.querySelector("#scale-name")?.textContent !== s,
start,
{ timeout: 20_000 },
);
const atOrbit = (await page.locator("#scale-name").textContent())!;
expect(atOrbit).toContain("orbit");
await page.mouse.move(cx, cy - 30);
await page.mouse.down();
await page.mouse.move(cx + 22, cy - 20, { steps: 10 }); // descend partway into the segment
await page.waitForFunction(() => (document.querySelector("video") as HTMLVideoElement).currentTime > 0, null, { timeout: 5000 });
const fwd = await read();
// The single segment morph is the descend/forward file (cosmos__orbit), NOT a `.rev`.
expect(fwd.morphs[fwd.morphs.length - 1]).toMatch(/^transitions\/cosmos.*\.mp4$/);
expect(fwd.morphs[fwd.morphs.length - 1]).not.toMatch(/\.rev\.mp4$/);
// ...then zoom back OUT to cosmos.
await wheelOnStage(page, -60);
await page.waitForFunction(
(s) => document.querySelector("#scale-name")?.textContent !== s,
atOrbit,
{ timeout: 20_000 },
);
await page.mouse.move(cx + 4, cy - 30, { steps: 10 }); // turn back toward the start
await page.waitForTimeout(120); // let the throttled rAF seek land
const back = await read();
await page.mouse.up();
const played = await morphs(page);
// The most recent morph is the zoom-out: a `.rev` companion. Its file is named
// for the descending pair (cosmos<...>__orbit<...>), so it starts "cosmos".
expect(played[played.length - 1]).toMatch(/^transitions\/cosmos.*\.rev\.mp4$/);
const landed = (await page.locator("#scale-name").textContent())!;
expect(landed).toContain("cosmos");
await page.waitForTimeout(2500);
expect(await page.locator("#scale-name").textContent()).toBe(landed); // locked
expect(back.t).toBeLessThan(fwd.t); // SAME morph file, seeking backward
expect(back.gb).toBeLessThan(fwd.gb); // next-scale audio receding
expect(back.name).toContain("cosmos"); // re-locked the altitude we left
});
test("crossing a detent commits and locks the destination 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;
// Drag clockwise across one full detent (top -> right ~= 90deg = 1.25 detents),
// crossing integer 1 (orbit) so it commits, then hold past it (no auto-complete).
await page.mouse.move(cx, cy - 30);
await page.mouse.down();
await page.mouse.move(cx + 30, cy + 6, { steps: 14 });
await page.waitForFunction(() => (window as any).__hefState().ringIndex === 1, null, { timeout: 5000 });
const st = await page.evaluate(() => (window as any).__hefState());
expect(st.ringIndex).toBe(1); // committed to orbit
expect(String(st.activeClipId)).toMatch(/^orbit/); // locked the destination pool pick
const played = await page.evaluate(() => (window as any).__hefMorphs || []);
expect(played[0]).toMatch(/^transitions\/cosmos.*\.mp4$/);
expect(played[0]).not.toMatch(/\.rev\.mp4$/);
// Hold: the committed clip does not change while parked mid-gesture.
await page.waitForTimeout(800);
const st2 = await page.evaluate(() => (window as any).__hefState());
expect(st2.activeClipId).toBe(st.activeClipId);
await page.mouse.up();
});
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More