storebound/cli/shots/devices.ts

300 lines
22 KiB
TypeScript

// Capture sources for raw frames, as pure argv builders (tests run them without a simulator or emulator) plus the
// config check. The IO lives in capture.ts / record.ts.
// web Playwright on a running web build (default)
// ios-sim xcrun simctl: boot, status bar override, launch with -AppleLanguages + your launch args / deep link,
// io screenshot. Any engine that builds for the iOS Simulator (Unity, Godot, Flutter, native, Capacitor).
// android-emu adb: demo-mode status bar, per-app language (Android 13+), am start with deep link / extras,
// exec-out screencap. Emulator or a USB device.
// folder bring your own frames: <frames>/<lang>/<phone|tablet>/<name>.png, checked for size.
import { validateSelector } from './selectors';
import type { ShotsCapture, ShotSpec, VideoRecord } from '../lib/config';
import { validateAuth } from './auth';
export type Source = 'web' | 'ios-sim' | 'android-emu' | 'folder';
export const SOURCES: Source[] = ['web', 'ios-sim', 'android-emu', 'folder'];
/** "{lang}" (and "{locale}", "{shot}") in deep links, URLs and launch args. */
export function fill(s: string, v: { lang: string; locale?: string; shot?: string }): string {
return s.replaceAll('{lang}', v.lang).replaceAll('{locale}', v.locale ?? v.lang).replaceAll('{shot}', v.shot ?? '');
}
/** Problems with shots.capture (pure). */
export function validateCapture(c: ShotsCapture | undefined): string[] {
if (!c) return [];
const e: string[] = [];
const src = c.source ?? 'web';
if (!SOURCES.includes(src)) return [`shots.capture.source: one of ${SOURCES.join(', ')}`];
if (src === 'web' && !/^https?:\/\//.test(c.baseUrl ?? '')) e.push('shots.capture.baseUrl: http(s) URL of the running app or game (source web)');
if (src === 'ios-sim') {
if (!c.ios?.bundleId) e.push('shots.capture.ios.bundleId: bundle id of the app on the simulator (source ios-sim)');
if (!c.ios?.device && !c.ios?.devices?.phone) e.push('shots.capture.ios.device: simulator name or UDID, e.g. "iPhone 17 Pro Max"');
}
if (c.ios?.warmupMs !== undefined && !(Number.isInteger(c.ios.warmupMs) && c.ios.warmupMs >= 0)) e.push('shots.capture.ios.warmupMs: milliseconds (integer >= 0)');
if (c.concurrency !== undefined && !(Number.isInteger(c.concurrency) && c.concurrency >= 1 && c.concurrency <= 32)) e.push('shots.capture.concurrency: integer 1-32');
if (c.auth) e.push(...validateAuth(c.auth));
if (c.session !== undefined && !['per-shot', 'per-language'].includes(c.session)) e.push('shots.capture.session: per-shot | per-language');
if (c.reuseTimeoutMs !== undefined && !(Number.isInteger(c.reuseTimeoutMs) && c.reuseTimeoutMs > 0)) e.push('shots.capture.reuseTimeoutMs: milliseconds (integer > 0)');
if (src === 'android-emu' && !c.android?.package) e.push('shots.capture.android.package: application id (source android-emu)');
if (src !== 'folder' && (!c.shots || !Object.keys(c.shots).length)) e.push('shots.capture.shots: at least one shot');
for (const [name, s] of Object.entries(c.shots ?? {})) {
if (!/^[A-Za-z0-9._-]+$/.test(name)) e.push(`shots.capture.shots.${name}: name must be a file name (letters, digits, . _ -)`);
if (src === 'web') {
if (s.waitSelector !== undefined) e.push(...validateSelector(`shots.capture.shots.${name}.waitSelector`, s.waitSelector));
if (s.click !== undefined) for (const [i, c] of (Array.isArray(s.click) && s.click.some(x => typeof x !== 'string') ? s.click : [s.click]).entries()) e.push(...validateSelector(`shots.capture.shots.${name}.click[${i}]`, c));
} else if (s.waitSelector !== undefined || s.click !== undefined) e.push(`shots.capture.shots.${name}: waitSelector / click only apply to source web`);
if (s.navigate && !['spa', 'reload'].includes(s.navigate)) e.push(`shots.capture.shots.${name}.navigate: spa | reload`);
if (s.device && !['phone', 'tablet', 'both', 'landscape'].includes(s.device)) e.push(`shots.capture.shots.${name}.device: phone | tablet | both | landscape`);
if (src === 'web' && s.path === undefined) e.push(`shots.capture.shots.${name}.path: URL path (source web)`);
if ((src === 'web' || src === 'folder') && (s.args || s.extras)) e.push(`shots.capture.shots.${name}: args / extras only apply to ios-sim / android-emu`);
for (const [k, v] of Object.entries(s.extras ?? {})) {
if (!/^[A-Za-z0-9._-]+$/.test(k)) e.push(`shots.capture.shots.${name}.extras.${k}: key must be letters, digits, . _ -`);
if (!['string', 'number', 'boolean'].includes(typeof v)) e.push(`shots.capture.shots.${name}.extras.${k}: string | number | boolean`);
}
}
const px = (v: unknown) => Number.isInteger(v) && (v as number) >= 0;
if (typeof c.cropTop === 'object' && c.cropTop !== null) {
for (const [k, v] of Object.entries(c.cropTop)) {
if (k !== 'phone' && k !== 'tablet') e.push(`shots.capture.cropTop.${k}: phone | tablet`);
else if (!px(v)) e.push(`shots.capture.cropTop.${k}: pixels (integer >= 0)`);
}
} else if (c.cropTop !== undefined && !px(c.cropTop)) e.push('shots.capture.cropTop: pixels (integer >= 0), or { phone, tablet }');
return e;
}
/** Problems with shots.video.record (pure). The web / simulator / emulator settings come from shots.capture. */
export function validateRecord(r: VideoRecord, cap: ShotsCapture | undefined): string[] {
const e: string[] = [];
const src = r.source ?? cap?.source ?? 'web';
if (!SOURCES.includes(src)) return [`shots.video.record.source: one of ${SOURCES.join(', ')}`];
if (r.device && r.device !== 'phone' && r.device !== 'tablet') e.push('shots.video.record.device: phone | tablet');
if (src === 'web' && !/^https?:\/\//.test(cap?.baseUrl ?? '')) e.push('shots.video.record: source web needs shots.capture.baseUrl');
if (src === 'ios-sim' && !cap?.ios?.bundleId) e.push('shots.video.record: source ios-sim needs shots.capture.ios (bundleId, device)');
if (src === 'android-emu' && !cap?.android?.package) e.push('shots.video.record: source android-emu needs shots.capture.android.package');
if (src !== 'folder' && !Object.keys(r.clips ?? {}).length) e.push('shots.video.record.clips: at least one clip');
for (const [name, c] of Object.entries(r.clips ?? {})) {
if (!/^[A-Za-z0-9._-]+$/.test(name)) e.push(`shots.video.record.clips.${name}: name must be a file name (letters, digits, . _ -)`);
if (!(typeof c.seconds === 'number' && c.seconds >= 1 && c.seconds <= 60)) e.push(`shots.video.record.clips.${name}.seconds: 1-60`);
if (src === 'web' && c.path === undefined) e.push(`shots.video.record.clips.${name}.path: URL path (source web)`);
}
return e;
}
/** Install hints for the external tools each source needs (shown when the tool is not on PATH). */
export const TOOL_HINTS: Record<string, string> = {
xcrun: 'xcrun not found: the iOS Simulator needs macOS with Xcode (App Store) and its command line tools (xcode-select --install)',
adb: 'adb not found: install the Android SDK platform-tools (Android Studio > SDK Manager) and set ANDROID_HOME, or put adb on PATH',
ffmpeg: 'ffmpeg not found: install it (brew install ffmpeg, apt install ffmpeg, winget install ffmpeg); it crops the status bar off the screenshots',
};
/** Simulator name / UDID for a device class: ios.devices.<device>, phone falls back to ios.device. */
export function simTarget(ios: NonNullable<ShotsCapture['ios']>, device: 'phone' | 'tablet'): string | undefined {
return device === 'tablet' ? ios.devices?.tablet : ios.devices?.phone ?? ios.device;
}
/** adb serial for a device class: android.serials.<device>, phone falls back to android.serial (undefined = the only device). */
export function adbTarget(and: NonNullable<ShotsCapture['android']>, device: 'phone' | 'tablet'): string | undefined {
return device === 'tablet' ? and.serials?.tablet : and.serials?.phone ?? and.serial;
}
/** Capture device classes: phone / tablet frames (status bar cut) and desktop landscape frames (Mac, Chromebook). */
export type CaptureDevice = 'phone' | 'tablet' | 'landscape';
export const CAPTURE_DEVICES: readonly CaptureDevice[] = ['phone', 'tablet', 'landscape'];
/** Which shots run on which device class. phone / tablet: device unset = phone, 'both' = phone + tablet (unchanged).
* landscape: the shots with device 'landscape'; with `allLandscape` (the project renders a landscape size, or
* --device landscape was asked for) also every shot with no device or 'both', because render needs a landscape frame
* for each scene. A shot that says 'phone' or 'tablet' explicitly stays off the landscape list. */
export function shotsFor(c: ShotsCapture, device: CaptureDevice, only: string[] = [], allLandscape = false): [string, ShotSpec][] {
return Object.entries(c.shots ?? {}).filter(([name, s]) => {
if (only.length && !only.includes(name)) return false;
if (device === 'landscape') return s.device === 'landscape' || (allLandscape && (s.device === undefined || s.device === 'both'));
return s.device !== 'landscape' && ((s.device ?? 'phone') === 'both' || (s.device ?? 'phone') === device);
});
}
// ---- iOS Simulator (xcrun simctl) ----
interface SimDevice { udid: string; name: string; state: string; isAvailable?: boolean }
/** `simctl list devices -j` -> the device matching a name or UDID: booted first, then the newest runtime. */
export function pickSimulator(listJson: string, want: string): SimDevice | null {
const j = JSON.parse(listJson) as { devices: Record<string, SimDevice[]> };
const runtimes = Object.keys(j.devices).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
const all = runtimes.flatMap(r => j.devices[r]!.filter(d => d.isAvailable !== false));
const hits = all.filter(d => d.udid === want || d.name === want);
return hits.find(d => d.state === 'Booted') ?? hits[0] ?? null;
}
export const STATUS_BAR = { time: '9:41', batteryState: 'charged', batteryLevel: 100, cellularBars: 4, wifiBars: 3 } as const;
export function simStatusBarArgs(udid: string): string[] {
return ['simctl', 'status_bar', udid, 'override', '--time', STATUS_BAR.time, '--batteryState', STATUS_BAR.batteryState,
'--batteryLevel', String(STATUS_BAR.batteryLevel), '--cellularMode', 'active', '--cellularBars', String(STATUS_BAR.cellularBars),
'--wifiMode', 'active', '--wifiBars', String(STATUS_BAR.wifiBars)];
}
/** Apple locale for -AppleLocale from a language id / App Store locale: "en-US" -> en_US, "tr" -> tr, "zh-Hans" -> zh-Hans. */
export const appleLocale = (l: string): string => (/^[a-z]{2,3}-[A-Z]{2}$/.test(l) ? l.replace('-', '_') : l);
/** `simctl launch` for one shot: language via -AppleLanguages / -AppleLocale, then your launch args ({lang} filled). */
export function simLaunchArgs(udid: string, bundleId: string, o: { lang: string; locale?: string; shot: string; args?: string[]; baseArgs?: string[] }): string[] {
const loc = o.locale ?? o.lang;
const extra = [...(o.baseArgs ?? []), ...(o.args ?? [])].map(a => fill(a, { lang: o.lang, locale: loc, shot: o.shot }));
return ['simctl', 'launch', '--terminate-running-process', udid, bundleId, '-AppleLanguages', `(${loc})`, '-AppleLocale', appleLocale(loc), ...extra];
}
export const simOpenUrlArgs = (udid: string, url: string): string[] => ['simctl', 'openurl', udid, url];
export const simBootArgs = (udid: string): string[] => ['simctl', 'boot', udid];
export const simBootStatusArgs = (udid: string): string[] => ['simctl', 'bootstatus', udid, '-b'];
export const simShutdownArgs = (udid: string): string[] => ['simctl', 'shutdown', udid];
export const simStatusBarClearArgs = (udid: string): string[] => ['simctl', 'status_bar', udid, 'clear'];
export const simInstallArgs = (udid: string, app: string): string[] => ['simctl', 'install', udid, app];
export const simScreenshotArgs = (udid: string, out: string): string[] => ['simctl', 'io', udid, 'screenshot', '--type=png', '--mask=ignored', out];
export const simRecordArgs = (udid: string, out: string): string[] => ['simctl', 'io', udid, 'recordVideo', '--codec=h264', '--mask=ignored', '--force', out];
// ---- Android (adb) ----
/** POSIX single-quote for `adb shell` (the remote side runs the string through sh). */
export const shQuote = (s: string): string => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(s) ? s : `'${s.replaceAll("'", `'\\''`)}'`);
const adb = (serial: string | undefined, ...rest: string[]): string[] => [...(serial ? ['-s', serial] : []), ...rest];
/** Demo mode: clean, fixed status bar (9:41, full battery, full signal, no notifications). */
export function adbDemoModeArgs(serial?: string): string[][] {
const b = (cmd: string, ...extra: string[]) => adb(serial, 'shell', 'am', 'broadcast', '-a', 'com.android.systemui.demo', '-e', 'command', cmd, ...extra);
return [
adb(serial, 'shell', 'settings', 'put', 'global', 'sysui_demo_allowed', '1'),
b('enter'),
b('clock', '-e', 'hhmm', '0941'),
b('battery', '-e', 'level', '100', '-e', 'plugged', 'false'),
b('network', '-e', 'wifi', 'show', '-e', 'level', '4'),
b('network', '-e', 'mobile', 'show', '-e', 'datatype', 'none', '-e', 'level', '4'),
b('notifications', '-e', 'visible', 'false'),
];
}
export const adbDemoExitArgs = (serial?: string): string[] => adb(serial, 'shell', 'am', 'broadcast', '-a', 'com.android.systemui.demo', '-e', 'command', 'exit');
/** BCP 47 tag for Android from a language id / Play code: "en-US" stays, "es-419" stays, "pt_BR" -> pt-BR. */
export const androidLocale = (l: string): string => l.replace('_', '-');
/** Per-app language (Android 13+, API 33): no root, no system locale change. Older devices: set the language in the
* game from a launch extra instead (see `extras` with "{lang}"). */
export const adbAppLocaleArgs = (pkg: string, lang: string, serial?: string): string[] => adb(serial, 'shell', 'cmd', 'locale', 'set-app-locales', pkg, '--locales', androidLocale(lang));
export const adbStateArgs = (serial?: string): string[] => adb(serial, 'get-state');
export const adbInstallArgs = (apk: string, serial?: string): string[] => adb(serial, 'install', '-r', apk);
export const adbForceStopArgs = (pkg: string, serial?: string): string[] => adb(serial, 'shell', 'am', 'force-stop', pkg);
/** `am start` for one shot: a deep link (VIEW intent to the package) or the launch activity, plus typed extras. */
export function adbStartArgs(pkg: string, o: { lang: string; locale?: string; shot: string; url?: string; activity?: string; extras?: Record<string, string | number | boolean> }, serial?: string): string[] {
const v = { lang: o.lang, locale: o.locale, shot: o.shot };
const parts = ['am', 'start', '-W'];
if (o.url) parts.push('-a', 'android.intent.action.VIEW', '-d', fill(o.url, v));
else parts.push('-n', `${pkg}/${o.activity ?? '.MainActivity'}`);
for (const [k, x] of Object.entries(o.extras ?? {})) {
if (typeof x === 'boolean') parts.push('--ez', k, String(x));
else if (typeof x === 'number' && Number.isInteger(x)) parts.push('--ei', k, String(x));
else if (typeof x === 'number') parts.push('--ef', k, String(x));
else parts.push('--es', k, fill(x, v));
}
if (o.url) parts.push(pkg);
return adb(serial, 'shell', parts.map(shQuote).join(' '));
}
export const adbScreencapArgs = (serial?: string): string[] => adb(serial, 'exec-out', 'screencap', '-p');
/** screenrecord stops by itself after `seconds` (Android caps it at 180). */
export const adbRecordArgs = (remote: string, seconds: number, serial?: string): string[] => adb(serial, 'shell', 'screenrecord', '--time-limit', String(Math.min(180, Math.max(1, Math.round(seconds)))), remote);
export const ADB_REMOTE_CLIP = '/sdcard/storebound-clip.mp4';
export const adbPullArgs = (remote: string, local: string, serial?: string): string[] => adb(serial, 'pull', remote, local);
export const adbRmArgs = (remote: string, serial?: string): string[] => adb(serial, 'shell', 'rm', '-f', remote);
// ---- clips ----
/** Clip file name: config order first, so `shots video` joins them in that order: 01-intro.mp4. */
export const clipName = (i: number, name: string): string => `${String(i + 1).padStart(2, '0')}-${name}.mp4`;
/** ffmpeg argv: cut [start, start + seconds) out of a raw recording, constant 30 fps H.264 (yuv420p, even size), no
* audio (`shots video` adds music or a silent track). simctl recordVideo writes a frame only when the screen changes,
* so a still ending is padded with the last frame up to `seconds`. Pure. */
export const trimArgs = (inp: string, out: string, start: number, seconds: number, fps = 30): string[] =>
['-v', 'error', '-y', ...(start > 0 ? ['-ss', start.toFixed(3)] : []), '-i', inp, '-t', seconds.toFixed(3),
'-vf', `tpad=stop_mode=clone:stop_duration=${seconds.toFixed(3)},fps=${fps},scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p`, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '16', '-an', '-movflags', '+faststart', out];
export interface ScreencastFrame { file: string; t: number }
/** ffmpeg concat-demuxer list for timestamped frames (`t` in seconds, frames come at an uneven rate): each frame lasts
* until the next one; the last one fills up to `seconds`. Pure. */
export function concatList(frames: ScreencastFrame[], seconds: number): string {
const t0 = frames[0]?.t ?? 0;
const q = (f: string) => `'${f.replaceAll("'", "'\\''")}'`;
const lines: string[] = [];
frames.forEach((f, k) => {
const next = frames[k + 1]?.t ?? t0 + Math.max(seconds, f.t - t0 + 1 / 30);
lines.push(`file ${q(f.file)}`, `duration ${Math.max(0.001, next - f.t).toFixed(4)}`);
});
if (frames.length) lines.push(`file ${q(frames.at(-1)!.file)}`);
return lines.join('\n') + '\n';
}
/** ffmpeg argv: a concat list of frames -> constant 30 fps H.264 clip of `seconds`. Pure. */
export const concatArgs = (list: string, out: string, seconds: number, fps = 30): string[] =>
['-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', list, '-t', seconds.toFixed(3),
'-vf', `fps=${fps},scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p`, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '16', '-an', '-movflags', '+faststart', out];
/** Long side cap of a web recording: the tallest store video (886 x 1920 iPhone preview, 1080 x 1920 Play promo), so
* `shots video` only scales down; a 3x phone (2706 px) or a 13" iPad (2752 px) would cost capture speed for pixels
* every output throws away. */
export const RECORD_MAX_SIDE = 1920;
/** Web capture for a frame: CDP `Page.captureScreenshot` with clip = the viewport in CSS px and `scale` = the frame's
* dpr (capped at RECORD_MAX_SIDE on the long side). Chromium returns CSS size x clip.scale whatever the context's
* deviceScaleFactor (checked on Chromium 153); its screencast stays at CSS size (maxWidth only shrinks), which is why
* the recorder does not use it. width / height = the expected image size. Pure. */
export function recordSize(d: { w: number; h: number; dpr?: number }): { width: number; height: number; scale: number } {
const scale = Math.min(d.dpr ?? 2, RECORD_MAX_SIDE / Math.max(d.w, d.h));
return { width: Math.round(d.w * scale), height: Math.round(d.h * scale), scale };
}
/** Guardrail: a first frame smaller than requested means the browser ignored the capture scale (the clip would be an
* upscale in `shots video`). Message or null. Pure. */
export function captureShortfall(got: { w: number; h: number }, want: { width: number; height: number }): string | null {
if (got.w >= want.width - 2 && got.h >= want.height - 2) return null;
return `frames are ${got.w}x${got.h}, asked for ${want.width}x${want.height}: this browser ignores the capture scale, so the clip is upscaled later (set STOREBOUND_CHROME to a current Chromium or record with --source folder)`;
}
// ---- frames ----
/** Status bar rows to cut from a full-screen capture so the frame matches the template (which draws its own bar).
* Default: the same share of the height the default phone frame uses (54 of 956 px on a 6.9" iPhone). */
export const defaultCropTop = (h: number, bar = 54, screen = 956): number => Math.round(h * bar / screen);
/** Rows to crop for a capture `h` px tall on a device class: shots.capture.cropTop (number or per device), else the
* bar share of that device's frame (bar / (frame height + bar)). */
export function cropTopFor(cropTop: ShotsCapture['cropTop'], device: 'phone' | 'tablet', h: number, dims: { h: number; bar: number }): number {
const set = typeof cropTop === 'number' ? cropTop : cropTop?.[device];
return set ?? defaultCropTop(h, dims.bar, dims.h + dims.bar);
}
/** ffmpeg argv: crop `top` rows, write 24-bit RGB PNG. */
export const cropArgs = (inp: string, out: string, top: number): string[] =>
['-v', 'error', '-y', '-i', inp, ...(top > 0 ? ['-vf', `crop=iw:ih-${top}:0:${top}`] : []), '-pix_fmt', 'rgb24', out];
export interface FrameIssue { file: string; msg: string }
/** Folder source: every frame the scenes use must exist per language / device, with the device's aspect ratio
* (within 3 %) and at least the frame's CSS width x 2 pixels wide. */
export function checkFrames(found: Map<string, { w: number; h: number }>, need: { lang: string; device: 'phone' | 'tablet'; name: string }[],
dims: Record<'phone' | 'tablet', { w: number; h: number }>): FrameIssue[] {
const out: FrameIssue[] = [];
for (const n of need) {
const rel = `${n.lang}/${n.device}/${n.name}`;
const f = found.get(rel);
if (!f) { out.push({ file: rel, msg: 'missing' }); continue; }
const d = dims[n.device];
const want = d.w / d.h, got = f.w / f.h;
if (Math.abs(got / want - 1) > 0.03) out.push({ file: rel, msg: `${f.w}x${f.h} has aspect ${got.toFixed(3)}, ${n.device} frames want ${want.toFixed(3)} (${d.w}x${d.h} or a multiple; crop the status bar)` });
else if (f.w < d.w * 2) out.push({ file: rel, msg: `${f.w}x${f.h} is small: at least ${d.w * 2} px wide keeps the store images sharp` });
}
return out;
}