shots record: gameplay clips from a web build (Chromium screencast), iOS Simulator, Android emulator or a folder

This commit is contained in:
CRTkafa 2026-10-10 22:22:52 +08:00
parent d36825e44f
commit ea18406712
4 changed files with 319 additions and 4 deletions

View file

@ -2,7 +2,7 @@
// do (see credentials.ts). The file default-exports a plain object; `import type { ShipConfig }` is optional. // do (see credentials.ts). The file default-exports a plain object; `import type { ShipConfig }` is optional.
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { dirname, isAbsolute, join, resolve } from 'node:path'; import { dirname, isAbsolute, join, resolve } from 'node:path';
import { validateCapture } from '../shots/devices'; import { validateCapture, validateRecord } from '../shots/devices';
export type ProductType = 'consumable' | 'non_consumable'; export type ProductType = 'consumable' | 'non_consumable';
@ -191,6 +191,37 @@ export interface VideoConfig {
/** cover (crop to fill, default) or contain (letterbox on `background`). */ /** cover (crop to fill, default) or contain (letterbox on `background`). */
fit?: 'cover' | 'contain'; fit?: 'cover' | 'contain';
background?: string; background?: string;
/** Gameplay clips recorded by `storebound shots record` into <clips>/<lang>/NN-<name>.mp4. */
record?: VideoRecord;
}
/** How `shots record` films clips. Uses shots.capture for the web URL / simulator / emulator settings. */
export interface VideoRecord {
/** Default: shots.capture.source (web when unset). folder = only list / check the clips you put there yourself. */
source?: 'web' | 'ios-sim' | 'android-emu' | 'folder';
/** Which frame size / simulator / emulator: phone (default) or tablet. */
device?: 'phone' | 'tablet';
/** Clip name -> what to film, in order. */
clips: Record<string, ClipSpec>;
}
export interface ClipSpec {
/** Length in seconds (1-60). */
seconds: number;
/** web: URL path after shots.capture.baseUrl ("{lang}" replaced), e.g. "/?level=3&autoplay=1". */
path?: string;
/** web: JS expression polled until true before filming starts. */
waitFor?: string;
/** web: JS run before filming starts. */
eval?: string[];
/** ios-sim / android-emu: deep link opened after launch. */
url?: string;
/** ios-sim: extra launch arguments ("{lang}", "{shot}" = the clip name). */
args?: string[];
/** android-emu: intent extras for `am start`. */
extras?: Record<string, string | number | boolean>;
/** Wait after launch before filming (ms). Default 1000 (web) / 2500 (simulator, emulator). */
delayMs?: number;
} }
export interface IconsConfig { export interface IconsConfig {
@ -350,6 +381,7 @@ export function validateConfig(c: unknown): string[] {
for (const t of v.targets ?? []) if (!['iphone', 'ipad', 'youtube', 'youtube-landscape'].includes(t)) e.push(`shots.video.targets: unknown "${t}" (iphone, ipad, youtube, youtube-landscape)`); for (const t of v.targets ?? []) if (!['iphone', 'ipad', 'youtube', 'youtube-landscape'].includes(t)) e.push(`shots.video.targets: unknown "${t}" (iphone, ipad, youtube, youtube-landscape)`);
if (v.fit && !['cover', 'contain'].includes(v.fit)) e.push('shots.video.fit: cover | contain'); if (v.fit && !['cover', 'contain'].includes(v.fit)) e.push('shots.video.fit: cover | contain');
for (const id of v.captionScenes ?? []) if (!sceneIds.has(id)) e.push(`shots.video.captionScenes: unknown scene "${id}"`); for (const id of v.captionScenes ?? []) if (!sceneIds.has(id)) e.push(`shots.video.captionScenes: unknown scene "${id}"`);
if (v.record) e.push(...validateRecord(v.record, s.capture));
} }
} }
if (cfg.icons) { if (cfg.icons) {

View file

@ -53,13 +53,13 @@ export function adbPath(): string {
} }
/** Throws the install hint when a tool is neither a path that exists nor on PATH. */ /** Throws the install hint when a tool is neither a path that exists nor on PATH. */
function requireTool(cmd: string, name = cmd): void { export function requireTool(cmd: string, name = cmd): void {
if (existsSync(cmd) || Bun.which(cmd)) return; if (existsSync(cmd) || Bun.which(cmd)) return;
throw new Error(TOOL_HINTS[name] ?? `${name} not found on PATH`); 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). */ /** Language ids -> the locale each source should use (App Store locale for iOS, first Play code for Android). */
function localeOf(config: ShipConfig, lang: string, source: Source): string { export function localeOf(config: ShipConfig, lang: string, source: Source): string {
const l = config.languages.find(x => x.id === lang); const l = config.languages.find(x => x.id === lang);
if (source === 'ios-sim') return l?.apple ?? lang; if (source === 'ios-sim') return l?.apple ?? lang;
const p = l?.play; const p = l?.play;

View file

@ -6,7 +6,7 @@
// android-emu adb: demo-mode status bar, per-app language (Android 13+), am start with deep link / extras, // 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. // exec-out screencap. Emulator or a USB device.
// folder bring your own frames: <frames>/<lang>/<phone|tablet>/<name>.png, checked for size. // folder bring your own frames: <frames>/<lang>/<phone|tablet>/<name>.png, checked for size.
import type { ShotsCapture, ShotSpec } from '../lib/config'; import type { ShotsCapture, ShotSpec, VideoRecord } from '../lib/config';
export type Source = 'web' | 'ios-sim' | 'android-emu' | 'folder'; export type Source = 'web' | 'ios-sim' | 'android-emu' | 'folder';
export const SOURCES: Source[] = ['web', 'ios-sim', 'android-emu', 'folder']; export const SOURCES: Source[] = ['web', 'ios-sim', 'android-emu', 'folder'];
@ -50,6 +50,24 @@ export function validateCapture(c: ShotsCapture | undefined): string[] {
return e; 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). */ /** Install hints for the external tools each source needs (shown when the tool is not on PATH). */
export const TOOL_HINTS: Record<string, string> = { 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)', xcrun: 'xcrun not found: the iOS Simulator needs macOS with Xcode (App Store) and its command line tools (xcode-select --install)',
@ -165,6 +183,50 @@ export const adbScreencapArgs = (serial?: string): string[] => adb(serial, 'exec
/** screenrecord stops by itself after `seconds` (Android caps it at 180). */ /** 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 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 ---- // ---- frames ----
/** Status bar rows to cut from a full-screen capture so the frame matches the template (which draws its own bar). /** Status bar rows to cut from a full-screen capture so the frame matches the template (which draws its own bar).

221
cli/shots/record.ts Normal file
View file

@ -0,0 +1,221 @@
// Gameplay clips for app previews / promo videos, from any engine.
// storebound shots record [--lang en,tr] [--only intro,combo] [--source web|ios-sim|android-emu|folder] [--dry] [--keep-booted]
// Config: shots.video.record { source?, device?, clips: { <name>: { seconds, path | url | args | extras, waitFor?, delayMs? } } };
// the URL / simulator / emulator settings are the ones of shots.capture. Out: <shots.video.clips>/<lang>/NN-<name>.mp4
// (NN = config order, so `shots video` joins them in that order): H.264, 30 fps, no audio.
// web Chromium screencast of the page through Playwright (JPEG frames at up to 2x the frame size, joined by
// your ffmpeg; no Playwright ffmpeg download needed). Filming starts when waitFor turns true (+ delayMs).
// ios-sim `xcrun simctl io <udid> recordVideo` while the app runs (launch args with -AppleLanguages, deep link),
// stopped with SIGINT after `seconds`. Boots the simulator if needed (shut down again afterwards unless
// --keep-booted), status bar 9:41, one warm-up launch after a cold boot (shots.capture.ios.warmupMs).
// android-emu `adb shell screenrecord --time-limit <seconds>` on the running emulator / device (never booted, wiped or
// rebooted by storebound), pulled and removed from the device. Demo-mode status bar, per-app language.
// folder your own clips (screen recorder, engine capture, OBS): lists <clips>/<lang>/ or <clips>/ with durations.
// Make the game playable hands-free for this (an autoplay / attract mode switch) or record yourself playing with
// --source folder. Apple: previews must show footage captured from the app itself.
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import type { ClipSpec, VideoRecord } from '../lib/config';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { done, log, step, warn } from '../lib/log';
import { launch } from './browser';
import { adbPath, localeOf, requireTool, sh } from './capture';
import { ADB_REMOTE_CLIP, adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, adbForceStopArgs, adbPullArgs, adbRecordArgs, adbRmArgs, adbStartArgs, adbStateArgs,
adbTarget, clipName, fill, pickSimulator, recordSize, simBootArgs, simBootStatusArgs, simLaunchArgs, simOpenUrlArgs, simRecordArgs, simShutdownArgs,
simStatusBarArgs, simStatusBarClearArgs, simTarget, SOURCES, type Source, trimArgs, validateRecord, concatArgs, concatList, type ScreencastFrame } from './devices';
import { frameDims } from './lib';
import { CLIP_RE, clipFiles } from './video';
/** Clips to film, in config order (with their index for the file name), limited by --only. */
export function clipsToRecord(r: VideoRecord, only: string[] = []): [number, string, ClipSpec][] {
return Object.entries(r.clips ?? {}).map(([n, c], i) => [i, n, c] as [number, string, ClipSpec]).filter(([, n]) => !only.length || only.includes(n));
}
const probeDuration = (file: string): number => {
const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file]);
return Number(r.stdout?.toString().trim()) || 0;
};
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const shots = need(config.shots, 'shots');
const vc = shots.video ?? {};
const a = ctx.args;
const dry = a.has('dry');
const rec: VideoRecord = vc.record ?? { clips: {} };
const cap = shots.capture;
const source = (a.get('source') ?? rec.source ?? cap?.source ?? 'web') as Source;
if (!SOURCES.includes(source)) throw new Error(`--source: one of ${SOURCES.join(', ')}`);
const problems = validateRecord({ ...rec, source }, cap);
if (problems.length) throw new Error(`shots.video.record:\n ${problems.join('\n ')}`);
const langs = a.list('lang').length ? a.list('lang') : config.languages.map(l => l.id);
const clipsDir = resolve(vc.clips ?? 'store/video/clips');
const device = (a.get('device') ?? rec.device ?? 'phone') as 'phone' | 'tablet';
const todo = clipsToRecord(rec, a.list('only'));
const tmpDir = join(root, '.storebound', 'tmp', 'record');
const total = langs.length * todo.length;
let i = 0, made = 0, failed = 0;
const progress = (what: string) => log('shots', step(++i, total, what));
const outFor = (lang: string, idx: number, name: string) => { const d = join(clipsDir, lang); if (!dry) mkdirSync(d, { recursive: true }); return join(d, clipName(idx, name)); };
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);
};
const finish = (raw: string, out: string, start: number, seconds: number) => {
sh('ffmpeg', trimArgs(raw, out, start, seconds), { timeout: 600_000 });
rmSync(raw, { force: true });
};
if (source === 'folder') {
let short = 0;
for (const lang of langs) {
const files = clipFiles(clipsDir, lang);
const durs = files.map(f => [f, probeDuration(f)] as const);
const sum = durs.reduce((t, [, d]) => t + d, 0);
log('shots', `${lang}: ${files.length} clips, ${sum.toFixed(1)} s${files.length ? '' : ` (put them in ${join(clipsDir, lang)} or ${clipsDir})`}`);
for (const [f, d] of durs) log('shots', ` ${f.slice(clipsDir.length + 1)} ${d ? d.toFixed(1) + ' s' : '(ffprobe could not read it)'}`);
if (sum < 15) short++;
}
if (short) warn('shots', `${short} language(s) have under 15 s of clips: App Store previews need 15-30 s`);
return done('shots', `folder clips listed: ${clipsDir}`);
}
if (!todo.length) throw new Error('nothing to record (shots.video.record.clips, --only)');
if (!dry) { mkdirSync(tmpDir, { recursive: true }); requireTool('ffmpeg'); }
if (source === 'web') {
const c = need(cap, 'shots.capture');
const d = frameDims(config)[device];
const size = recordSize(d);
if (dry) {
for (const lang of langs) for (const [idx, name, cl] of todo) progress(`${lang}/${clipName(idx, name)} <- ${c.baseUrl}${fill(cl.path ?? '/', { lang, shot: name })} (${cl.seconds} s, ${size.width}x${size.height})`);
return done('shots', `record planned (dry run): ${total} clips from web`);
}
const browser = await launch(root, { headless: true, args: ['--mute-audio', '--ignore-gpu-blocklist', '--enable-webgl', '--enable-gpu'] });
try {
for (const lang of langs) for (const [idx, name, cl] of todo) {
const q = (c.query ?? '').replaceAll('{lang}', lang);
const path = fill(cl.path ?? '/', { lang, shot: name });
const url = need(c.baseUrl, 'shots.capture.baseUrl').replace(/\/$/, '') + path + (q ? (path.includes('?') ? '&' : '?') + q : '');
const context = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: d.dpr, hasTouch: true, isMobile: true,
serviceWorkers: 'block', locale: lang });
const page = await context.newPage();
const dir = join(tmpDir, `web-${lang}-${name}`);
mkdirSync(dir, { recursive: true });
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 90000 });
if (cl.waitFor) await page.waitForFunction(cl.waitFor, null, { timeout: 120000, polling: 50 });
for (const js of cl.eval ?? []) await page.evaluate(js);
await page.waitForTimeout(cl.delayMs ?? 1000);
// Chromium's screencast (what Playwright's own recorder uses), straight to JPEG frames + system ffmpeg
const cdp = await context.newCDPSession(page);
const frames: ScreencastFrame[] = [];
let n = 0;
cdp.on('Page.screencastFrame', (f: { data: string; sessionId: number; metadata: { timestamp: number } }) => {
const file = join(dir, `f${String(++n).padStart(5, '0')}.jpg`);
writeFileSync(file, Buffer.from(f.data, 'base64'));
frames.push({ file, t: f.metadata.timestamp });
cdp.send('Page.screencastFrameAck', { sessionId: f.sessionId }).catch(() => {});
});
await cdp.send('Page.startScreencast', { format: 'jpeg', quality: 92, maxWidth: size.width, maxHeight: size.height, everyNthFrame: 1 });
await page.waitForTimeout(cl.seconds * 1000 + 300);
await cdp.send('Page.stopScreencast');
await context.close();
if (frames.length < 2) throw new Error(`the screencast delivered ${frames.length} frames (is the page animating?)`);
const list = join(dir, 'frames.txt');
writeFileSync(list, concatList(frames, cl.seconds));
sh('ffmpeg', concatArgs(list, outFor(lang, idx, name), cl.seconds), { timeout: 600_000 });
rmSync(dir, { recursive: true, force: true });
made++;
progress(`${lang}/${clipName(idx, name)} (${cl.seconds} s, ${frames.length} frames)`);
} catch (e) { failed++; i++; warn('shots', `${lang}/${name}: ${(e as Error).message.slice(0, 300)}`); await context.close().catch(() => {}); }
}
} finally { await browser.close(); }
}
if (source === 'ios-sim') {
const ios = need(cap?.ios, 'shots.capture.ios');
if (!dry) requireTool('xcrun');
const list = dry && !Bun.which('xcrun') ? '{"devices":{}}' : sh('xcrun', ['simctl', 'list', 'devices', '-j']).out.toString();
const want = simTarget(ios, device);
if (!want) throw new Error(`no shots.capture.ios.${device === 'tablet' ? 'devices.tablet' : 'device'}`);
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)`);
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.statusBar !== false) exec('xcrun', simStatusBarArgs(sim.udid));
const warmup = ios.warmupMs ?? 8000;
if (bootedByUs && warmup > 0) {
log('shots', `warming up ${ios.bundleId} (${warmup} ms)`);
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 [idx, name, cl] 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: cl.args }));
if (cl.url) { if (!dry) await Bun.sleep(1500); exec('xcrun', simOpenUrlArgs(sim.udid, fill(cl.url, { lang, locale, shot: name }))); }
const out = outFor(lang, idx, name);
const raw = join(tmpDir, `sim-${name}.mp4`);
if (dry) { log('shots', `would run: xcrun ${simRecordArgs(sim.udid, raw).join(' ')} (${cl.seconds} s, then SIGINT)`); progress(`${lang}/${clipName(idx, name)}`); continue; }
await Bun.sleep(cl.delayMs ?? 2500);
const p = Bun.spawn(['xcrun', ...simRecordArgs(sim.udid, raw)], { stdout: 'ignore', stderr: 'pipe' });
await Bun.sleep(cl.seconds * 1000 + 700);
p.kill('SIGINT');
await p.exited;
if (!existsSync(raw)) throw new Error(`simctl recordVideo wrote nothing: ${(await new Response(p.stderr).text()).trim().slice(0, 300)}`);
finish(raw, out, Math.max(0, probeDuration(raw) - cl.seconds), cl.seconds);
made++;
progress(`${lang}/${clipName(idx, name)} (${sim.name})`);
} catch (e) { failed++; i++; warn('shots', `${lang}/${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)`); 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');
const serial = adbTarget(and, device);
if (!dry && sh(adb, adbStateArgs(serial), { allowFail: true }).code !== 0) throw new Error(`no Android device${serial ? ` ${serial}` : ''} running (adb devices): start an emulator or plug in a device; storebound never boots one`);
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) exec(adb, adbAppLocaleArgs(and.package, locale, serial), { allowFail: true });
for (const [idx, name, cl] of todo) {
try {
exec(adb, adbForceStopArgs(and.package, serial));
exec(adb, adbStartArgs(and.package, { lang, locale, shot: name, url: cl.url, activity: and.activity, extras: cl.extras }, serial));
if (!dry) await Bun.sleep(cl.delayMs ?? 2500);
exec(adb, adbRecordArgs(ADB_REMOTE_CLIP, cl.seconds, serial), { timeout: (cl.seconds + 30) * 1000 });
const raw = join(tmpDir, `adb-${name}.mp4`);
exec(adb, adbPullArgs(ADB_REMOTE_CLIP, raw, serial), { timeout: 120_000 });
exec(adb, adbRmArgs(ADB_REMOTE_CLIP, serial), { allowFail: true });
if (dry) { progress(`${lang}/${clipName(idx, name)}`); continue; }
finish(raw, outFor(lang, idx, name), 0, cl.seconds);
made++;
progress(`${lang}/${clipName(idx, name)}${serial ? ` (${serial})` : ''}`);
} catch (e) { failed++; i++; warn('shots', `${lang}/${name}: ${(e as Error).message.slice(0, 300)}`); }
}
}
} finally {
if (and.demoMode !== false) exec(adb, adbDemoExitArgs(serial), { allowFail: true });
}
}
if (!dry) rmSync(tmpDir, { recursive: true, force: true });
for (const lang of langs) {
const d = join(clipsDir, lang);
const stale = existsSync(d) ? readdirSync(d).filter(f => CLIP_RE.test(f) && !Object.keys(rec.clips ?? {}).some((n, k) => f === clipName(k, n))) : [];
if (stale.length) warn('shots', `${lang}: other clips in ${d} are joined too: ${stale.join(', ')}`);
}
if (failed) throw new Error(`${failed} clips failed (${made} ok)`);
done('shots', dry ? `record planned (dry run): ${total} clips from ${source}` : `record done: ${made} clips in ${clipsDir}`);
}