// Run against a local dev server. Requires Playwright, Chrome and FFmpeg. // PLAYWRIGHT_MODULE may point to an existing Playwright index.mjs. // FFMPEG may point to an existing ffmpeg executable. import { mkdir, writeFile } from "node:fs/promises"; import { spawnSync } from "node:child_process"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; const { chromium } = await import( process.env.PLAYWRIGHT_MODULE ? pathToFileURL(resolve(process.env.PLAYWRIGHT_MODULE)).href : "playwright" ); const ffmpeg = process.env.FFMPEG || "ffmpeg"; const base = process.env.CAPTURE_URL || "http://127.0.0.1:5186"; const output = resolve("docs/media"); const temporary = resolve(".tools/readme-capture"); await mkdir(output, { recursive: true }); await mkdir(temporary, { recursive: true }); const browser = await chromium.launch({ channel: "chrome", headless: true, args: process.platform === "win32" ? ["--use-angle=d3d11", "--enable-gpu", "--ignore-gpu-blocklist"] : ["--enable-gpu"], }); const context = await browser.newContext({ viewport: { width: 1600, height: 900 }, deviceScaleFactor: 1, }); const page = await context.newPage(); const errors = []; page.on("pageerror", (error) => errors.push(error.message)); page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); const session = await context.newCDPSession(page); const manifest = { commit: spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8", }).stdout.trim(), capturedAt: new Date().toISOString(), viewport: "1600x900", quality: "original", gifs: [], screenshots: [], errors, }; const wait = (ms) => page.waitForTimeout(ms); const click = (selector) => page.locator(selector).click(); async function shot(name) { await page.mouse.move(1580, 880); await page.screenshot({ path: resolve(output, `${name}.jpg`), type: "jpeg", quality: 93, }); manifest.screenshots.push(name); } function encode(args) { const result = spawnSync( ffmpeg, ["-y", "-hide_banner", "-loglevel", "error", ...args], { encoding: "utf8" }, ); if (result.status !== 0) throw new Error(result.stderr || `FFmpeg failed: ${result.error}`); } async function record(name, actions, width = 800) { const fps = name === "browse" ? 8 : 12; if (name === "browse") width = 640; const folder = resolve(temporary, `${name}-${Date.now()}`); await mkdir(folder, { recursive: true }); const frames = []; const writes = []; const onFrame = (event) => { const filename = `frame-${String(frames.length).padStart(5, "0")}.jpg`; frames.push({ filename, timestamp: event.metadata.timestamp }); writes.push( writeFile(resolve(folder, filename), Buffer.from(event.data, "base64")), ); void session .send("Page.screencastFrameAck", { sessionId: event.sessionId }) .catch(() => {}); }; session.on("Page.screencastFrame", onFrame); await session.send("Page.startScreencast", { format: "jpeg", quality: 92, maxWidth: 1600, maxHeight: 900, everyNthFrame: 3, }); try { await wait(250); await actions(); } finally { await session.send("Page.stopScreencast"); session.off("Page.screencastFrame", onFrame); await Promise.all(writes); } if (frames.length < 10) throw new Error(`Too few frames for ${name}`); const intervals = frames .slice(1) .map((frame, i) => frame.timestamp - frames[i].timestamp); const duration = frames.at(-1).timestamp - frames[0].timestamp; await writeFile( resolve(folder, "frames.txt"), frames .map( (frame, i) => `file '${frame.filename}'\nduration ${intervals[i] || 1 / 12}\n`, ) .join("") + `file '${frames.at(-1).filename}'\n`, ); encode([ "-f", "concat", "-safe", "0", "-i", resolve(folder, "frames.txt"), "-vf", `fps=${fps},scale=${width}:-2:flags=lanczos,split[a][b];[a]palettegen=max_colors=96:stats_mode=full[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`, "-loop", "0", resolve(output, `${name}.gif`), ]); manifest.gifs.push({ name, frames: frames.length, duration, width, fps, averageCaptureFps: frames.length / duration, maximumFrameGap: Math.max(...intervals), }); console.log( `Captured ${name}: ${duration.toFixed(2)}s, ${frames.length} source frames`, ); } try { await page.goto(`${base}/?scene=archive`); await page.waitForFunction( () => window.rhine?.stats().ready && !document.querySelector("#loading"), null, { timeout: 60000 }, ); await page.evaluate(() => document.fonts.ready); await wait(6000); await shot("archive"); await record("browse", async () => { for (const key of [ "ArrowDown", "ArrowDown", "ArrowRight", "ArrowDown", "ArrowRight", "ArrowLeft", "ArrowLeft", ]) { await page.keyboard.press(key); await wait(700); } await wait(1000); }); await page.evaluate(() => window.rhine.select(0)); await wait(1600); await record( "decryption", async () => { await page.keyboard.press("Enter"); await wait(7500); }, 960, ); await page.waitForFunction( () => window.rhine.stats().decryption.phase === "clear", ); await shot("detail"); await click('[data-tab="notes"]'); await wait(450); await shot("research"); await click('[data-action="model-viewer"]'); await page.waitForFunction( () => document.querySelector('[data-viewer="explode"]')?.disabled === false, ); await wait(1700); await shot("viewer-clear"); await record("glass-motion", async () => { await click('[data-viewer="frosted"]'); await wait(1700); await shot("viewer-frosted"); await click('[data-viewer="clear"]'); await wait(1700); }); await record("assembly-motion", async () => { await click('[data-viewer="explode"]'); await wait(1800); await shot("assembly"); await page.mouse.move(850, 460); await page.mouse.down(); for (let i = 1; i <= 30; i++) { await page.mouse.move(850 + i * 6, 460 + i); await wait(25); } await page.mouse.up(); await wait(600); await click('[data-viewer="reset"]'); await wait(850); await click('[data-viewer="assemble"]'); await wait(1800); }); await click('[data-viewer="close"]'); await wait(450); await page.keyboard.press("Escape"); await wait(1800); await click('[data-action="search"]'); await wait(400); await page.locator("#archive-search").fill("莱茵"); await wait(500); await shot("search"); await page.keyboard.press("Escape"); await wait(350); await click('[data-action="settings"]'); await wait(400); await shot("settings"); await page.keyboard.press("Escape"); await wait(350); await record("boot-motion", async () => { await click('[data-action="replay"]'); await wait(6500); await shot("boot"); await wait(13000); }); manifest.stats = await page.evaluate(() => window.rhine.stats()); await writeFile( resolve(temporary, "manifest.json"), JSON.stringify(manifest, null, 2), ); if (errors.length) throw new Error(errors.join("\n")); const { stats, ...publicManifest } = manifest; await writeFile( resolve(output, "capture.json"), JSON.stringify(publicManifest, null, 2) + "\n", ); } finally { await browser.close(); }