// Raw frames from a running build of your app or game, from any engine. // storebound shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet] [--source web|ios-sim|android-emu|folder] // [--dry] [--keep-booted] [--concurrency N] [--only-missing | --force] // Frames go to ///.png: the screen BELOW the status bar (the template draws one). // shots.capture.source (default web): // web headless Chromium (playwright), mobile viewport + touch at phone / tablet size. Each shot: // { path, waitFor?: JS expression that turns true, eval?: [JS], delayMs?, device? }; URL = baseUrl + path + // query, "{lang}" replaced. Parallel contexts, per-language page reuse, the input-hash cache and the // per-frame timing (goto / waitFor / eval / delay / screenshot): capture-web.ts. // ios-sim xcrun simctl: boots the simulator (ios.device / ios.devices.tablet), optional install of ios.app, status // bar 9:41 / full battery / full bars, one warm-up launch after a cold boot (ios.warmupMs), then per shot: // launch ios.bundleId with -AppleLanguages (xx) -AppleLocale + ios.args + shot.args, open shot.url // (deep link) if given, wait, `simctl io screenshot`. // android-emu adb: demo mode status bar, per-app language (Android 13+, `cmd locale set-app-locales`), then per shot // force-stop + `am start` with shot.url (VIEW intent) or the launch activity + shot.extras, wait, // `exec-out screencap -p`. Works on an emulator or a USB device. Nothing is wiped or rebooted. // folder your own frames (any tool, any engine): only checks that every frame the scenes use exists with the // right aspect ratio and resolution. // A simulator storebound booted itself is shut down at the end (--keep-booted keeps it); one that was already running is // left running with its status bar restored. --dry prints the simctl / adb commands without running them. // Simulator / emulator screenshots are full screen: cropTop rows (the status bar) are cut so the frame fits the template // (default per device class from the frame dims: phone 162 px of a 6.9" iPhone, tablet 48 px of a 13" iPad with apple.ipad). // The app or game needs switches for this: language, scene, fixed seed, no ads / tutorial, freeze. See the store-screenshots skill. import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import type { ShipConfig, ShotsCapture } from '../lib/config'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { done, log, step, warn } from '../lib/log'; import { closeBrowser, launch } from './browser'; import { checkFrameFolder } from './check'; import { adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, adbForceStopArgs, adbInstallArgs, adbScreencapArgs, adbStartArgs, adbStateArgs, adbTarget, cropArgs, cropTopFor, fill, pickSimulator, shotsFor, simBootArgs, simBootStatusArgs, simInstallArgs, simLaunchArgs, simOpenUrlArgs, simScreenshotArgs, simShutdownArgs, simStatusBarArgs, simStatusBarClearArgs, simTarget, SOURCES, type Source, TOOL_HINTS, validateCapture } from './devices'; import { type CopyFile, copyText, type FrameDims, frameDims } from './lib'; import { captureWeb, defaultConcurrency, hashPaths, hashServed, planWeb, readCache, selectJobs, summaryLines, writeCaptureState } from './capture-web'; import { imageInfo } from './spec'; type Device = 'phone' | 'tablet'; export function sh(cmd: string, args: string[], o: { allowFail?: boolean; timeout?: number } = {}): { out: Buffer; err: string; code: number } { const r = spawnSync(cmd, args, { timeout: o.timeout ?? 120_000, maxBuffer: 256 * 1024 * 1024 }); const code = r.status ?? 1; if (code !== 0 && !o.allowFail) throw new Error(`${cmd} ${args.slice(0, 6).join(' ')} -> exit ${code}: ${(r.stderr?.toString() || r.error?.message || '').trim().slice(0, 400)}`); return { out: r.stdout ?? Buffer.alloc(0), err: r.stderr?.toString() ?? '', code }; } /** adb from ANDROID_HOME / ANDROID_SDK_ROOT / the default SDK folder, else PATH. */ export function adbPath(): string { const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT ?? join(homedir(), process.platform === 'darwin' ? 'Library/Android/sdk' : 'Android/Sdk'); const p = join(sdk, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb'); return existsSync(p) ? p : 'adb'; } /** Throws the install hint when a tool is neither a path that exists nor on PATH. */ export function requireTool(cmd: string, name = cmd): void { if (existsSync(cmd) || Bun.which(cmd)) return; throw new Error(TOOL_HINTS[name] ?? `${name} not found on PATH`); } /** Language ids -> the locale each source should use (App Store locale for iOS, first Play code for Android). */ export function localeOf(config: ShipConfig, lang: string, source: Source): string { const l = config.languages.find(x => x.id === lang); if (source === 'ios-sim') return l?.apple ?? lang; const p = l?.play; return (Array.isArray(p) ? p[0] : p) ?? lang; } /** Crop the status bar off a full-screen capture and store it as a 24-bit RGB frame. */ function storeFrame(tmp: string, out: string, cropTop: ShotsCapture['cropTop'], device: Device, dims: FrameDims): void { const { w, h } = imageInfo(readFileSync(tmp)); if (!h) throw new Error('screenshot is not a PNG'); const top = cropTopFor(cropTop, device, h, dims); sh('ffmpeg', cropArgs(tmp, out, top)); rmSync(tmp, { force: true }); const want = dims.w / dims.h, got = w / (h - top); if (Math.abs(got / want - 1) > 0.03) warn('shots', `${out}: ${w}x${h - top} after cropping ${top} rows has aspect ${got.toFixed(3)}, ${device} frames want ${want.toFixed(3)} (set shots.${device} to this device's screen in pt, or shots.capture.cropTop)`); } export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const shots = need(config.shots, 'shots'); const cap = need(shots.capture, 'shots.capture'); const a = ctx.args; const dry = a.has('dry'); const source = (a.get('source') ?? cap.source ?? 'web') as Source; if (!SOURCES.includes(source)) throw new Error(`--source: one of ${SOURCES.join(', ')}`); const problems = validateCapture({ ...cap, source }); if (problems.length) throw new Error(`shots.capture:\n ${problems.join('\n ')}`); const langs = a.list('lang').length ? a.list('lang') : config.languages.map(l => l.id); const only = a.list('only'); const devices: Device[] = a.get('device') ? [a.get('device') as Device] : ['phone', 'tablet']; if (devices.some(d => d !== 'phone' && d !== 'tablet')) throw new Error('--device: phone | tablet'); const framesDir = resolve(shots.frames); const dims = frameDims(config); const tmpDir = join(root, '.storebound', 'tmp'); const target = (lang: string, device: Device, name: string) => { const d = join(framesDir, lang, device); if (!dry) mkdirSync(d, { recursive: true }); return join(d, `${name}.png`); }; const total = langs.length * devices.reduce((t, d) => t + shotsFor(cap, d, only).length, 0); let failed = 0, n = 0, i = 0; const progress = (what: string) => log('shots', step(++i, total, what)); /** Runs a simctl / adb step, or prints it with --dry. */ const exec = (cmd: string, args: string[], o: { allowFail?: boolean; timeout?: number } = {}) => { if (dry) { log('shots', `would run: ${cmd === 'xcrun' || cmd === 'ffmpeg' ? cmd : 'adb'} ${args.join(' ')}`); return { out: Buffer.alloc(0), err: '', code: 0 }; } return sh(cmd, args, o); }; if (source === 'folder') { const dir = resolve(cap.folder ?? shots.frames); const issues = checkFrameFolder(config, dir, langs); for (const x of issues) warn('shots', `frames/${x.file}: ${x.msg}`); if (issues.length) throw new Error(`${issues.length} frame problems in ${dir} (expected //.png)`); if (dir !== framesDir) warn('shots', `frames are read from shots.frames (${shots.frames}); point it at ${cap.folder} or copy them there`); return done('shots', `folder frames ok: ${dir}`); } if (!dry) mkdirSync(tmpDir, { recursive: true }); if (source === 'web') { if (dry) { for (const lang of langs) for (const device of devices) for (const [name, s] of shotsFor(cap, device, only)) progress(`${lang}/${device}/${name}.png <- ${cap.baseUrl}${(s.path ?? '/').replaceAll('{lang}', lang)}`); return done('shots', `capture planned (dry run): ${total} frames from web`); } need(cap.baseUrl, 'shots.capture.baseUrl'); const concurrency = a.num('concurrency', cap.concurrency ?? defaultConcurrency('capture')); const builds = cap.build ? (Array.isArray(cap.build) ? cap.build : [cap.build]).map(p => resolve(p)) : []; const build = builds.length ? hashPaths(builds) : await hashServed(cap.baseUrl!); let copy: CopyFile = {}; try { copy = JSON.parse(readFileSync(resolve(shots.copy), 'utf8')) as CopyFile; } catch { /* no copy yet */ } const fontsCss = join(root, '.storebound', 'fonts', 'fonts.css'); const jobs = planWeb(cap, { langs, devices, only, dims, framesDir, build, copyText: l => copyText(l, copy[l]), fonts: existsSync(fontsCss) ? readFileSync(fontsCss, 'utf8') : '' }); const cache = readCache(root); const { run: todo, skipped } = selectJobs(jobs, cache, a.has('force') ? 'force' : a.has('only-missing') ? 'only-missing' : 'changed'); if (skipped.length) log('shots', `${skipped.length} of ${jobs.length} frames unchanged, skipped (${a.has('only-missing') ? '--only-missing' : 'same inputs as last time'}; --force captures them again)`); log('shots', `capturing ${todo.length} frames, ${Math.min(concurrency, todo.length) || 0} at a time${cap.session === 'per-language' ? ', one page per language' : ''}`); if (todo.length) { const browser = await launch(root, { headless: true, args: ['--mute-audio', '--ignore-gpu-blocklist', '--enable-webgl', '--enable-gpu'] }); let report; try { report = await captureWeb(browser, cap, todo, { concurrency, dims }); } finally { await closeBrowser(browser); } // capped close, then kill: Chrome can hang 10-30 s in its shutdown report.skipped = skipped.map(j => j.frame); writeCaptureState(root, report, cache, todo); for (const l of summaryLines(report)) log('shots', l); n += report.frames.filter(f => f.ok).length; failed += report.frames.filter(f => !f.ok).length; } } if (source === 'ios-sim') { const ios = need(cap.ios, 'shots.capture.ios'); if (!dry) { requireTool('xcrun'); requireTool('ffmpeg'); } const list = dry && !Bun.which('xcrun') ? '{"devices":{}}' : sh('xcrun', ['simctl', 'list', 'devices', '-j']).out.toString(); for (const device of devices) { const want = simTarget(ios, device); const todo = shotsFor(cap, device, only); if (!todo.length) continue; if (!want) { warn('shots', `no shots.capture.ios.devices.${device}: ${todo.length} ${device} shots skipped`); i += todo.length * langs.length; continue; } const sim = pickSimulator(list, want) ?? (dry ? { udid: want, name: want, state: 'Shutdown' } : null); if (!sim) throw new Error(`no simulator "${want}" (xcrun simctl list devices; create one in Xcode > Window > Devices and Simulators)`); const bootedByUs = sim.state !== 'Booted'; if (bootedByUs) { log('shots', `booting ${sim.name} (${sim.udid})`); exec('xcrun', simBootArgs(sim.udid), { allowFail: true }); exec('xcrun', simBootStatusArgs(sim.udid), { timeout: 300_000 }); } else log('shots', `using running simulator ${sim.name} (${sim.udid})`); try { if (ios.app) { log('shots', `installing ${ios.app}`); exec('xcrun', simInstallArgs(sim.udid, resolve(ios.app)), { timeout: 300_000 }); } if (ios.statusBar !== false) exec('xcrun', simStatusBarArgs(sim.udid)); const warmup = ios.warmupMs ?? 8000; if (bootedByUs && warmup > 0) { log('shots', `warming up ${ios.bundleId} (first launch after boot, ${warmup} ms; shots.capture.ios.warmupMs)`); exec('xcrun', simLaunchArgs(sim.udid, ios.bundleId, { lang: langs[0]!, locale: localeOf(config, langs[0]!, 'ios-sim'), shot: '', baseArgs: ios.args })); if (!dry) await Bun.sleep(warmup); } for (const lang of langs) for (const [name, s] of todo) { try { const locale = localeOf(config, lang, 'ios-sim'); exec('xcrun', simLaunchArgs(sim.udid, ios.bundleId, { lang, locale, shot: name, baseArgs: ios.args, args: s.args })); if (s.url) { if (!dry) await Bun.sleep(1500); exec('xcrun', simOpenUrlArgs(sim.udid, fill(s.url, { lang, locale, shot: name }))); } const out = target(lang, device, name); if (dry) { progress(`${lang}/${device}/${name}.png (${sim.name}, would wait ${s.delayMs ?? 2500} ms)`); continue; } await Bun.sleep(s.delayMs ?? 2500); const tmp = join(tmpDir, `sim-${name}.png`); sh('xcrun', simScreenshotArgs(sim.udid, tmp)); storeFrame(tmp, out, cap.cropTop, device, dims[device]); n++; progress(`${lang}/${device}/${name}.png (${sim.name})`); } catch (e) { failed++; i++; warn('shots', `${lang}/${device}/${name}: ${(e as Error).message.slice(0, 300)}`); } } } finally { if (ios.statusBar !== false) exec('xcrun', simStatusBarClearArgs(sim.udid), { allowFail: true }); if (bootedByUs && !a.has('keep-booted')) { log('shots', `shutting down ${sim.name} (booted by storebound; --keep-booted keeps it)`); exec('xcrun', simShutdownArgs(sim.udid), { allowFail: true }); } } } } if (source === 'android-emu') { const and = need(cap.android, 'shots.capture.android'); const adb = adbPath(); if (!dry) { requireTool(adb, 'adb'); requireTool('ffmpeg'); } for (const device of devices) { const todo = shotsFor(cap, device, only); if (!todo.length) continue; const serial = adbTarget(and, device); if (device === 'tablet' && !serial) { warn('shots', `no shots.capture.android.serials.tablet: ${todo.length} tablet shots skipped`); i += todo.length * langs.length; continue; } if (!dry && sh(adb, adbStateArgs(serial), { allowFail: true }).code !== 0) throw new Error(`no Android device${serial ? ` ${serial}` : ''} (adb devices): start an emulator or plug in a device; with several, set shots.capture.android.serial`); log('shots', `using Android device ${serial ?? '(the only one)'}`); if (and.apk) { log('shots', `installing ${and.apk}`); exec(adb, adbInstallArgs(resolve(and.apk), serial), { timeout: 300_000 }); } if (and.demoMode !== false) for (const args of adbDemoModeArgs(serial)) exec(adb, args, { allowFail: true }); try { for (const lang of langs) { const locale = localeOf(config, lang, 'android-emu'); if (and.appLocale !== false) { const r = exec(adb, adbAppLocaleArgs(and.package, locale, serial), { allowFail: true }); if (r.code !== 0) warn('shots', `per-app language needs Android 13+; set the language from a launch extra instead (${r.err.trim().slice(0, 120)})`); } for (const [name, s] of todo) { try { exec(adb, adbForceStopArgs(and.package, serial)); exec(adb, adbStartArgs(and.package, { lang, locale, shot: name, url: s.url, activity: and.activity, extras: s.extras }, serial)); const out = target(lang, device, name); if (dry) { progress(`${lang}/${device}/${name}.png (would wait ${s.delayMs ?? 2500} ms, screencap)`); continue; } await Bun.sleep(s.delayMs ?? 2500); const tmp = join(tmpDir, `adb-${name}.png`); writeFileSync(tmp, sh(adb, adbScreencapArgs(serial)).out); storeFrame(tmp, out, cap.cropTop, device, dims[device]); n++; progress(`${lang}/${device}/${name}.png${serial ? ` (${serial})` : ''}`); } catch (e) { failed++; i++; warn('shots', `${lang}/${device}/${name}: ${(e as Error).message.slice(0, 300)}`); } } } } finally { if (and.demoMode !== false) exec(adb, adbDemoExitArgs(serial), { allowFail: true }); } } } if (failed) throw new Error(`${failed} captures failed (${n} ok)`); done('shots', dry ? `capture planned (dry run): ${total} frames from ${source}` : `capture done: ${n} frames from ${source} in ${framesDir}`); }