shots record: gameplay clips from a web build (Chromium screencast), iOS Simulator, Android emulator or a folder
This commit is contained in:
parent
d36825e44f
commit
ea18406712
4 changed files with 319 additions and 4 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
221
cli/shots/record.ts
Normal 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}`);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue