// 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: { : { seconds, path | url | args | extras, waitFor?, delayMs? } } }; // the URL / simulator / emulator settings are the ones of shots.capture. Out: //NN-.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 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 ` 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 // or / 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, 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 { 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}`); }