storebound/cli/shots/record.ts

238 lines
16 KiB
TypeScript

// App or 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 JPEG frames of the page in Chromium through Playwright (CDP Page.captureScreenshot at the frame's device
// pixels, long side capped at 1920; 3 requests in flight, ~45-55 frames/s on an M1 Pro), each stamped
// with the middle of its request and joined by your ffmpeg at their real durations (no Playwright ffmpeg
// download needed). Not the screencast: it stays at CSS size. 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 app or game run 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, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
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, captureShortfall, 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 { imageInfo } from './spec';
import { CLIP_RE, clipFiles } from './video';
import * as rt from '../lib/rt';
/** 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));
}
/** Capture requests kept in flight for a web clip. */
const IN_FLIGHT = 3;
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);
// captureScreenshot, not the screencast (its frames stay at CSS size); a few requests in flight keep the
// rate near the page's own (one at a time reaches ~20 frames/s)
const cdp = await context.newCDPSession(page);
const clip = { x: 0, y: 0, width: d.w, height: d.h, scale: size.scale };
const frames: ScreencastFrame[] = [];
let n = 0;
const end = performance.now() + cl.seconds * 1000 + 300;
const grab = async () => {
while (performance.now() < end) {
const t0 = performance.now();
const r = await cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 92, optimizeForSpeed: true, clip }) as { data: string };
const file = join(dir, `f${String(++n).padStart(5, '0')}.jpg`);
frames.push({ file, t: (t0 + performance.now()) / 2000 });
await writeFile(file, Buffer.from(r.data, 'base64'));
}
};
await Promise.all(Array.from({ length: IN_FLIGHT }, grab));
await context.close();
if (frames.length < 2) throw new Error(`the capture delivered ${frames.length} frames`);
frames.sort((x, y) => x.t - y.t);
const short = captureShortfall(imageInfo(readFileSync(frames[0]!.file)), size);
if (short) warn('shots', `${lang}/${name}: ${short}`);
const rate = frames.length / (frames.at(-1)!.t - frames[0]!.t || 1);
if (rate < 20) warn('shots', `${lang}/${name}: only ${rate.toFixed(0)} frames/s captured (the clip will stutter): close heavy apps, or record with --source folder`);
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, ${size.width}x${size.height}, ${frames.length} frames, ${rate.toFixed(0)}/s)`);
} 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 && !rt.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 rt.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 rt.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 rt.sleep(cl.delayMs ?? 2500);
const p = rt.spawn(['xcrun', ...simRecordArgs(sim.udid, raw)], { stdout: 'ignore', stderr: 'pipe' });
await rt.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 rt.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}`);
}