diff --git a/scripts/motion-frames.ts b/scripts/motion-frames.ts new file mode 100644 index 0000000..25b8783 --- /dev/null +++ b/scripts/motion-frames.ts @@ -0,0 +1,99 @@ +// Captures before / mid / after frames of each navigation motion from a running server, for visual review. +// Animations are slowed 10x through the DevTools Animation domain (view transitions included), so a screenshot +// taken a few hundred ms in lands mid-transition. A second pass with prefers-reduced-motion shows the same +// moment with no motion. Uses the sample course; sign-up creates a throwaway account on that server. +// +// bun scripts/motion-frames.ts http://localhost:3491 /tmp/frames +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { Chrome } from "../packages/feature-learner/test/chrome"; + +const [base = "http://localhost:3491", out = "motion-frames"] = process.argv.slice(2); +const SLOW = 0.1; +mkdirSync(out, { recursive: true }); +let dir = out; + +const chrome = await Chrome.launch(); +await chrome.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false }); +await chrome.send("Animation.enable"); + +const js = (s: string) => JSON.stringify(s); +async function until(cond: string, ms = 8000) { + const end = Date.now() + ms; + while (!(await chrome.eval(cond).catch(() => false))) { + if (Date.now() > end) throw new Error(`timed out: ${cond}`); + await Bun.sleep(30); + } +} +async function shot(name: string) { + const r = await chrome.send("Page.captureScreenshot", { format: "png" }); + writeFileSync(join(dir, `${name}.png`), Buffer.from(r.result.data, "base64")); +} +async function open(path: string, ready: string) { + await chrome.goto(`${base}${path}`, 100); + await until(`location.pathname === ${js(path)} && !!document.querySelector(${js(ready)})`); + await Bun.sleep(500); +} +/** Runs `action` (page JS), then captures frames at the given real-time offsets (ms). */ +async function frames(name: string, action: string, at: number[]) { + await shot(`${name}-0-before`); + const t0 = Date.now(); + await chrome.eval(`(${action}, true)`); + for (const [i, t] of at.entries()) { + await Bun.sleep(Math.max(0, t - (Date.now() - t0))); + await shot(`${name}-${i + 1}-${t}ms`); + } +} + +const flows: [string, () => Promise][] = [ + ["01-library-to-course", async () => { + await open("/", 'a[href="/c/sample"]'); + await frames("01-library-to-course", `document.querySelector('a[href="/c/sample"]').click()`, [300, 700, 2500]); + }], + ["02-course-to-lesson", async () => { + await open("/c/sample", ".lr-main > article h1"); + await frames("02-course-to-lesson", `document.querySelector('.lr-les[href="/c/sample/m1-code"]').click()`, [300, 700, 2500]); + }], + ["03-lesson-next", async () => { + await open("/c/sample/m1-code", ".lr-main .lr-lesson h1.title"); + await frames("03-lesson-next", `document.querySelector(".stc-dock .stc-pill--primary").click()`, [300, 700, 2500]); + }], + ["04-module-open", async () => { + await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title"); + await frames("04-module-open", `[...document.querySelectorAll(".lr-mod-head")][1].click()`, [500, 2500]); + }], + ["05-fold-open", async () => { + await open("/c/sample/m1-video", "details.fold.transcript"); + await chrome.eval(`document.querySelector("details.fold.transcript").scrollIntoView({ block: "center" })`); + // ::details-content transitions are not slowed by the Animation domain; sample real time instead. + await frames("05-fold-open", `document.querySelector("details.fold.transcript > summary").click()`, [80, 140, 1000]); + }], + ["06-slide-next", async () => { + await open("/c/sample/m1-video", '.stage canvas[data-page="1"]'); + await chrome.eval(`document.querySelector(".deck").scrollIntoView({ block: "center" })`); + await Bun.sleep(300); + await frames("06-slide-next", `document.querySelector('.deckbar [aria-label="Next slide"]').click()`, [600, 2500]); + }], + ["07-menu", async () => { + await open("/c/sample/m1-intro", ".lr-switch-btn"); + await frames("07-menu", `document.querySelector(".lr-switch-btn").click()`, [500, 2000]); + }], + ["08-toast", async () => { + await open("/c/sample/m2-ex-add", ".stc-seg button"); + await frames("08-toast", `[...document.querySelectorAll(".stc-seg button")].find((b) => b.textContent === "Reading").click()`, [500, 2000]); + }], +]; + +try { + await chrome.send("Animation.setPlaybackRate", { playbackRate: SLOW }); + for (const [, run] of flows) await run(); + // The same moments with reduced motion: the end state at once. + await chrome.send("Animation.setPlaybackRate", { playbackRate: 1 }); + await chrome.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: "reduce" }] }); + dir = join(out, "reduced"); + mkdirSync(dir, { recursive: true }); + for (const [name, run] of flows) await run().catch((e) => console.error(name, e)); +} finally { + await chrome.close(); +} +console.log(`frames written to ${out}`);