scripts/marketing-screenshots/capture.ts
Rama main · réplica de lectura del repositorio original.
/**
* Capture the console screenshots used on capgo.app (Cap-go/website).
*
* bun run serve:worktree # frontend for this worktree
* bun run screenshots:marketing # seed + capture + export
* bun run screenshots:marketing -- observe- # only shots whose name starts with "observe-"
* bun run screenshots:marketing -- --videos # also record the looping videos (needs ffmpeg)
* bun run screenshots:marketing -- video- # only the videos
*
* Env:
* BASE_URL frontend URL (default http://localhost:5173)
* DB_URL local Postgres, set by `supabase:with-env`; seeding is skipped without it
* WEBSITE_DIR website repo root; webp files are written to apps/web/public/landing-demos
* OUT_DIR raw png + webp output (default .context/marketing-screenshots)
*
* Seeds fake demo data into the LOCAL stack only and refuses any non-local database.
*/
import type { Browser, BrowserContext, Page } from '@playwright/test'
import type { Shot } from './shots'
import type { VideoFlow } from './videos'
import { spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, resolve } from 'node:path'
import process from 'node:process'
import { chromium } from '@playwright/test'
import { Client } from 'pg'
import { installMocks, maskIdentities } from './mocks'
import { shots } from './shots'
import { CURSOR_SCRIPT, videoFlows } from './videos'
const repoRoot = resolve(import.meta.dir, '../..')
const baseUrl = (process.env.BASE_URL ?? 'http://localhost:5173').replace(/\/$/, '')
const outDir = resolve(process.env.OUT_DIR ?? resolve(repoRoot, '.context/marketing-screenshots'))
const websiteDir = process.env.WEBSITE_DIR ? resolve(process.env.WEBSITE_DIR) : null
const filters = process.argv.slice(2).filter(arg => !arg.startsWith('--'))
const skipSeed = process.argv.includes('--no-seed')
const withVideos = process.argv.includes('--videos')
const selectedByName = (name: string) => filters.length === 0 || filters.some(filter => name.startsWith(filter))
const HIDE_CHROME_CSS = `
#__vue-devtools-container__, [id^="vue-devtools"], vite-error-overlay { display: none !important; }
*, *::before, *::after { caret-color: transparent !important; }
[data-sonner-toaster], [data-test="getting-started-nav"], [data-test="section-intro"] { display: none !important; }
`
async function seed() {
const dbUrl = process.env.DB_URL
if (skipSeed || !dbUrl) {
console.log(skipSeed ? 'Skipping seed (--no-seed).' : 'DB_URL not set: skipping seed. Run through `bun run screenshots:marketing`.')
return
}
// IPv6 hosts come back bracketed (`[::1]`) from URL.hostname.
const host = new URL(dbUrl).hostname.replace(/^\[|\]$/g, '')
if (!['127.0.0.1', 'localhost', '::1'].includes(host))
throw new Error(`Refusing to seed non-local database host "${host}".`)
const client = new Client({ connectionString: dbUrl })
await client.connect()
try {
await client.query(readFileSync(resolve(import.meta.dir, 'seed.sql'), 'utf8'))
console.log('Seeded demo data.')
}
finally {
await client.end()
}
}
async function settle(page: Page, waitMs: number) {
await page.addStyleTag({ content: HIDE_CHROME_CSS }).catch(() => {})
await page.waitForLoadState('networkidle').catch(() => {})
await page.waitForTimeout(waitMs)
// Re-apply: some pages mount their chrome after the first paint.
await page.addStyleTag({ content: HIDE_CHROME_CSS }).catch(() => {})
}
async function login(page: Page) {
await page.goto(`${baseUrl}/login/`)
await page.fill('[data-test="email"]', 'test@capgo.app')
await page.fill('[data-test="password"]', 'testtest')
await page.locator('[data-test="submit"]').click()
await page.waitForURL(/\/(apps|dashboard)(\/|$)/, { timeout: 30_000 })
await settle(page, 1000)
}
/** Crop, resize, and encode a PNG to webp with Chromium's canvas (no image tooling needed). */
async function encodeWebp(browser: Browser, png: Buffer, exp: NonNullable<Shot['exports']>[number]) {
const page = await browser.newPage()
try {
const dataUrl = await page.evaluate(async ({ src, crop, width, quality }) => {
const img = new Image()
img.src = src
await img.decode()
const area = crop ?? { x: 0, y: 0, width: img.naturalWidth, height: img.naturalHeight }
const scale = width / area.width
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = Math.round(area.height * scale)
const ctx = canvas.getContext('2d')!
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(img, area.x, area.y, area.width, area.height, 0, 0, canvas.width, canvas.height)
return canvas.toDataURL('image/webp', quality)
}, { src: `data:image/png;base64,${png.toString('base64')}`, crop: exp.crop ?? null, width: exp.width, quality: exp.quality ?? 0.8 })
return Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64')
}
finally {
await page.close()
}
}
function writeOutput(file: string, data: Buffer) {
const targets = [resolve(outDir, 'webp', file)]
if (websiteDir)
targets.push(resolve(websiteDir, 'apps/web/public/landing-demos', file))
for (const target of targets) {
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, data)
}
}
function ffmpeg(args: string[]) {
const result = spawnSync('ffmpeg', ['-y', '-loglevel', 'error', ...args], { stdio: 'inherit' })
if (result.status !== 0)
throw new Error(`ffmpeg failed: ${args.join(' ')}`)
}
/** Record one flow in its own context (video needs it), then trim, fade, and encode mp4 + webm + poster. */
async function recordVideo(browser: Browser, storageState: Awaited<ReturnType<BrowserContext['storageState']>>, flow: VideoFlow) {
const workDir = mkdtempSync(resolve(tmpdir(), 'capgo-video-'))
const context = await browser.newContext({
storageState,
viewport: flow.viewport,
colorScheme: 'dark',
locale: 'en-US',
timezoneId: 'UTC',
recordVideo: { dir: workDir, size: flow.viewport },
})
await context.addInitScript(CURSOR_SCRIPT)
const page = await context.newPage()
const recordingStart = Date.now()
await installMocks(page)
await page.goto(baseUrl + flow.path)
await settle(page, 3000)
await maskIdentities(page)
const start = (Date.now() - recordingStart) / 1000
await flow.run(page)
const duration = (Date.now() - recordingStart) / 1000 - start
const video = page.video()
await context.close()
const raw = await video!.path()
const fade = `fade=t=in:st=0:d=0.3,fade=t=out:st=${Math.max(0, duration - 0.4).toFixed(2)}:d=0.4`
const trim = ['-ss', start.toFixed(2), '-t', duration.toFixed(2), '-i', raw]
const mp4 = resolve(workDir, 'out.mp4')
const webm = resolve(workDir, 'out.webm')
const poster = resolve(workDir, 'poster.png')
ffmpeg([...trim, '-vf', `fps=30,${fade}`, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '24', '-preset', 'slow', '-movflags', '+faststart', '-an', mp4])
ffmpeg([...trim, '-vf', `fps=30,${fade}`, '-c:v', 'libvpx-vp9', '-crf', '36', '-b:v', '0', '-an', webm])
ffmpeg(['-ss', (start + 0.5).toFixed(2), '-i', raw, '-frames:v', '1', poster])
writeOutput(`${flow.file}.mp4`, readFileSync(mp4))
writeOutput(`${flow.file}.webm`, readFileSync(webm))
writeOutput(`${flow.file}-poster.webp`, await encodeWebp(browser, readFileSync(poster), { file: '', width: flow.viewport.width, quality: 0.8 }))
rmSync(workDir, { recursive: true, force: true })
console.log(`recorded ${flow.name} (${duration.toFixed(1)}s) -> ${flow.file}.{mp4,webm}`)
}
async function main() {
const reachable = await fetch(`${baseUrl}/login/`).then(res => res.ok, () => false)
if (!reachable)
throw new Error(`Frontend not reachable at ${baseUrl}. Start it with \`bun run serve:worktree\` or set BASE_URL.`)
await seed()
mkdirSync(resolve(outDir, 'png'), { recursive: true })
const browser = await chromium.launch()
const context = await browser.newContext({ deviceScaleFactor: 2, colorScheme: 'dark', locale: 'en-US', timezoneId: 'UTC' })
const page = await context.newPage()
page.on('pageerror', error => console.warn(`[page error] ${error.message}`))
await installMocks(page)
await login(page)
const selected = shots.filter(shot => selectedByName(shot.name))
for (const shot of selected) {
await page.setViewportSize(shot.viewport)
await page.goto(baseUrl + shot.path)
await settle(page, 4000)
await shot.prepare?.(page)
await maskIdentities(page)
const png = await page.screenshot()
writeFileSync(resolve(outDir, 'png', `${shot.name}.png`), png)
for (const exp of shot.exports ?? [])
writeOutput(exp.file, await encodeWebp(browser, png, exp))
console.log(`captured ${shot.name}${shot.exports?.length ? ` -> ${shot.exports.map(exp => exp.file).join(', ')}` : ''}`)
}
// Videos run with --videos, or when a filter names them (e.g. `video-`); other filters still apply.
const wantVideos = withVideos || filters.some(filter => filter.startsWith('video'))
const flows = wantVideos ? videoFlows.filter(flow => selectedByName(flow.name)) : []
if (flows.length > 0) {
const storageState = await context.storageState()
for (const flow of flows)
await recordVideo(browser, storageState, flow)
}
await browser.close()
console.log(`Done. Raw captures in ${resolve(outDir, 'png')}${websiteDir ? `, webp written to ${websiteDir}` : ''}.`)
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
})