scripts/generate-app-store-screenshots.ts
Rama main · réplica de lectura del repositorio original.
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { spawn, spawnSync } from 'node:child_process'
import { copyFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import net from 'node:net'
import type { Browser, Page } from '@playwright/test'
import { chromium } from '@playwright/test'
import { createClient } from '@supabase/supabase-js'
import { Client } from 'pg'
import { installMocks, maskIdentities } from './marketing-screenshots/mocks'
import { getPlaywrightStripeApiBaseUrl, getStripeEmulatorPort } from './playwright-stripe'
import { getSupabaseWorktreeConfig } from './supabase-worktree-config'
interface ManagedProcess {
child: ChildProcessWithoutNullStreams
name: string
}
interface SupabaseStatus {
API_URL?: string
ANON_KEY?: string
PUBLISHABLE_KEY?: string
DB_URL?: string
}
interface ScreenshotDevice {
slug: string
// App Store Connect matches screenshots by pixel size, the prefix only keeps files readable
filePrefix: string
cssWidth: number
cssHeight: number
scale: number
mobile: boolean
// Defaults to the fastlane deliver folder; set for sizes deliver can't upload yet
outputDir?: string
// Google Play screenshot folders that reuse this capture
androidDirs: string[]
}
interface CreativeAsset {
fileName: string
cssWidth: number
cssHeight: number
scale: number
layout: 'header' | 'search' | 'feature'
}
const repoRoot = process.cwd()
const outputRoot = resolve(repoRoot, 'fastlane/screenshots/en-US')
const androidOutputRoot = resolve(repoRoot, 'fastlane/metadata/android/en-US/images')
const androidScreenshotDirs = ['phoneScreenshots', 'sevenInchScreenshots', 'tenInchScreenshots']
const appStoreAssetsRoot = resolve(repoRoot, 'fastlane/app-store-assets')
const rawRoot = resolve(repoRoot, '.context/app-store-screenshots/raw')
const composeRoot = resolve(repoRoot, '.context/app-store-screenshots/compose')
const brandMarkPath = resolve(repoRoot, 'assets/brand/capgo-mark-white.svg')
const logRoot = resolve(repoRoot, '.context/app-store-screenshots/logs')
const screenshotBaseUrl = process.env.APP_STORE_SCREENSHOT_BASE_URL || 'http://127.0.0.1:5173'
const supabaseConfig = getSupabaseWorktreeConfig(repoRoot)
const localSupabaseUrl = `http://127.0.0.1:${supabaseConfig.ports.api}`
const localApiDomain = `127.0.0.1:${supabaseConfig.ports.api}/functions/v1`
const stripePort = getStripeEmulatorPort(process.env)
const stripeApiBaseUrl = getPlaywrightStripeApiBaseUrl(process.env)
const managedProcesses: ManagedProcess[] = []
const isWindows = process.platform === 'win32'
const devices: ScreenshotDevice[] = [
{
slug: 'iphone-6-9',
filePrefix: 'iPhone 6.9in',
cssWidth: 440,
cssHeight: 956,
scale: 3,
mobile: true,
androidDirs: [],
},
{
slug: 'iphone-6-5',
filePrefix: 'iPhone 6.5in',
cssWidth: 428,
cssHeight: 926,
scale: 3,
mobile: true,
androidDirs: [],
},
{
slug: 'iphone-6-3',
filePrefix: 'iPhone 6.3in',
cssWidth: 402,
cssHeight: 874,
scale: 3,
mobile: true,
androidDirs: [],
},
{
slug: 'iphone-5-5',
filePrefix: 'iPhone 5.5in',
cssWidth: 414,
cssHeight: 736,
scale: 3,
mobile: true,
androidDirs: ['phoneScreenshots'],
},
{
slug: 'iphone-duo-outer',
filePrefix: 'iPhone Duo outer',
cssWidth: 466,
cssHeight: 678,
scale: 3,
mobile: true,
outputDir: resolve(appStoreAssetsRoot, 'iphone-duo'),
androidDirs: [],
},
{
slug: 'iphone-duo-inner',
filePrefix: 'iPhone Duo inner',
cssWidth: 669,
cssHeight: 951,
scale: 3,
mobile: true,
outputDir: resolve(appStoreAssetsRoot, 'iphone-duo'),
androidDirs: [],
},
{
slug: 'ipad-13',
filePrefix: 'iPad 13in',
cssWidth: 1032,
cssHeight: 1376,
scale: 2,
mobile: false,
androidDirs: ['sevenInchScreenshots', 'tenInchScreenshots'],
},
]
// App Store creative assets (product page header, search results) and the Google Play feature graphic
const creativeAssets: CreativeAsset[] = [
{ fileName: 'product-page-header-21x9.png', cssWidth: 1920, cssHeight: 823, scale: 2, layout: 'header' },
{ fileName: 'product-page-header-16x9.png', cssWidth: 2622, cssHeight: 1475, scale: 2, layout: 'header' },
{ fileName: 'search-result-3x2.png', cssWidth: 1920, cssHeight: 1280, scale: 2, layout: 'search' },
{ fileName: 'search-result-16x9.png', cssWidth: 2622, cssHeight: 1475, scale: 2, layout: 'search' },
]
const featureGraphic: CreativeAsset = { fileName: 'featureGraphic.png', cssWidth: 1024, cssHeight: 500, scale: 1, layout: 'feature' }
// Demo data comes from scripts/marketing-screenshots (seed.sql + mocked analytics), so every screen shows a live-looking app
// playOnly screens show other platforms (Android device rows, platform tags), which App Review rejects (guideline 2.3.10)
const screens: { slug: string, path: string, title: string, subtitle: string, playOnly?: boolean, prepare?: (page: Page) => Promise<void> }[] = [
{ slug: 'live-release', path: '/app/com.demo.app?days=7', title: 'Watch each release go live', subtitle: 'Adoption, installs, and failures in real time' },
{
slug: 'rollout',
path: '/app/com.demo.app/channel/1',
title: 'Roll out safely',
subtitle: 'Start small and pause automatically on errors',
playOnly: true,
prepare: async (page) => {
await page.getByText(/of devices receive/).first().evaluate((el: Element) => el.closest('div')?.parentElement?.scrollIntoView({ block: 'center' })).catch(() => {})
await page.waitForTimeout(600)
},
},
{ slug: 'update-health', path: '/app/com.demo.app/observe/errors?days=7', title: 'Catch failed updates fast', subtitle: 'See which devices failed and why' },
{ slug: 'channels', path: '/app/com.demo.app/channels', title: 'Ship to beta, then production', subtitle: 'Channels for every audience' },
{ slug: 'devices', path: '/app/com.demo.app/devices', title: 'Know what each device runs', subtitle: 'Find any user\'s version in seconds', playOnly: true },
{ slug: 'preview-qr', path: '/app/com.demo.app/channel/1/preview?appStoreQr=1', title: 'Preview any build with a scan', subtitle: 'Share bundles with your team by QR' },
{ slug: 'builds', path: '/app/com.demo.app/builds', title: 'Native builds in the cloud', subtitle: 'Signed, uploaded, and tracked for you' },
]
const hideChromeCss = `
#__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; }
`
// Framed layout: headline on top, the app capture inside a bezel that bleeds off the bottom edge
const frameWidthRatio = 0.84
const frameTopRatio = 0.27
const frameBleedRatio = 0.04
const bezelRatio = 0.016
function frameMetrics(device: ScreenshotDevice) {
const frameWidth = Math.round(device.cssWidth * frameWidthRatio)
const bezel = Math.max(4, Math.round(device.cssWidth * bezelRatio))
const frameTop = Math.round(device.cssHeight * frameTopRatio)
const frameHeight = device.cssHeight - frameTop + Math.round(device.cssHeight * frameBleedRatio)
return {
frameWidth,
frameTop,
bezel,
rawWidth: frameWidth - bezel * 2,
rawHeight: frameHeight - bezel * 2,
}
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function isHttpReady(url: string): Promise<boolean> {
try {
const response = await fetch(url)
return response.status < 500
}
catch {
return false
}
}
function isTcpReady(port: number, host = '127.0.0.1'): Promise<boolean> {
return new Promise((resolveReady) => {
const socket = net.connect({ port, host })
socket.setTimeout(750)
socket.once('connect', () => {
socket.destroy()
resolveReady(true)
})
socket.once('timeout', () => {
socket.destroy()
resolveReady(false)
})
socket.once('error', () => {
socket.destroy()
resolveReady(false)
})
})
}
async function waitForHttp(url: string, timeoutMs: number, label: string) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isHttpReady(url))
return
await sleep(1000)
}
throw new Error(`Timed out waiting for ${label} at ${url}`)
}
async function waitForTcp(port: number, timeoutMs: number, label: string) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await isTcpReady(port))
return
await sleep(500)
}
throw new Error(`Timed out waiting for ${label} on port ${port}`)
}
async function waitForFile(path: string, timeoutMs: number, label: string) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (existsSync(path))
return
await sleep(1000)
}
throw new Error(`Timed out waiting for ${label} at ${path}`)
}
function startProcess(name: string, args: string[], env: NodeJS.ProcessEnv = {}): ManagedProcess {
mkdirSync(logRoot, { recursive: true })
const logPath = resolve(logRoot, `${name}.log`)
const logStream = createWriteStream(logPath, { flags: 'a' })
const child = spawn(args[0], args.slice(1), {
cwd: repoRoot,
env: { ...process.env, ...env },
detached: !isWindows,
})
child.stdout.pipe(logStream)
child.stderr.pipe(logStream)
const managed = { child, name }
managedProcesses.push(managed)
return managed
}
async function stopProcess(processToStop: ManagedProcess) {
const hasExited = () => processToStop.child.exitCode !== null || processToStop.child.signalCode !== null
if (hasExited())
return
const waitForExit = (timeoutMs: number) => {
if (hasExited())
return Promise.resolve(true)
return Promise.race([
new Promise<boolean>((resolveExit) => {
processToStop.child.once('exit', () => resolveExit(true))
}),
sleep(timeoutMs).then(() => hasExited()),
])
}
const terminateProcessTree = (signal: NodeJS.Signals) => {
if (!processToStop.child.pid)
return
if (isWindows) {
const args = ['/PID', String(processToStop.child.pid), '/T']
if (signal === 'SIGKILL')
args.push('/F')
spawnSync('taskkill', args, { stdio: 'ignore' })
return
}
try {
process.kill(-processToStop.child.pid, signal)
}
catch {
// The process might not be the group leader on older runs.
}
}
if (processToStop.child.pid) {
terminateProcessTree('SIGTERM')
if (!isWindows)
spawnSync('pkill', ['-TERM', '-P', String(processToStop.child.pid)], { stdio: 'ignore' })
}
processToStop.child.kill('SIGTERM')
if (await waitForExit(5000))
return
if (processToStop.child.pid) {
terminateProcessTree('SIGKILL')
if (!isWindows)
spawnSync('pkill', ['-KILL', '-P', String(processToStop.child.pid)], { stdio: 'ignore' })
}
if (!hasExited())
processToStop.child.kill('SIGKILL')
await waitForExit(2000)
}
function parseSupabaseStatus(stdout: string): SupabaseStatus | null {
const jsonStart = stdout.indexOf('{')
if (jsonStart < 0)
return null
try {
return JSON.parse(stdout.slice(jsonStart)) as SupabaseStatus
}
catch {
return null
}
}
function getSupabaseStatus(): SupabaseStatus | null {
const result = spawnSync('bun', ['scripts/supabase-worktree.ts', 'status', '-o', 'json'], {
cwd: repoRoot,
encoding: 'utf8',
env: process.env,
})
if ((result.status ?? 1) !== 0)
return null
return parseSupabaseStatus(result.stdout || '')
}
function readPngSize(path: string): { width: number, height: number } {
const buffer = readFileSync(path)
if (buffer.toString('ascii', 1, 4) !== 'PNG')
throw new Error(`${path} is not a PNG file`)
return {
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20),
}
}
function verifyScreenshotSize(path: string, expectedWidth: number, expectedHeight: number) {
const { width, height } = readPngSize(path)
if (width !== expectedWidth || height !== expectedHeight)
throw new Error(`${path} is ${width}x${height}, expected ${expectedWidth}x${expectedHeight}`)
}
async function ensureLocalStack() {
if (!(await isTcpReady(stripePort))) {
startProcess('stripe-emulator', ['bun', 'run', 'stripe:emulator'], {
STRIPE_EMULATOR_PORT: String(stripePort),
})
await waitForTcp(stripePort, 60_000, 'Stripe emulator')
}
const backendOkUrl = `${localSupabaseUrl}/functions/v1/ok`
const reuseBackend = process.env.APP_STORE_SCREENSHOTS_REUSE_BACKEND === 'true'
if (!reuseBackend || !(await isHttpReady(backendOkUrl))) {
const readyFile = resolve(repoRoot, '.context/app-store-screenshots/backend.ready')
rmSync(readyFile, { force: true })
startProcess('backend-playwright', ['bun', 'run', 'backend:playwright'], {
ENV: 'local',
STRIPE_SECRET_KEY: 'sk_test_emulator',
STRIPE_API_BASE_URL: stripeApiBaseUrl,
STRIPE_WEBHOOK_SECRET: 'testsecret',
WEBAPP_URL: screenshotBaseUrl,
PLAYWRIGHT_READY_FILE: readyFile,
})
await waitForFile(readyFile, 360_000, 'Supabase functions')
}
const status = getSupabaseStatus()
const supabaseUrl = status?.API_URL || localSupabaseUrl
const supabaseAnon = process.env.SUPABASE_ANON_KEY || status?.ANON_KEY || status?.PUBLISHABLE_KEY
if (!supabaseAnon)
throw new Error('Unable to resolve the local Supabase anon key from environment or worktree status')
const apiDomain = supabaseUrl.replace(/^https?:\/\//, '').replace(/\/$/, '') + '/functions/v1'
if (!(await isHttpReady(`${screenshotBaseUrl}/login/`))) {
const frontendPort = new URL(screenshotBaseUrl).port || '5173'
startProcess('frontend', ['bunx', 'vite', '--host', '127.0.0.1', '--port', frontendPort, '--strictPort'], {
ENV: 'local',
SUPA_URL: supabaseUrl,
SUPA_ANON: supabaseAnon,
API_DOMAIN: apiDomain || localApiDomain,
CAPTCHA_KEY: '',
})
await waitForHttp(`${screenshotBaseUrl}/login/`, 180_000, 'frontend')
}
return { supabaseUrl, supabaseAnon }
}
async function seedDemoData() {
const dbUrl = process.env.DB_URL || getSupabaseStatus()?.DB_URL
if (!dbUrl)
throw new Error('Unable to resolve the local database URL for the demo seed')
// 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(repoRoot, 'scripts/marketing-screenshots/seed.sql'), 'utf8'))
}
finally {
await client.end()
}
}
async function prepareScreenshotData(supabaseUrl: string, supabaseAnon: string) {
const supabase = createClient(supabaseUrl, supabaseAnon, {
auth: {
autoRefreshToken: false,
persistSession: false,
detectSessionInUrl: false,
},
})
const { error: signInError } = await supabase.auth.signInWithPassword({
email: 'test@capgo.app',
password: 'testtest',
})
if (signInError)
throw new Error(`Unable to sign in for screenshot data setup: ${signInError.message}`)
const { error } = await supabase
.from('apps')
.update({ allow_preview: true })
.eq('app_id', 'com.demo.app')
if (error)
throw new Error(`Unable to enable demo preview for screenshots: ${error.message}`)
}
// Runs in the browser before every navigation: keep first-run hints, the
// support usernames prompt, and the dev-server Vue DevTools button out of store screenshots.
function hideScreenshotNoise() {
localStorage.setItem('capgo-section-intro-dismissed', JSON.stringify(['apps', 'bundles', 'channels', 'builds', 'devices', 'apikeys']))
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (!key?.startsWith('sb-') || !key.endsWith('-auth-token'))
continue
try {
const userId = JSON.parse(localStorage.getItem(key) || '{}')?.user?.id
if (userId)
localStorage.setItem(`capgo.supportUsernames.dismissed.${userId}`, '1')
}
catch {}
}
document.addEventListener('DOMContentLoaded', () => {
const style = document.createElement('style')
style.textContent = '#__vue-devtools-container__, [id^="vue-devtools"] { display: none !important; }'
document.head.appendChild(style)
})
}
async function login(page: Page) {
await page.goto('/login/', { waitUntil: 'domcontentloaded' })
await page.fill('[data-test="email"]', 'test@capgo.app')
await page.waitForSelector('[data-test="password"]', { timeout: 30_000 })
await page.fill('[data-test="password"]', 'testtest')
await page.click('[data-test="submit"]')
// Match the pathname only: a bounced login lands on /login?to=/dashboard
await page.waitForURL(url => /^\/(?:apps|dashboard)(?:\/|$)/.test(url.pathname), { timeout: 60_000 })
}
async function settlePage(page: Page) {
await page.waitForLoadState('domcontentloaded')
await page.addStyleTag({ content: hideChromeCss }).catch(() => {})
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {})
await page.waitForTimeout(3000)
// Re-apply: some pages mount their chrome after the first paint
await page.addStyleTag({ content: hideChromeCss }).catch(() => {})
}
function escapeHtml(value: string) {
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
}
function fileUrl(path: string) {
return pathToFileURL(path).href
}
const pageStyle = `
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: #fff;
background:
radial-gradient(120% 70% at 50% 0%, rgba(17, 158, 255, 0.28) 0%, rgba(17, 158, 255, 0) 60%),
radial-gradient(90% 60% at 100% 100%, rgba(31, 178, 165, 0.18) 0%, rgba(31, 178, 165, 0) 70%),
linear-gradient(180deg, #001a2c 0%, #04253d 100%);
-webkit-font-smoothing: antialiased;
}
.phone {
position: absolute;
background: #0a1622;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.14), 0 40px 80px rgba(0, 0, 0, 0.45);
overflow: hidden;
}
.phone img { display: block; width: 100%; height: auto; }
`
function framedScreenHtml(device: ScreenshotDevice, screen: (typeof screens)[number], rawPath: string) {
const m = frameMetrics(device)
const titleSize = Math.min(device.cssWidth * 0.085, device.cssHeight * 0.05)
const subtitleSize = titleSize * 0.46
const radius = Math.round(m.frameWidth * 0.1)
return `<!doctype html><html><head><meta charset="utf-8"><style>${pageStyle}
.copy { position: absolute; left: 7%; right: 7%; top: 0; height: ${m.frameTop}px; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; gap: ${Math.round(subtitleSize * 0.7)}px; }
h1 { font-size: ${titleSize}px; font-weight: 800; line-height: 1.08; letter-spacing: -0.025em; text-wrap: balance; }
p { font-size: ${subtitleSize}px; font-weight: 500; line-height: 1.3; color: #a9c4dc; text-wrap: balance; }
.phone { left: ${Math.round((device.cssWidth - m.frameWidth) / 2)}px; top: ${m.frameTop}px; width: ${m.frameWidth}px; padding: ${m.bezel}px; border-radius: ${radius}px; }
.phone img { border-radius: ${radius - m.bezel}px; }
</style></head><body>
<div class="copy"><h1>${escapeHtml(screen.title)}</h1><p>${escapeHtml(screen.subtitle)}</p></div>
<div class="phone"><img src="${fileUrl(rawPath)}"></div>
</body></html>`
}
// Three phones fanned around the center: App Store crops headers per device, so the focal point stays centered
function creativeHtml(asset: CreativeAsset, rawPaths: string[]) {
const { cssWidth: w, cssHeight: h } = asset
const mark = fileUrl(brandMarkPath)
if (asset.layout === 'feature') {
const phoneWidth = 200
return `<!doctype html><html><head><meta charset="utf-8"><style>${pageStyle}
.brand { position: absolute; left: 64px; top: 0; bottom: 0; width: 520px; display: flex; flex-direction: column; justify-content: center; gap: 18px; }
.logo { display: flex; align-items: center; gap: 16px; font-size: 44px; font-weight: 800; letter-spacing: -0.02em; }
.logo img { width: 60px; height: 60px; }
h1 { font-size: 40px; font-weight: 800; line-height: 1.08; white-space: nowrap; letter-spacing: -0.025em; }
p { font-size: 21px; font-weight: 500; color: #a9c4dc; }
.phone { width: ${phoneWidth}px; padding: 5px; border-radius: 26px; }
.phone img { border-radius: 21px; }
</style></head><body>
<div class="brand"><div class="logo"><img src="${mark}">Capgo</div><h1>Ship app updates in minutes</h1><p>Live updates for Capacitor apps</p></div>
<div class="phone" style="left: 610px; top: 96px;"><img src="${fileUrl(rawPaths[3])}"></div>
<div class="phone" style="left: 790px; top: 48px;"><img src="${fileUrl(rawPaths[0])}"></div>
</body></html>`
}
const isHeader = asset.layout === 'header'
const titleSize = Math.round(h * (isHeader ? 0.075 : 0.065))
const copyHeight = Math.round(h * (isHeader ? 0.24 : 0.22))
const phoneWidth = Math.round(h * (isHeader ? 0.42 : 0.4))
const gap = Math.round(phoneWidth * 0.08)
const centerTop = copyHeight + Math.round(h * 0.03)
const sideTop = centerTop + Math.round(h * 0.08)
const centerLeft = Math.round((w - phoneWidth) / 2)
const title = isHeader ? 'Ship app updates in minutes' : 'Live updates for Capacitor apps'
return `<!doctype html><html><head><meta charset="utf-8"><style>${pageStyle}
.copy { position: absolute; left: 0; right: 0; top: 0; height: ${copyHeight}px; display: flex; align-items: flex-end; justify-content: center; }
h1 { font-size: ${titleSize}px; font-weight: 800; letter-spacing: -0.025em; }
.phone { width: ${phoneWidth}px; padding: ${Math.round(phoneWidth * 0.018)}px; border-radius: ${Math.round(phoneWidth * 0.1)}px; }
.phone img { border-radius: ${Math.round(phoneWidth * 0.082)}px; }
</style></head><body>
<div class="copy"><h1>${title}</h1></div>
<div class="phone" style="left: ${centerLeft - phoneWidth - gap}px; top: ${sideTop}px;"><img src="${fileUrl(rawPaths[3])}"></div>
<div class="phone" style="left: ${centerLeft + phoneWidth + gap}px; top: ${sideTop}px;"><img src="${fileUrl(rawPaths[2])}"></div>
<div class="phone" style="left: ${centerLeft}px; top: ${centerTop}px; z-index: 1;"><img src="${fileUrl(rawPaths[0])}"></div>
</body></html>`
}
async function renderHtml(browser: Browser, html: string, name: string, cssWidth: number, cssHeight: number, scale: number, outputPath: string) {
mkdirSync(composeRoot, { recursive: true })
const htmlPath = resolve(composeRoot, `${name}.html`)
writeFileSync(htmlPath, html)
const context = await browser.newContext({ viewport: { width: cssWidth, height: cssHeight }, deviceScaleFactor: scale })
try {
const page = await context.newPage()
await page.goto(fileUrl(htmlPath), { waitUntil: 'load' })
await page.evaluate(() => document.fonts.ready)
mkdirSync(dirname(outputPath), { recursive: true })
await page.screenshot({ path: outputPath, fullPage: false, animations: 'disabled' })
verifyScreenshotSize(outputPath, cssWidth * scale, cssHeight * scale)
}
finally {
await context.close()
}
}
async function captureRawScreens(browser: Browser, device: ScreenshotDevice) {
const { rawWidth, rawHeight } = frameMetrics(device)
const context = await browser.newContext({
baseURL: screenshotBaseUrl,
viewport: { width: rawWidth, height: rawHeight },
deviceScaleFactor: device.scale,
hasTouch: device.mobile,
isMobile: device.mobile,
locale: 'en-US',
reducedMotion: 'reduce',
colorScheme: 'light',
timezoneId: 'UTC',
})
await context.addInitScript(hideScreenshotNoise)
const page = await context.newPage()
await installMocks(page)
const rawPaths: string[] = []
try {
await login(page)
for (const screen of screens) {
await page.goto(screen.path, { waitUntil: 'domcontentloaded' })
await settlePage(page)
await screen.prepare?.(page)
await maskIdentities(page)
const rawPath = resolve(rawRoot, device.slug, `${screen.slug}.png`)
mkdirSync(dirname(rawPath), { recursive: true })
await page.screenshot({ path: rawPath, fullPage: false, animations: 'disabled', caret: 'hide' })
rawPaths.push(rawPath)
}
}
finally {
await context.close()
}
return rawPaths
}
async function captureScreenshots() {
for (const dir of [outputRoot, appStoreAssetsRoot, rawRoot, ...androidScreenshotDirs.map(dir => resolve(androidOutputRoot, dir))]) {
rmSync(dir, { recursive: true, force: true })
mkdirSync(dir, { recursive: true })
}
const browser = await chromium.launch({ headless: true })
try {
let creativeRawPaths: string[] = []
for (const device of devices) {
const rawPaths = await captureRawScreens(browser, device)
if (device.slug === 'iphone-6-9')
creativeRawPaths = rawPaths
let appleIndex = 0
for (const [index, screen] of screens.entries()) {
if (screen.playOnly && device.androidDirs.length === 0)
continue
const androidName = `${String(index + 1).padStart(2, '0')}-${screen.slug}.png`
const fileName = screen.playOnly ? androidName : `${device.filePrefix}-${String(++appleIndex).padStart(2, '0')}-${screen.slug}.png`
const outputPath = screen.playOnly ? resolve(composeRoot, device.slug, androidName) : resolve(device.outputDir ?? outputRoot, fileName)
await renderHtml(browser, framedScreenHtml(device, screen, rawPaths[index]), `${device.slug}-${screen.slug}`, device.cssWidth, device.cssHeight, device.scale, outputPath)
for (const dir of device.androidDirs)
copyFileSync(outputPath, resolve(androidOutputRoot, dir, androidName))
console.log(`${device.slug}: ${fileName}`)
}
}
for (const asset of creativeAssets) {
await renderHtml(browser, creativeHtml(asset, creativeRawPaths), asset.fileName.replace('.png', ''), asset.cssWidth, asset.cssHeight, asset.scale, resolve(appStoreAssetsRoot, 'creative', asset.fileName))
console.log(`creative: ${asset.fileName}`)
}
await renderHtml(browser, creativeHtml(featureGraphic, creativeRawPaths), 'feature-graphic', featureGraphic.cssWidth, featureGraphic.cssHeight, featureGraphic.scale, resolve(androidOutputRoot, featureGraphic.fileName))
console.log(`android: ${featureGraphic.fileName}`)
}
finally {
await browser.close()
}
}
try {
const localStack = await ensureLocalStack()
await seedDemoData()
await prepareScreenshotData(localStack.supabaseUrl, localStack.supabaseAnon)
await captureScreenshots()
console.log(`Screenshots written to ${outputRoot}, ${appStoreAssetsRoot} and ${androidOutputRoot}`)
}
finally {
for (const managed of managedProcesses.reverse())
await stopProcess(managed)
}