shots: feature rotate, per-language frame fallback, layout-aware capture selectors

This commit is contained in:
CRTkafa 2026-10-24 13:00:48 +08:00
parent 2e1c7cdc3c
commit 61b5fa0623
15 changed files with 355 additions and 17 deletions

View file

@ -1,6 +1,6 @@
// Raw frames from a running build of your app or game, from any engine.
// storebound shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet] [--source web|ios-sim|android-emu|folder]
// [--dry] [--keep-booted] [--concurrency N] [--only-missing | --force]
// [--dry [--probe]] [--keep-booted] [--concurrency N] [--only-missing | --force]
// Frames go to <shots.frames>/<lang>/<device>/<name>.png: the screen BELOW the status bar (the template draws one).
// shots.capture.source (default web):
// web headless Chromium (playwright), mobile viewport + touch at phone / tablet size. Each shot:
@ -18,7 +18,9 @@
// folder your own frames (any tool, any engine): only checks that every frame the scenes use exists with the
// right aspect ratio and resolution.
// A simulator storebound booted itself is shut down at the end (--keep-booted keeps it); one that was already running is
// left running with its status bar restored. --dry prints the simctl / adb commands without running them.
// left running with its status bar restored. Web shots may list waitSelector / click candidates per layout
// ({ phone, tablet, any }, data attributes first, text= fallback); --dry prints them per size, --dry --probe opens the
// pages and reports which candidate matched or MISSed on which size. --dry prints the simctl / adb commands without running them.
// Simulator / emulator screenshots are full screen: cropTop rows (the status bar) are cut so the frame fits the template
// (default per device class from the frame dims: phone 162 px of a 6.9" iPhone, tablet 48 px of a 13" iPad with apple.ipad).
// The app or game needs switches for this: language, scene, fixed seed, no ads / tutorial, freeze. See the store-screenshots skill.
@ -26,7 +28,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node
import { homedir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import type { ShipConfig, ShotsCapture } from '../lib/config';
import type { ShipConfig, ShotSelector, ShotsCapture } from '../lib/config';
import { candidates } from './selectors';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { done, log, step, warn } from '../lib/log';
@ -36,7 +39,7 @@ import { adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, adbForceStopArgs, a
cropArgs, cropTopFor, fill, pickSimulator, shotsFor, simBootArgs, simBootStatusArgs, simInstallArgs, simLaunchArgs, simOpenUrlArgs,
simScreenshotArgs, simShutdownArgs, simStatusBarArgs, simStatusBarClearArgs, simTarget, SOURCES, type Source, TOOL_HINTS, validateCapture } from './devices';
import { type CopyFile, copyText, type FrameDims, frameDims } from './lib';
import { captureWeb, defaultConcurrency, hashPaths, hashServed, planWeb, readCache, selectJobs, summaryLines, writeCaptureState } from './capture-web';
import { captureWeb, defaultConcurrency, probeSelectors, hashPaths, hashServed, planWeb, readCache, selectJobs, summaryLines, writeCaptureState } from './capture-web';
import { imageInfo } from './spec';
import { authPlan, safeUrl } from './auth';
import * as rt from '../lib/rt';
@ -122,7 +125,21 @@ export async function run(ctx: Ctx): Promise<void> {
if (source === 'web') {
if (dry) {
for (const lang of langs) for (const device of devices) for (const [name, s] of shotsFor(cap, device, only)) progress(`${lang}/${device}/${name}.png <- ${cap.baseUrl}${(s.path ?? '/').replaceAll('{lang}', lang)}`);
for (const lang of langs) for (const device of devices) for (const [name, s] of shotsFor(cap, device, only)) {
progress(`${lang}/${device}/${name}.png <- ${cap.baseUrl}${(s.path ?? '/').replaceAll('{lang}', lang)}`);
const sel: [string, string[]][] = [['waitSelector', candidates(s.waitSelector, device)], ...(Array.isArray(s.click) && s.click.some(c => typeof c !== 'string') ? s.click : s.click === undefined ? [] : [s.click]).map((c, k) => [`click[${k}]`, candidates(c as ShotSelector, device)] as [string, string[]])];
for (const [what, list] of sel) if (list.length) log('shots', ` ${what} on ${device} ${dims[device].w}x${dims[device].h}: ${list.join(' | ')}`);
}
if (a.has('probe')) { // open the pages and test every candidate on each size (needs the app running at baseUrl)
need(cap.baseUrl, 'shots.capture.baseUrl');
const auth = cap.auth ? authPlan(cap.auth, { root, baseUrl: cap.baseUrl! }) : undefined;
const jobs = planWeb(cap, { langs, devices, only, dims, framesDir, build: '', fonts: '' });
const browser = await launch(root, { headless: true, args: ['--mute-audio'] });
let r;
try { r = await probeSelectors(browser, jobs, { dims, ...(auth ? { auth } : {}) }); } finally { await closeBrowser(browser); }
for (const l of r.lines) log('shots', l);
if (r.missed) throw new Error(`${r.missed} selector lists matched nothing (see MISS lines above: the size and every candidate with its match count)`);
}
if (cap.auth) log('shots', cap.auth.storageState ? `would load the signed-in state from ${cap.auth.storageState} into every context` : `would sign every context in first: ${(cap.auth.request?.method ?? 'POST').toUpperCase()} ${safeUrl(cap.auth.request?.url ?? '')} (values from .storebound/credentials.json / env, never shown)`);
return done('shots', `capture planned (dry run): ${total} frames from web`);
}