// 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: ///.png, checked for size. 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 (s.navigate && !['spa', 'reload'].includes(s.navigate)) e.push(`shots.capture.shots.${name}.navigate: spa | reload`); if (s.device && !['phone', 'tablet', 'both'].includes(s.device)) e.push(`shots.capture.shots.${name}.device: phone | tablet | both`); 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 = { 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., phone falls back to ios.device. */ export function simTarget(ios: NonNullable, device: 'phone' | 'tablet'): string | undefined { return device === 'tablet' ? ios.devices?.tablet : ios.devices?.phone ?? ios.device; } /** adb serial for a device class: android.serials., phone falls back to android.serial (undefined = the only device). */ export function adbTarget(and: NonNullable, device: 'phone' | 'tablet'): string | undefined { return device === 'tablet' ? and.serials?.tablet : and.serials?.phone ?? and.serial; } /** Which shots run on which device class. */ export function shotsFor(c: ShotsCapture, device: 'phone' | 'tablet', only: string[] = []): [string, ShotSpec][] { return Object.entries(c.shots ?? {}).filter(([name, s]) => (!only.length || only.includes(name)) && ((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 }; 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 }, 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 screencast frames (Chromium sends a frame only when the page changes): 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]; /** Screencast size for a frame: the frame at 2x (capped at 1600 px on the long side), even numbers. */ export function recordSize(d: { w: number; h: number }): { width: number; height: number } { const k = Math.min(2, 1600 / Math.max(d.w, d.h)); const even = (n: number) => Math.max(2, Math.round(n * k / 2) * 2); return { width: even(d.w), height: even(d.h) }; } // ---- 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, 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; }