storebound/cli/shots/capture.ts

278 lines
20 KiB
TypeScript

// 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|landscape] [--source web|ios-sim|android-emu|folder]
// [--dry [--probe]] [--keep-booted] [--concurrency N] [--only-missing | --force]
// Frames go to <shots.frames>/<lang>/<device>/<name>.png: the screen BELOW the status bar (the template draws one).
// Devices: phone, tablet and landscape (<lang>/landscape/<name>.png, web source only: Mac App Store and Chromebook
// screenshots; a desktop window, no status bar crop). The default device list adds landscape when the project renders a
// landscape size (apple.mac, play.chromebook, shots.landscape.playTablets); then shots with no device (and 'both') are
// captured for landscape too, since render needs a landscape frame per scene. A shot says device: 'landscape' to be
// landscape only, 'phone' / 'tablet' to stay out of it. ios-sim / android-emu skip landscape with a warning.
// shots.capture.source (default web):
// web headless Chromium (playwright), mobile viewport + touch at phone / tablet size, or a desktop viewport (no touch)
// at shots.landscape.size (default 1440 x 900 @2x) for landscape. 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. Signed-in apps:
// shots.capture.auth (a sign-in request or a storageState from `storebound shots login`), auth.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. Web shots may list waitSelector / click candidates per layout
// ({ phone, tablet, any }, data attributes first, text= fallback); --dry prints them per size, --dry --probe opens the
// pages and reports which candidate matched or MISSed on which size. --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, ShotSelector, ShotsCapture } from '../lib/config';
import { candidates } from './selectors';
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, CAPTURE_DEVICES, type CaptureDevice, 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, landscapeDims, rendersLandscape } from './lib';
import { captureWeb, defaultConcurrency, probeSelectors, hashPaths, hashServed, planWeb, readCache, selectJobs, summaryLines, writeCaptureState } from './capture-web';
import { imageInfo } from './spec';
import { authPlan, safeUrl } from './auth';
import * as rt from '../lib/rt';
type Device = CaptureDevice;
type PhoneTablet = '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) || rt.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: PhoneTablet, 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<void> {
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');
if (a.get('device') && !CAPTURE_DEVICES.includes(a.get('device') as Device)) throw new Error(`--device: ${CAPTURE_DEVICES.join(' | ')}`);
const landscapeOn = rendersLandscape(config);
let devices: Device[] = a.get('device') ? [a.get('device') as Device] : ['phone', 'tablet', ...(landscapeOn ? ['landscape' as const] : [])];
const allLandscape = landscapeOn || a.get('device') === 'landscape'; // shots with no device / 'both' also make a landscape frame
if (source !== 'web' && source !== 'folder' && devices.includes('landscape')) {
warn('shots', `landscape frames (Mac, Chromebook) are not supported from ${source}: skipped; capture them from a web build (--source web) or put them in <shots.frames>/<lang>/landscape/ (--source folder)`);
devices = devices.filter(d => d !== 'landscape');
}
const mobile = devices.filter((d): d is PhoneTablet => d !== 'landscape');
const framesDir = resolve(shots.frames);
const dims = { ...frameDims(config), landscape: landscapeDims(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, allLandscape).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 <lang>/${shots.layout === 'bleed' ? '<size>' : '<phone|tablet>'}/<name>.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, allLandscape)) {
progress(`${lang}/${device}/${name}.png <- ${cap.baseUrl}${(s.path ?? '/').replaceAll('{lang}', lang)}`);
const sel: [string, string[]][] = [['waitSelector', candidates(s.waitSelector, device)], ...(Array.isArray(s.click) && s.click.some(c => typeof c !== 'string') ? s.click : s.click === undefined ? [] : [s.click]).map((c, k) => [`click[${k}]`, candidates(c as ShotSelector, device)] as [string, string[]])];
for (const [what, list] of sel) if (list.length) log('shots', ` ${what} on ${device} ${dims[device].w}x${dims[device].h}: ${list.join(' | ')}`);
}
if (a.has('probe')) { // open the pages and test every candidate on each size (needs the app running at baseUrl)
need(cap.baseUrl, 'shots.capture.baseUrl');
const auth = cap.auth ? authPlan(cap.auth, { root, baseUrl: cap.baseUrl! }) : undefined;
const jobs = planWeb(cap, { langs, devices, only, dims, framesDir, build: '', fonts: '', allLandscape });
const browser = await launch(root, { headless: true, args: ['--mute-audio'] });
let r;
try { r = await probeSelectors(browser, jobs, { dims, ...(auth ? { auth } : {}) }); } finally { await closeBrowser(browser); }
for (const l of r.lines) log('shots', l);
if (r.missed) throw new Error(`${r.missed} selector lists matched nothing (see MISS lines above: the size and every candidate with its match count)`);
}
if (cap.auth) log('shots', cap.auth.storageState ? `would load the signed-in state from ${cap.auth.storageState} into every context` : `would sign every context in first: ${(cap.auth.request?.method ?? 'POST').toUpperCase()} ${safeUrl(cap.auth.request?.url ?? '')} (values from .storebound/credentials.json / env, never shown)`);
return done('shots', `capture planned (dry run): ${total} frames from web`);
}
need(cap.baseUrl, 'shots.capture.baseUrl');
const auth = cap.auth ? authPlan(cap.auth, { root, baseUrl: cap.baseUrl! }) : undefined;
if (auth) log('shots', cap.auth!.storageState ? `signed in from ${cap.auth!.storageState}` : `signing every context in: ${(cap.auth!.request!.method ?? 'POST').toUpperCase()} ${safeUrl(cap.auth!.request!.url)}`);
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, allLandscape, 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, ...(auth ? { auth } : {}) }); } 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 && !rt.which('xcrun') ? '{"devices":{}}' : sh('xcrun', ['simctl', 'list', 'devices', '-j']).out.toString();
for (const device of mobile) {
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 rt.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 rt.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 rt.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 mobile) {
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 rt.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}`);
}