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

@ -167,6 +167,10 @@ export interface ShotsConfig {
copy: string;
/** Raw frames: <frames>/<languageId>/<phone|tablet>/<name>.png */
frames: string;
/** true: every language needs its own frames (<frames>/<lang>/...). Default false: a language with copy but no frames
* folder uses the default language's frames (play.defaultLanguage, else the first language); `shots check` / `render`
* say so as an info line. */
perLanguageFrames?: boolean;
/** Output: <out>/<languageId>/<size>/NN.png and feature/1024x500.png */
out: string;
/** Optional logo image for the hero scene and the feature graphic. */
@ -183,6 +187,9 @@ export interface ShotsConfig {
* bottom edge on purpose (shots check then allows it). `scale` (0.5-1.5, default 1) multiplies the phone's width
* (still clamped to the canvas with 'contain'). */
feature?: { frame: string; palette?: string | Palette; fit?: 'contain' | 'bleed'; scale?: number;
/** Degrees added to the phone's tilt (layout "bleed": the frame image is turned), like a scene frame's `rotate`: -90 stands a
* landscape game frame upright in a phone lying on its side. Default 0. */
rotate?: number;
/** layout "bleed": the name on the feature graphic when there is no logo (default app.name). */
title?: string;
/** Photo background for the feature graphic (path relative to the config, or { image, overlay }); wins over the
@ -207,7 +214,16 @@ export interface ShotsConfig {
}
/** One raw frame. Web: `path` (+ query). Simulator / emulator: `url` deep link and / or `args` / `extras`. */
/** Web capture: a selector, candidates tried in order, or per layout { phone, tablet, any } (own list first). Prefer data
* attributes (`[data-shot="shop-row"]`); `text=Shop` and `role=button[name="Shop"]` work as fallbacks. */
export type ShotSelector = string | string[] | { phone?: string | string[]; tablet?: string | string[]; any?: string | string[] };
export interface ShotSpec {
/** Web: wait (up to the waitFor timeout) until one of these matches, before waitFor; a miss names the selector and the size. */
waitSelector?: ShotSelector;
/** Web: element(s) to click after waitSelector, in order (a list of selectors is ONE click with fallbacks; use
* [{...}, {...}] objects for several clicks). */
click?: ShotSelector | ShotSelector[];
/** Web: URL path after baseUrl, "{lang}" replaced. */
path?: string;
/** ios-sim / android-emu: deep link opened after launch, e.g. "mygame://shot/{shot}?lang={lang}". */
@ -1259,6 +1275,7 @@ export function validateConfig(c: unknown): string[] {
if (s.feature !== undefined) {
if (!s.feature || typeof s.feature.frame !== 'string' || !s.feature.frame) e.push('shots.feature.frame: a frame name');
if (s.feature?.fit !== undefined && s.feature.fit !== 'contain' && s.feature.fit !== 'bleed') e.push("shots.feature.fit: 'contain' | 'bleed'");
if (s.feature?.rotate !== undefined && !(typeof s.feature.rotate === 'number' && Math.abs(s.feature.rotate) <= 360)) e.push('shots.feature.rotate: a number of degrees from -360 to 360');
if (s.feature?.scale !== undefined && !(typeof s.feature.scale === 'number' && s.feature.scale >= 0.5 && s.feature.scale <= 1.5)) e.push('shots.feature.scale: a number from 0.5 to 1.5');
e.push(...featureBackgroundIssues(s.feature?.background));
if (s.feature?.palette !== undefined) e.push(...paletteIssues(s.feature.palette, 'shots.feature.palette'));

File diff suppressed because one or more lines are too long

View file

@ -24,6 +24,7 @@ import type { ShotSpec, ShotsCapture } from '../lib/config';
import { log, warn } from '../lib/log';
import { type AuthPlan, expiredHint, landedOnLogin, maskSecrets, safeUrl } from './auth';
import { shotsFor } from './devices';
import { applySelectors, candidates, probe } from './selectors';
import type { FrameDims } from './lib';
type Device = 'phone' | 'tablet';
@ -166,6 +167,7 @@ export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[]
if (!expired.has(safeUrl(s.page.url()))) { expired.add(safeUrl(s.page.url())); warn('shots', `${j.lang}/${j.device}/${j.name}: ${mask(expiredHint(s.page.url()))}`); }
throw new Error(`landed on the sign-in page ${safeUrl(s.page.url())}`);
}
await applySelectors(s.page, j.spec, { device: j.device, w: o.dims[j.device].w, h: o.dims[j.device].h }, waitTimeout); // waitSelector, click: a miss names the selector and the size
if (j.spec.waitFor) await s.page.waitForFunction(j.spec.waitFor, null, { timeout: waitTimeout, polling: 50 });
t.waitFor = Date.now() - t0; t0 = Date.now();
for (const js of j.spec.eval ?? []) await s.page.evaluate(js);
@ -245,3 +247,32 @@ export function writeCaptureState(root: string, report: CaptureReport, cache: Ca
writeFileSync(cachePath(root), JSON.stringify(Object.fromEntries(Object.entries(cache).sort()), null, 1) + '\n');
writeFileSync(join(root, '.storebound', 'capture-report.json'), JSON.stringify(report, null, 1) + '\n');
}
/* eslint-disable @typescript-eslint/no-explicit-any */
/** `shots capture --dry --probe`: opens each page (no screenshot), runs the shot's waitFor, and reports per size which
* selector candidates match. Returns the log lines; `missed` counts shots where no candidate of a list matched. */
export async function probeSelectors(browser: any, jobs: WebJob[], o: { dims: Record<Device, FrameDims>; auth?: AuthPlan; timeoutMs?: number }): Promise<{ lines: string[]; missed: number }> {
const lines: string[] = []; let missed = 0;
for (const j of jobs) {
const lists = [['waitSelector', candidates(j.spec.waitSelector, j.device)], ...(Array.isArray(j.spec.click) && j.spec.click.some(c => typeof c !== 'string') ? j.spec.click : j.spec.click === undefined ? [] : [j.spec.click])
.map((c, i) => [`click[${i}]`, candidates(c as any, j.device)] as const)].filter(([, l]) => l.length) as [string, string[]][];
if (!lists.length) continue;
const d = o.dims[j.device];
const c = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: 1, hasTouch: true, isMobile: true, serviceWorkers: 'block', locale: j.lang, ...(o.auth?.contextOptions ?? {}) });
try {
if (o.auth) await o.auth.prepare(c);
const page = await c.newPage();
await page.goto(j.url, { waitUntil: 'domcontentloaded', timeout: 90000 });
if (j.spec.waitFor) await page.waitForFunction(j.spec.waitFor, null, { timeout: o.timeoutMs ?? 20000, polling: 50 }).catch(() => lines.push(`${j.frame} ${d.w}x${d.h}: waitFor did not become true in ${(o.timeoutMs ?? 20000) / 1000}s`));
for (const [what, list] of lists) {
await page.waitForTimeout(500);
const res = await probe(page, list);
const hit = res.find(r => r.matches > 0);
if (!hit) missed++;
lines.push(`${j.frame} ${j.device} ${d.w}x${d.h} ${what}: ${hit ? `ok via ${hit.selector}` : 'MISS'} (${res.map(r => `${r.selector} -> ${r.matches < 0 ? 'invalid' : r.matches}`).join('; ')})`);
}
} catch (e) { missed++; lines.push(`${j.frame}: probe failed: ${(e as Error).message.split('\n')[0]!.slice(0, 200)}`); } finally { await c.close(); }
}
return { lines, missed };
}
/* eslint-enable @typescript-eslint/no-explicit-any */

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`);
}

View file

@ -15,7 +15,7 @@ import type { ShipConfig } from '../lib/config';
import type { Ctx } from '../lib/ctx';
import { log, printJson } from '../lib/log';
import { checkFrames, type FrameIssue } from './devices';
import { defaultSizes, frameDims, renderDims, SIZES } from './lib';
import { defaultSizes, frameDims, frameLangs, langHasFrames, renderDims, SIZES } from './lib';
import type { LocalIssue, RendersCheck } from '../lib/validate-rules';
import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders, storeOf } from './report';
import { imageInfo } from './spec';
@ -125,9 +125,13 @@ export async function run(ctx: Ctx): Promise<void> {
const tablets = resolvedTablets(config, resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'));
const issues = [...allIssues(scan), ...localFontIssues(readManifest(fontsDirOf(root))).map(issue => ({ where: '.storebound/fonts/fonts.json', issue })),
...backgroundIssues(config, resolve, tablets)];
const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, resolve(cap.folder ?? config.shots!.frames), scan.langs, tablets) : [];
// languages with copy but no frames use the default language's (shots.perLanguageFrames: true requires them per language)
const framesRoot = resolve(cap?.folder ?? config.shots!.frames), def = config.play?.defaultLanguage ?? config.languages[0]!.id;
const fl = frameLangs(scan.langs, l => langHasFrames(framesRoot, l), def, config.shots?.perLanguageFrames);
const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, framesRoot, scan.langs.filter(l => !fl.borrowed.includes(l)), tablets) : [];
if (fl.borrowed.length && !ctx.args.has('json')) console.log(`info frames: no frames for ${fl.borrowed.join(', ')}; they use the ${def} frames (shots.perLanguageFrames: true requires per-language frames)`);
if (ctx.args.has('json')) {
printJson({ images: scan.images.length, videos: scan.videos.length, issues, frames: frameIssues });
printJson({ images: scan.images.length, videos: scan.videos.length, issues, frames: frameIssues, info: fl.borrowed.map(l => ({ lang: l, framesFrom: def })) });
} else {
for (const { where, issue } of issues) console.log(`${issue.level === 'error' ? 'ERROR' : 'warn '} ${where}: ${issue.msg}`);
for (const f of frameIssues) console.log(`ERROR frames/${f.file}: ${f.msg}`);

View file

@ -6,6 +6,7 @@
// 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.
// folder bring your own frames: <frames>/<lang>/<phone|tablet>/<name>.png, checked for size.
import { validateSelector } from './selectors';
import type { ShotsCapture, ShotSpec, VideoRecord } from '../lib/config';
import { validateAuth } from './auth';
@ -37,6 +38,10 @@ export function validateCapture(c: ShotsCapture | undefined): string[] {
if (src !== 'folder' && (!c.shots || !Object.keys(c.shots).length)) e.push('shots.capture.shots: at least one shot');
for (const [name, s] of Object.entries(c.shots ?? {})) {
if (!/^[A-Za-z0-9._-]+$/.test(name)) e.push(`shots.capture.shots.${name}: name must be a file name (letters, digits, . _ -)`);
if (src === 'web') {
if (s.waitSelector !== undefined) e.push(...validateSelector(`shots.capture.shots.${name}.waitSelector`, s.waitSelector));
if (s.click !== undefined) for (const [i, c] of (Array.isArray(s.click) && s.click.some(x => typeof x !== 'string') ? s.click : [s.click]).entries()) e.push(...validateSelector(`shots.capture.shots.${name}.click[${i}]`, c));
} else if (s.waitSelector !== undefined || s.click !== undefined) e.push(`shots.capture.shots.${name}: waitSelector / click only apply to source web`);
if (s.navigate && !['spa', 'reload'].includes(s.navigate)) e.push(`shots.capture.shots.${name}.navigate: spa | reload`);
if (s.device && !['phone', 'tablet', 'both'].includes(s.device)) e.push(`shots.capture.shots.${name}.device: phone | tablet | both`);
if (src === 'web' && s.path === undefined) e.push(`shots.capture.shots.${name}.path: URL path (source web)`);

View file

@ -37,6 +37,12 @@ await document.fonts.ready;
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
const overflow = [];
const img = c.querySelector('img.frame');
// shots.feature.rotate: turn the frame like a scene frame's rotate; a quarter turn swaps the box so the image still covers the canvas
const rot = Number(job.rotate) || 0;
if (rot) {
const quarter = Math.abs(rot % 180) === 90, W = quarter ? 500 : 1024, H = quarter ? 1024 : 500;
Object.assign(img.style, { inset: 'auto', left: (512 - W / 2) + 'px', top: (250 - H / 2) + 'px', width: W + 'px', height: H + 'px', transform: `rotate(${rot}deg)` });
}
if (!img.naturalWidth) overflow.push(`feature frame ${job.frame} did not load`);
else if (img.naturalWidth < 1024) overflow.push(`feature frame ${img.naturalWidth}x${img.naturalHeight} is upscaled: capture it at least 1024 px wide`);
const t = c.querySelector('.name, .logo').getBoundingClientRect();

View file

@ -6,7 +6,7 @@
// sublines, badge labels; plus the Noto face of a non-Latin script). A character the stack lacks gets an automatic
// Noto fallback (logged; `shots check` errors on renders that used it unless shots.fonts.fallback lists it); a
// character no fallback covers fails with the language and the characters.
// storebound shots fonts [--json] (re-run after editing the copy or the fonts)
// storebound shots fonts [--lang en,tr] [--json] (--lang limits the report to those languages; the manifest keeps all) (re-run after editing the copy or the fonts)
// Out: <project>/.storebound/fonts/*.woff2 + fonts.css + fonts.json (faces and the per-language stacks, read by render).
// The Google fonts are SIL Open Font License 1.1. A local font file in shots.fonts (local-fonts.ts) gets the same cmap
// check, is read in place (never copied) and recorded with its path, sha256 and license note.
@ -107,7 +107,10 @@ export async function run(ctx: Ctx): Promise<void> {
}
for (const f of Object.values(local)) if (!f.license) warn('shots', `local font ${f.path} has no license note: add license ('OFL-1.1', 'proprietary', ...) to its shots.fonts entry`);
let auto = 0;
for (const [lang, l] of Object.entries(manifest.langs)) for (const f of l.fallbacks) {
const only = ctx.args.list('lang');
const unknown = only.filter(l => !(l in manifest.langs));
if (unknown.length) throw new Error(`--lang: no copy or config language ${unknown.join(', ')} (known: ${Object.keys(manifest.langs).join(', ')})`);
for (const [lang, l] of Object.entries(manifest.langs)) for (const f of only.length && !only.includes(lang) ? [] : l.fallbacks) {
const ok = manifest.faces[f.face]?.chosen;
if (!ok) auto++;
const base = f.role === 'title' ? title : body;

View file

@ -1,4 +1,6 @@
// Pure parts of the screenshot pipeline: store sizes, palettes, per-language script fonts, copy validation, job data.
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { specFamily } from './local-fonts';
import { bgIssue } from './palette';
import type { Palette, ShipConfig, ShotScene, ShotsConfig } from '../lib/config';
@ -280,8 +282,9 @@ export interface FeatureLayout { left: number; top: number; width: number; heigh
* side, centred vertically; `scale` (default 1) multiplies the design width before that clamp. fit 'bleed': the design
* width x scale at the old fixed top, cropped by the canvas edge on purpose. */
export function featureLayout(style: 'game' | 'app', dev: { w: number; h: number; bar: number },
o: { fit?: 'contain' | 'bleed'; scale?: number } = {}): FeatureLayout {
const d = FEATURE_DEVICE[style], C = FEATURE_CANVAS, fit = o.fit ?? 'contain';
o: { fit?: 'contain' | 'bleed'; scale?: number; rotate?: number } = {}): FeatureLayout {
const base = FEATURE_DEVICE[style], C = FEATURE_CANVAS, fit = o.fit ?? 'contain';
const d = { ...base, angle: base.angle + (o.rotate ?? 0) }; // shots.feature.rotate adds to the template's tilt, like a scene frame's rotate
const k = (dev.h + dev.bar) / dev.w;
let width = d.width * (o.scale ?? 1);
if (fit === 'contain') {
@ -329,3 +332,24 @@ export function framesForRender(scenes: { frames: { src: string }[] }[], sizes:
}
return [...out].sort();
}
/** Which language's frames each language loads (shots.perLanguageFrames false, the default): its own when
* <frames>/<lang> holds any file, else the default language's. `hasFrames` is injected so tests need no disk. */
export function frameLangs(langs: string[], hasFrames: (lang: string) => boolean, def: string, perLanguage = false): { map: Record<string, string>; borrowed: string[] } {
const map: Record<string, string> = {}, borrowed: string[] = [];
for (const l of langs) {
if (perLanguage || l === def || hasFrames(l)) map[l] = l;
else if (hasFrames(def)) { map[l] = def; borrowed.push(l); }
else map[l] = l; // nothing anywhere: the normal missing-frames error names it
}
return { map, borrowed };
}
/** true when <dir>/<lang> holds at least one file (any depth up to 3). */
export function langHasFrames(dir: string, lang: string): boolean {
const walk = (d: string, depth: number): boolean => {
let ents; try { ents = readdirSync(d, { withFileTypes: true }); } catch { return false; }
return ents.some(e => e.isFile() || (depth < 3 && e.isDirectory() && walk(join(d, e.name), depth + 1)));
};
return walk(join(dir, lang), 0);
}

View file

@ -28,7 +28,7 @@ import { done, log, warn } from '../lib/log';
import { closeBrowser, launch } from './browser';
import { defaultConcurrency, pool, progressLine } from './capture-web';
import { shotsStyle } from '../lib/config';
import { type CopyFile, type DeviceBox, type TextBox, SIZES, renderDims, sheetTimeoutMs, featureLayout, copyText, defaultSizes, frameDims, framesForRender, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib';
import { type CopyFile, type DeviceBox, type TextBox, SIZES, renderDims, sheetTimeoutMs, featureLayout, copyText, defaultSizes, frameDims, framesForRender, frameLangs, langHasFrames, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib';
import { bgCss, bgTone, type ContrastSample, contrastIssues, parseColour, photoBackground, photoCss, type RGB, worstContrast } from './palette';
import { type WeightSample, weightIssues } from './weights';
import type { Palette } from '../lib/config';
@ -74,9 +74,11 @@ export async function run(ctx: Ctx): Promise<void> {
}
const framesDir = resolve(shots.frames), outDir = resolve(shots.out);
// every frame the scenes load must exist: a missing one renders a store image with no app or game in it
const fl = frameLangs(langs, l => langHasFrames(framesDir, l), config.play?.defaultLanguage ?? config.languages[0]!.id, shots.perLanguageFrames);
if (fl.borrowed.length) log('shots', `info: no frames for ${fl.borrowed.join(', ')}; using the ${config.play?.defaultLanguage ?? config.languages[0]!.id} frames (shots.perLanguageFrames: true requires them per language)`);
const gone = [...jobs.values()].flatMap(job => {
const sel = a.list('scenes').length ? job.scenes.filter(s => a.list('scenes').includes(s.id)) : job.scenes;
return framesForRender(sel, sizes, langs, shots);
return framesForRender(sel, sizes, langs.map(l => fl.map[l]!), shots);
}).filter((f, i, all) => all.indexOf(f) === i && !existsSync(join(framesDir, f)));
if (gone.length) throw new Error(`shots render: ${gone.length} frame file${gone.length > 1 ? 's' : ''} missing under ${relative(root, framesDir) || '.'} (run \`storebound shots capture\` first, or restore the frames folder; it is often git-ignored):\n ${gone.slice(0, 20).join('\n ')}${gone.length > 20 ? `\n ... ${gone.length - 20} more` : ''}`);
if (a.has('dry')) {
@ -164,7 +166,7 @@ export async function run(ctx: Ctx): Promise<void> {
fonts: { ...fonts, script: st.script, titleStack: st.title, bodyStack: st.body } };
if (url.searchParams.has('feature'))
return Response.json({ ...base, frame: shots.feature?.frame ?? scenes[0]!.frames[0]!.src, featureTitle: shots.feature?.title,
device: featureLayout(style, phone, { fit: shots.feature?.fit, scale: shots.feature?.scale }),
device: featureLayout(style, phone, { fit: shots.feature?.fit, scale: shots.feature?.scale, rotate: shots.feature?.rotate }), rotate: shots.feature?.rotate ?? 0,
palette: look(palette(shots.feature?.palette ?? scenes[0]!.palette, style), style === 'app' ? 135 : 160, style === 'app' ? 55 : 50, shots.feature?.background) });
const sid = url.searchParams.get('s') ?? '';
const scene = scenes.find(s => s.id === sid)!;
@ -174,7 +176,10 @@ export async function run(ctx: Ctx): Promise<void> {
let file = '';
if (p === '/logo' && shots.logo) file = resolve(shots.logo);
else if (p.startsWith('/bgimg/')) file = photos[Number(p.slice(7))] ?? '';
else if (p.startsWith('/frames/')) file = join(framesDir, p.slice(8));
else if (p.startsWith('/frames/')) {
const [fl0 = '', ...rest] = p.slice(8).split('/');
file = join(framesDir, [fl.map[fl0] ?? fl0, ...rest].join('/'));
}
else if (p.startsWith('/fonts/')) file = fontsRoute(p.slice(7), fontsDir, root, manifest) ?? '';
else if (p.startsWith('/out/')) file = join(outDir, p.slice(5));
else if (/^\/(template|feature)(-app|-bleed)?\.html$/.test(p)) return new Response(SHOTS_PAGES[p.slice(1)]!, { headers: { 'content-type': TYPES.html! } });

79
cli/shots/selectors.ts Normal file
View file

@ -0,0 +1,79 @@
// Layout-aware element selectors for web capture shots (shots.capture.shots.<name>.waitSelector / .click):
// a list of candidates tried in order, with a per-device list first ("phone", "tablet"). Data attributes are preferred
// (`[data-shot="shop-row"]`), then CSS, then Playwright text (`text=Shop`, `role=button[name="Shop"]`). A shop row that is
// a list on the phone and a grid at 1180x820 needs different selectors; the miss message names the selector and the size.
import type { ShotSelector } from '../lib/config';
type Device = 'phone' | 'tablet';
/** Candidates for one device: its own list first, then the shared ones, without repeats. */
export function candidates(sel: ShotSelector | undefined, device: Device): string[] {
if (sel === undefined) return [];
const list = (v: string | string[] | undefined): string[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]);
const all = typeof sel === 'string' || Array.isArray(sel) ? list(sel) : [...list(sel[device]), ...list(sel.any)];
return [...new Set(all)];
}
export function validateSelector(where: string, sel: unknown): string[] {
const ok = (v: unknown): boolean => typeof v === 'string' && v.trim() !== '';
const okList = (v: unknown): boolean => ok(v) || (Array.isArray(v) && v.length > 0 && v.every(ok));
if (okList(sel)) return [];
if (sel && typeof sel === 'object' && !Array.isArray(sel)) {
const o = sel as Record<string, unknown>, bad = Object.keys(o).filter(k => !['phone', 'tablet', 'any'].includes(k));
if (bad.length) return [`${where}: unknown key ${bad.join(', ')} (phone | tablet | any)`];
if (!Object.keys(o).length) return [`${where}: at least one of phone | tablet | any`];
return Object.entries(o).filter(([, v]) => !okList(v)).map(([k]) => `${where}.${k}: a selector or a list of selectors`);
}
return [`${where}: a selector, a list of selectors, or { phone, tablet, any }`];
}
/** Minimal Playwright surface used here, so tests can fake a page. */
export interface SelPage {
locator(sel: string): { count(): Promise<number>; first(): { click(o?: { timeout?: number }): Promise<void> } };
waitForTimeout(ms: number): Promise<void>;
}
export interface SelResult { selector: string; matches: number }
/** Match counts of every candidate. */
export async function probe(page: SelPage, list: string[]): Promise<SelResult[]> {
const out: SelResult[] = [];
for (const selector of list) {
let matches = 0;
try { matches = await page.locator(selector).count(); } catch { matches = -1; } // -1: not a valid selector
out.push({ selector, matches });
}
return out;
}
/** The miss message: which selector, on which size, what each candidate matched. */
export function missMessage(what: string, where: { device: Device; w: number; h: number }, res: SelResult[]): string {
const rows = res.map(r => `${r.selector} ${r.matches < 0 ? 'is not a valid selector' : `matched ${r.matches}`}`).join('; ');
return `${what} selector missed on ${where.device} ${where.w}x${where.h}: ${rows}. Add a candidate for this layout (waitSelector/click: { ${where.device}: [...] }), data attributes first`;
}
/** Waits (polling) until one candidate matches; returns it. Throws the miss message on timeout. */
export async function firstMatch(page: SelPage, list: string[], what: string, where: { device: Device; w: number; h: number }, timeoutMs: number): Promise<string> {
const end = Date.now() + timeoutMs;
let res: SelResult[] = [];
for (;;) {
res = await probe(page, list);
const hit = res.find(r => r.matches > 0);
if (hit) return hit.selector;
if (Date.now() >= end) throw new Error(missMessage(what, where, res));
await page.waitForTimeout(100);
}
}
/** waitSelector, then click, for one shot on one device. No-ops without either. */
export async function applySelectors(page: SelPage, spec: { waitSelector?: ShotSelector; click?: ShotSelector | ShotSelector[] }, where: { device: Device; w: number; h: number }, timeoutMs: number): Promise<void> {
const w = candidates(spec.waitSelector, where.device);
if (w.length) await firstMatch(page, w, 'waitSelector', where, timeoutMs);
const clicks = spec.click === undefined ? [] : Array.isArray(spec.click) && spec.click.some(c => typeof c !== 'string') ? spec.click : [spec.click as ShotSelector];
for (const c of clicks) {
const list = candidates(c, where.device);
if (!list.length) continue;
const hit = await firstMatch(page, list, 'click', where, timeoutMs);
await page.locator(hit).first().click({ timeout: timeoutMs });
}
}