mirror of
https://github.com/tabler/tabler.git
synced 2026-10-13 21:57:00 +04:00
350 lines
15 KiB
TypeScript
350 lines
15 KiB
TypeScript
// Records a short video of a screenshots page: `pnpm run record button`.
|
|
//
|
|
// Serves the built screenshots/dist via `astro preview` (like capture.ts),
|
|
// opens <slug>?motion (the layout keeps transitions and animations under that
|
|
// flag), plays the steps from steps/<slug>.json with a fake cursor, and grabs
|
|
// the frames through the DevTools screencast. The viewport is the canvas
|
|
// itself, zoomed to @2x, so a frame is the picture and nothing else. Frames
|
|
// come only when pixels change, so they are resampled to a constant frame rate
|
|
// and piped as MJPEG into Playwright's bundled ffmpeg, which writes a WebM
|
|
// (VP8). A system ffmpeg, when present, also writes an MP4 and a GIF.
|
|
//
|
|
// Steps: { "move": selector, "duration"?: ms } glides the cursor to the element
|
|
// (the duration follows the distance when left out),
|
|
// { "click": selector } moves there and clicks, { "click": true } clicks where the
|
|
// cursor is, { "wait": ms } holds.
|
|
|
|
import { chromium, type CDPSession, type Page } from 'playwright'
|
|
import { execFileSync, spawn } from 'node:child_process'
|
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
const root = path.join(__dirname, '..')
|
|
const distDir = path.join(root, 'dist')
|
|
const stepsDir = path.join(root, 'steps')
|
|
const outDir = path.join(root, 'captures')
|
|
|
|
const PORT = Number(process.env.CAPTURE_PORT ?? 4021)
|
|
const baseUrl = `http://localhost:${PORT}`
|
|
const FPS = 30
|
|
const SCALE = 2
|
|
|
|
type Step = { move: string; duration?: number } | { click: string | true } | { wait: number }
|
|
|
|
type Frame = { data: Buffer; time: number }
|
|
|
|
type Point = { x: number; y: number }
|
|
|
|
type RecordWindow = Window & { __recordMove: (from: Point, to: Point, ms: number) => Promise<void> }
|
|
|
|
// Runs in the page. Kept as text: tsx wraps named functions passed to
|
|
// page.evaluate() in its own helper, which does not exist in the browser.
|
|
const cursorScript = (scale: number) => `
|
|
window.__recordMove = (from, to, ms) => new Promise((resolve) => {
|
|
const el = document.getElementById('record-cursor')
|
|
// control point off the straight line, so the path bends like a hand would
|
|
const dx = to.x - from.x
|
|
const dy = to.y - from.y
|
|
const len = Math.hypot(dx, dy) || 1
|
|
const bend = Math.min(0.18 * len, 60) * (dx >= 0 ? 1 : -1)
|
|
const cx = (from.x + to.x) / 2 - (dy / len) * bend
|
|
const cy = (from.y + to.y) / 2 + (dx / len) * bend
|
|
const start = performance.now()
|
|
const frame = (now) => {
|
|
const t = ms > 0 ? Math.min(1, (now - start) / ms) : 1
|
|
const e = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
|
|
const x = (1 - e) * (1 - e) * from.x + 2 * (1 - e) * e * cx + e * e * to.x
|
|
const y = (1 - e) * (1 - e) * from.y + 2 * (1 - e) * e * cy + e * e * to.y
|
|
el.dataset.x = String(x)
|
|
el.dataset.y = String(y)
|
|
// viewport px → CSS px of the zoomed document
|
|
el.style.transform = 'translate(' + (x / ${scale} - 2) + 'px, ' + (y / ${scale} - 2) + 'px)'
|
|
if (t < 1) requestAnimationFrame(frame)
|
|
else resolve()
|
|
}
|
|
frame(start)
|
|
})
|
|
`
|
|
|
|
function discoverSlugs(): string[] {
|
|
if (!existsSync(distDir)) {
|
|
console.error(`No build found at ${path.relative(process.cwd(), distDir)} — run \`astro build\` first (or use \`pnpm run record\`).`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const filter = process.argv.slice(2)
|
|
return readdirSync(stepsDir)
|
|
.filter((file) => file.endsWith('.json'))
|
|
.map((file) => file.replace(/\.json$/, ''))
|
|
.filter((slug) => filter.length === 0 || filter.includes(slug))
|
|
.sort()
|
|
}
|
|
|
|
const astroBin = path.join(root, 'node_modules/.bin/astro')
|
|
|
|
function runAstro(args: string[]): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(astroBin, args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
child.stderr?.on('data', (data) => process.stderr.write(data))
|
|
child.once('error', reject)
|
|
child.once('exit', (code) => {
|
|
if (code === null || code === 0) resolve()
|
|
else reject(new Error(`astro ${args.join(' ')} exited with code ${code}`))
|
|
})
|
|
})
|
|
}
|
|
|
|
async function startPreviewServer(): Promise<void> {
|
|
await runAstro(['preview', '--background', '--port', String(PORT)])
|
|
|
|
const deadline = Date.now() + 20_000
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(baseUrl, { method: 'HEAD' })
|
|
if (response.ok || response.status === 404) return
|
|
} catch {
|
|
// not listening yet
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
}
|
|
|
|
throw new Error(`astro preview didn't come up on port ${PORT} within 20s`)
|
|
}
|
|
|
|
const stopPreviewServer = () => runAstro(['preview', 'stop']).catch(() => {})
|
|
|
|
// Playwright ships its own ffmpeg for video recording; it encodes VP8/WebM only.
|
|
function bundledFfmpeg(): string {
|
|
const cache = process.env.PLAYWRIGHT_BROWSERS_PATH ?? path.join(os.homedir(), process.platform === 'darwin' ? 'Library/Caches/ms-playwright' : '.cache/ms-playwright')
|
|
const dir = readdirSync(cache)
|
|
.filter((name) => name.startsWith('ffmpeg-'))
|
|
.sort()
|
|
.at(-1)
|
|
if (!dir) throw new Error(`No ffmpeg in ${cache} — run \`npx playwright install ffmpeg\``)
|
|
const bin = readdirSync(path.join(cache, dir)).find((name) => name.startsWith('ffmpeg-') && !name.includes('.'))
|
|
if (!bin) throw new Error(`No ffmpeg binary in ${path.join(cache, dir)}`)
|
|
return path.join(cache, dir, bin)
|
|
}
|
|
|
|
function systemFfmpeg(): string | undefined {
|
|
try {
|
|
return (
|
|
execFileSync(process.platform === 'win32' ? 'where' : 'which', ['ffmpeg'], { encoding: 'utf8' })
|
|
.trim()
|
|
.split('\n')[0] || undefined
|
|
)
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
// Frames arrive whenever the screen changes; the video needs one every 1/FPS s.
|
|
function resample(frames: Frame[], end: number): Buffer[] {
|
|
const out: Buffer[] = []
|
|
let index = 0
|
|
for (let t = frames[0].time; t <= end; t += 1 / FPS) {
|
|
while (index + 1 < frames.length && frames[index + 1].time <= t) index++
|
|
out.push(frames[index].data)
|
|
}
|
|
return out
|
|
}
|
|
|
|
function encode(ffmpeg: string, frames: Buffer[], args: string[], output: string): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(ffmpeg, ['-y', '-loglevel', 'error', '-f', 'image2pipe', '-c:v', 'mjpeg', '-framerate', String(FPS), '-i', 'pipe:0', ...args, output], { stdio: ['pipe', 'inherit', 'inherit'] })
|
|
child.once('error', reject)
|
|
child.stdin.on('error', reject)
|
|
child.once('exit', (code) => (code === 0 ? resolve() : reject(new Error(`ffmpeg exited with code ${code} for ${output}`))))
|
|
for (const frame of frames) child.stdin.write(frame)
|
|
child.stdin.end()
|
|
})
|
|
}
|
|
|
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
|
async function centerOf(page: Page, selector: string): Promise<{ x: number; y: number }> {
|
|
const box = await page.locator(selector).first().boundingBox()
|
|
if (!box) throw new Error(`${selector} is not visible`)
|
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
|
}
|
|
|
|
// The fake cursor from the layout. The page animates it on requestAnimationFrame
|
|
// — a slight arc, ease-in-out, a duration that grows with the distance — so the
|
|
// motion is as smooth as the screencast frame rate. The real mouse follows it a
|
|
// few times a second so :hover styles apply along the way.
|
|
async function moveCursor(page: Page, to: { x: number; y: number }, duration?: number): Promise<void> {
|
|
const from = await page.evaluate(() => {
|
|
const el = document.getElementById('record-cursor')!
|
|
return { x: Number(el.dataset.x), y: Number(el.dataset.y) }
|
|
})
|
|
const distance = Math.hypot(to.x - from.x, to.y - from.y)
|
|
const ms = duration ?? Math.round(320 + Math.sqrt(distance) * 22)
|
|
|
|
const animation = page.evaluate(([from, to, ms]) => (window as unknown as RecordWindow).__recordMove(from, to, ms), [from, to, ms] as const)
|
|
|
|
// the real pointer trails the sprite, so hover states light up on the way
|
|
let done = false
|
|
const follow = (async () => {
|
|
while (!done) {
|
|
const at = await page.evaluate(() => {
|
|
const el = document.getElementById('record-cursor')!
|
|
return { x: Number(el.dataset.x), y: Number(el.dataset.y) }
|
|
})
|
|
await page.mouse.move(at.x, at.y)
|
|
await sleep(50)
|
|
}
|
|
})()
|
|
await animation
|
|
done = true
|
|
await follow
|
|
await page.mouse.move(to.x, to.y)
|
|
}
|
|
|
|
// A click: the sprite dips for a moment, the button is held for a beat. With
|
|
// no target it lands where the cursor already is, after a `move`.
|
|
async function clickAt(page: Page, to?: { x: number; y: number }): Promise<void> {
|
|
if (to) await moveCursor(page, to)
|
|
await sleep(120)
|
|
await page.evaluate(() => (document.querySelector<HTMLElement>('#record-cursor .cursor')!.style.scale = '0.88'))
|
|
await page.mouse.down()
|
|
await sleep(110)
|
|
await page.evaluate(() => (document.querySelector<HTMLElement>('#record-cursor .cursor')!.style.scale = '1'))
|
|
await page.mouse.up()
|
|
}
|
|
|
|
async function play(page: Page, steps: Step[]): Promise<void> {
|
|
for (const step of steps) {
|
|
if ('move' in step) {
|
|
await moveCursor(page, await centerOf(page, step.move), step.duration)
|
|
} else if ('click' in step) {
|
|
await clickAt(page, step.click === true ? undefined : await centerOf(page, step.click))
|
|
} else {
|
|
await sleep(step.wait)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function recordOne(page: Page, client: CDPSession, slug: string, theme: 'light' | 'dark'): Promise<Frame[]> {
|
|
await page.goto(`${baseUrl}/${slug}?theme=${theme}&motion`, { waitUntil: 'load' })
|
|
await page.waitForFunction(() => document.documentElement.dataset.screenshotReady === 'true', { timeout: 15_000 })
|
|
|
|
// The canvas has to fill the viewport exactly, so a screencast frame is the
|
|
// picture: pin it to the corner and size the viewport to it. Screencast
|
|
// frames come in CSS pixels whatever the device scale factor, so the @2x
|
|
// rendering is a CSS zoom of the whole document in a viewport twice the size.
|
|
const size = await page.locator('#screenshot').boundingBox()
|
|
if (!size) throw new Error('#screenshot not found')
|
|
const width = Math.round(size.width) * SCALE
|
|
const height = Math.round(size.height) * SCALE
|
|
await page.addStyleTag({ content: '#screenshot { position: fixed !important; inset-inline-start: 0 !important; top: 0 !important; margin: 0 !important; } [data-screenshot-chrome] { display: none !important; }' })
|
|
await page.setViewportSize({ width, height })
|
|
await page.evaluate((scale) => (document.documentElement.style.zoom = String(scale)), SCALE)
|
|
const box = await page.locator('#screenshot').boundingBox()
|
|
if (!box || Math.round(box.x) !== 0 || Math.round(box.y) !== 0 || Math.round(box.width) !== width) {
|
|
throw new Error(`#screenshot is not at 0,0 ${width}x${height} (got ${JSON.stringify(box)})`)
|
|
}
|
|
|
|
// The layout's .cursor sprite, parked below the frame until the first move.
|
|
await page.evaluate(
|
|
([w, h]) => {
|
|
// outer element carries the position, the sprite inside only the press dip —
|
|
// a `scale` on the same element would scale the translation with it
|
|
const el = document.createElement('div')
|
|
el.id = 'record-cursor'
|
|
el.style.cssText = 'position: absolute; top: 0; left: 0; z-index: 10000; pointer-events: none'
|
|
const sprite = document.createElement('div')
|
|
sprite.className = 'cursor'
|
|
sprite.style.cssText = 'position: static; transform-origin: 4px 2px'
|
|
el.append(sprite)
|
|
document.getElementById('screenshot')!.append(el)
|
|
el.dataset.x = String(w / 2)
|
|
el.dataset.y = String(h + 40)
|
|
},
|
|
[width, height],
|
|
)
|
|
await page.addScriptTag({ content: cursorScript(SCALE) })
|
|
await moveCursor(page, { x: width / 2, y: height + 40 }, 0)
|
|
|
|
const frames: Frame[] = []
|
|
const onFrame = (event: { data: string; sessionId: number }) => {
|
|
frames.push({ data: Buffer.from(event.data, 'base64'), time: Date.now() / 1000 })
|
|
void client.send('Page.screencastFrameAck', { sessionId: event.sessionId }).catch(() => {})
|
|
}
|
|
client.on('Page.screencastFrame', onFrame)
|
|
await client.send('Page.startScreencast', { format: 'jpeg', quality: 92, maxWidth: width, maxHeight: height, everyNthFrame: 1 })
|
|
|
|
const steps = JSON.parse(readFileSync(path.join(stepsDir, `${slug}.json`), 'utf8')) as Step[]
|
|
await play(page, steps)
|
|
// Let the last change land before the screencast stops.
|
|
await sleep(200)
|
|
const end = Date.now() / 1000
|
|
|
|
await client.send('Page.stopScreencast')
|
|
client.off('Page.screencastFrame', onFrame)
|
|
|
|
if (frames.length === 0) throw new Error('no frames captured')
|
|
// Hold the last frame until the end mark, so the video does not cut on the last change.
|
|
return [...frames, { data: frames.at(-1)!.data, time: end }]
|
|
}
|
|
|
|
async function main() {
|
|
const slugs = discoverSlugs()
|
|
if (slugs.length === 0) {
|
|
console.error('No matching steps found in steps/.')
|
|
process.exit(1)
|
|
}
|
|
|
|
mkdirSync(outDir, { recursive: true })
|
|
const bundled = bundledFfmpeg()
|
|
const system = systemFfmpeg()
|
|
|
|
console.log(`Starting preview server on port ${PORT}…`)
|
|
await startPreviewServer()
|
|
process.once('SIGINT', () => void stopPreviewServer())
|
|
process.once('SIGTERM', () => void stopPreviewServer())
|
|
|
|
try {
|
|
const browser = await chromium.launch()
|
|
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } })
|
|
const page = await context.newPage()
|
|
const client = await context.newCDPSession(page)
|
|
|
|
for (const slug of slugs) {
|
|
for (const theme of ['light', 'dark'] as const) {
|
|
console.log(`${slug} (${theme})`)
|
|
const frames = await recordOne(page, client, slug, theme)
|
|
const sampled = resample(frames, frames.at(-1)!.time)
|
|
const name = `${slug}${theme === 'dark' ? '-dark' : ''}`
|
|
|
|
// RECORD_FRAMES="0.5,2,4" also writes those seconds as JPEG stills, for a quick look without a player.
|
|
for (const second of (process.env.RECORD_FRAMES ?? '').split(',').filter(Boolean).map(Number)) {
|
|
const frame = sampled[Math.min(sampled.length - 1, Math.round(second * FPS))]
|
|
writeFileSync(path.join(outDir, `${name}-${second}s.jpg`), frame)
|
|
}
|
|
|
|
await encode(bundled, sampled, ['-c:v', 'libvpx', '-b:v', '6M', '-pix_fmt', 'yuv420p', '-auto-alt-ref', '0'], path.join(outDir, `${name}.webm`))
|
|
console.log(` ✓ ${name}.webm (${sampled.length} frames, ${(sampled.length / FPS).toFixed(1)} s)`)
|
|
|
|
if (system) {
|
|
await encode(system, sampled, ['-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-movflags', '+faststart'], path.join(outDir, `${name}.mp4`))
|
|
await encode(system, sampled, ['-vf', `fps=${FPS},scale=iw/2:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=sierra2_4a`, '-loop', '0'], path.join(outDir, `${name}.gif`))
|
|
console.log(` ✓ ${name}.mp4, ${name}.gif`)
|
|
}
|
|
}
|
|
}
|
|
|
|
await browser.close()
|
|
if (!system) console.log('\nOnly WebM was written: install ffmpeg (brew install ffmpeg) to get MP4 and GIF too.')
|
|
} finally {
|
|
await stopPreviewServer()
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error)
|
|
process.exit(1)
|
|
})
|