Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70fc9a46ab | |||
| f2f242a411 | |||
| 31a3fd733f | |||
| 8458ab59eb | |||
| 20f49e936a | |||
| 0548e9a82b | |||
| 3dc3e3491f |
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user