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

@ -3,6 +3,9 @@
## Unreleased
- `shots.feature.rotate` (degrees) is honoured by the Play feature graphic like a scene frame's `rotate`: added to the template tilt and the phone is re-fitted (`-90` stands a landscape game frame upright); with `shots.layout: "bleed"` the frame image is turned and still covers the canvas.
- `shots check` / `shots render` without frames for a language that has copy use the default language's frames (an info line, no failure); `shots.perLanguageFrames: true` requires per-language frames. `shots fonts --lang en,tr` limits the report to those languages (the font manifest keeps all of them).
- `shots capture` web shots take `waitSelector` and `click` selector lists per layout (`{ phone, tablet, any }`, data attributes first, `text=` fallback). A miss names the selector and the size with every candidate's match count; `shots capture --dry` prints the candidates per size and `--dry --probe` opens the pages and reports which one matched or MISSed on which size.
- Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. A release counts as ready to publish only when the public store page positively shows an older version; a 404, a failed or stale page read, a same or unparseable version is never ready (verify says "not determined" instead of a mismatch).
- `apple upload` / `play upload` `--pre-check "<cmd with {file}>"` (config `upload.preCheck`) runs your gate before any byte is sent; a nonzero exit refuses the upload (ship-step exit 1, `PRE_CHECK_FAILED`). `--evidence <path.json>` (`upload.evidence`) writes file, sha256, size, command, exit code, stdout tail, time and version, also on failure, and the result carries it as `preCheck`.
- New `storebound shots copy translate --to de,es` fills store/shots/copy.json per language (agent translation or `--translate "<cmd>"`), machine-translated until `shots copy reviewed`, caption limits respected; `shots copy check`; `check` / `shots render` name this command for a missing language.

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

View file

@ -1441,3 +1441,16 @@ that fails without the fix (see CONTRIBUTING.md).
--platform macos` refuses an unsigned .pkg or one not signed with an installer certificate before any network call
(`tests/mas-build.test.ts`). The `com.apple.pkg` file type of the API build upload is taken from Apple's schema and has not
been exercised against the live API yet; `--via altool` (`--type macos`) is the fallback.
## 120. Play feature graphic shows a landscape game sideways; a language without frames fails the whole check
- **Symptom:** the 1024x500 feature graphic showed a landscape game frame sideways inside the portrait phone (the
feature path ignored `rotate`); `shots check` without `--lang` failed on missing frames of languages that only have
copy; a capture click on a shop row matched on the phone layout and silently missed at tablet / desktop sizes.
- **Do:** set `shots.feature.rotate` (degrees, added to the template tilt; `-90` stands a landscape frame upright in a
phone lying on its side; with layout "bleed" the frame image itself is turned). Frames of a language that has none
come from the default language (info line; `shots.perLanguageFrames: true` requires them per language). In web
capture list `waitSelector` / `click` candidates per layout (`{ phone: [...], tablet: [...], any: [...] }`, data
attributes first, `text=` last); `shots capture --dry` prints them per size, `--dry --probe` opens the pages and
names the MISS with the size and every candidate's match count.
- **Guard:** `tests/shots-gaps.test.ts`, `tests/browser/feature-fit.browser.ts` (rotate), `cli/shots/selectors.ts`.

View file

@ -63,6 +63,8 @@ describe('shots.feature config', () => {
expect(errs({ frame: 'a.png' })).toEqual([]);
expect(errs({ frame: 'a.png', fit: 'bleed', scale: 1.2 })).toEqual([]);
expect(errs({ frame: 'a.png', fit: 'cover' })).toEqual(["shots.feature.fit: 'contain' | 'bleed'"]);
expect(errs({ frame: 'a.png', rotate: -90 })).toEqual([]);
expect(errs({ frame: 'a.png', rotate: 'left' })).toEqual(['shots.feature.rotate: a number of degrees from -360 to 360']);
expect(errs({ frame: 'a.png', scale: 3 })).toEqual(['shots.feature.scale: a number from 0.5 to 1.5']);
});
});
@ -106,3 +108,41 @@ describe.skipIf(!browser)('feature graphic templates at 1024 x 500 (DOM layout b
} finally { await page.close(); }
}, scaled(30_000));
});
describe.skipIf(!browser)('shots.feature.rotate', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const route = (page: any, html: string, name: string, job: unknown) => page.route('http://t.test/**', (r: { request(): { url(): string }; fulfill(o: unknown): Promise<void> }) => {
const u = new URL(r.request().url());
if (u.pathname === `/${name}`) return r.fulfill({ contentType: 'text/html', body: html });
if (u.pathname === '/job.json') return r.fulfill({ contentType: 'application/json', body: JSON.stringify(job) });
if (u.pathname.startsWith('/frames/')) return r.fulfill({ contentType: 'image/png', body: PNG });
return r.fulfill({ contentType: 'text/css', body: '' });
});
const pal = { bg: ['#123456', '#345678', '#567890'], glow: '#ffffff', ink: '#000000', shadow: 'rgba(0,0,0,.4)' };
test('game template: a -90 phone lies on its side and still fits', async () => {
const page = await browser.newPage({ viewport: { width: 1024, height: 500 } });
try {
const device = featureLayout('game', DEFAULT_PHONE, { rotate: -90 });
expect(device.angle).toBe(-83);
await route(page, readFileSync(join(import.meta.dir, '..', '..', 'cli', 'shots', 'feature.html'), 'utf8'), 'feature.html',
{ style: 'game', phone: DEFAULT_PHONE, logo: false, appName: 'Demo', fonts: { title: 'X' }, frame: 'a.png', device, palette: pal });
await page.goto('http://t.test/feature.html?lang=en');
await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 15000 });
const d = (JSON.parse(await page.evaluate('document.body.dataset.devices')) as { x: number; y: number; w: number; h: number }[])[0]!;
expect(d.w).toBeGreaterThan(d.h); // landscape outline
expect(boxOutside(d, 1024, 500)).toEqual([]);
} finally { await page.close(); }
}, scaled(30_000));
test('bleed template: the frame image is turned and still covers the canvas', async () => {
const page = await browser.newPage({ viewport: { width: 1024, height: 500 } });
try {
await route(page, readFileSync(join(import.meta.dir, '..', '..', 'cli', 'shots', 'feature-bleed.html'), 'utf8'), 'feature-bleed.html',
{ fonts: { title: 'X' }, frame: 'a.png', appName: 'Demo', rotate: -90 });
await page.goto('http://t.test/feature-bleed.html?lang=en');
await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 15000 });
const r = await page.evaluate("(() => { const b = document.querySelector('img.frame').getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height }; })()") as { x: number; y: number; w: number; h: number };
expect(Math.round(r.w)).toBe(1024); expect(Math.round(r.h)).toBe(500); expect(Math.round(r.x)).toBe(0); expect(Math.round(r.y)).toBe(0);
expect(await page.evaluate("document.querySelector('img.frame').style.transform")).toBe('rotate(-90deg)');
} finally { await page.close(); }
}, scaled(30_000));
});

91
tests/shots-gaps.test.ts Normal file
View file

@ -0,0 +1,91 @@
// Real-use gaps in `shots`: feature rotate, check/render frame fallback per language, layout-aware capture selectors.
import { describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DEFAULT_PHONE, featureLayout, frameLangs, langHasFrames } from '../cli/shots/lib';
import { applySelectors, candidates, missMessage, probe, validateSelector, type SelPage } from '../cli/shots/selectors';
import { validateCapture } from '../cli/shots/devices';
describe('feature rotate', () => {
test('adds to the template tilt and still fits', () => {
const a = featureLayout('game', DEFAULT_PHONE, {}), b = featureLayout('game', DEFAULT_PHONE, { rotate: -90 });
expect(a.angle).toBe(7); expect(b.angle).toBe(-83);
expect(b.box.w).toBeGreaterThan(b.box.h);
expect(b.box.x).toBeGreaterThanOrEqual(23.99); expect(b.box.x + b.box.w).toBeLessThanOrEqual(1024 - 23.99);
expect(b.box.y).toBeGreaterThanOrEqual(23.99); expect(b.box.y + b.box.h).toBeLessThanOrEqual(500 - 23.99);
});
});
describe('frame languages', () => {
const has = (set: string[]) => (l: string) => set.includes(l);
test('a language without frames borrows the default language', () => {
expect(frameLangs(['en', 'tr', 'de'], has(['en', 'de']), 'en')).toEqual({ map: { en: 'en', tr: 'en', de: 'de' }, borrowed: ['tr'] });
});
test('perLanguage requires its own', () => {
expect(frameLangs(['en', 'tr'], has(['en']), 'en', true)).toEqual({ map: { en: 'en', tr: 'tr' }, borrowed: [] });
});
test('no frames anywhere: nothing borrowed, the missing error names the language', () => {
expect(frameLangs(['en', 'tr'], has([]), 'en').borrowed).toEqual([]);
});
test('langHasFrames reads the folder', () => {
const d = mkdtempSync(join(tmpdir(), 'sb-fl-'));
try {
mkdirSync(join(d, 'en', 'phone'), { recursive: true }); writeFileSync(join(d, 'en', 'phone', 'a.png'), 'x');
mkdirSync(join(d, 'tr'), { recursive: true });
expect(langHasFrames(d, 'en')).toBe(true);
expect(langHasFrames(d, 'tr')).toBe(false);
expect(langHasFrames(d, 'de')).toBe(false);
} finally { rmSync(d, { recursive: true, force: true }); }
});
});
/** Fake page: a DOM of selector -> count; text= selectors are just keys. */
function fake(dom: Record<string, number>, clicks: string[] = []): SelPage {
return {
locator: (s: string) => ({ count: async () => { if (s.startsWith('!')) throw new Error('bad'); return dom[s] ?? 0; }, first: () => ({ click: async () => { clicks.push(s); } }) }),
waitForTimeout: async () => {},
};
}
describe('capture selectors', () => {
test('per-device list first, then shared, no repeats', () => {
const s = { phone: ['[data-shot="row"]'], tablet: ['[data-shot="tile"]', 'text=Shop'], any: ['text=Shop'] };
expect(candidates(s, 'tablet')).toEqual(['[data-shot="tile"]', 'text=Shop']);
expect(candidates(s, 'phone')).toEqual(['[data-shot="row"]', 'text=Shop']);
expect(candidates('a', 'phone')).toEqual(['a']);
expect(candidates(undefined, 'phone')).toEqual([]);
});
test('validation', () => {
expect(validateSelector('x', 'a')).toEqual([]);
expect(validateSelector('x', { tablet: ['a'] })).toEqual([]);
expect(validateSelector('x', { desktop: 'a' })[0]).toContain('unknown key desktop');
expect(validateSelector('x', '')).not.toEqual([]);
expect(validateSelector('x', {})).not.toEqual([]);
});
test('the click lands on the first candidate that matches', async () => {
const clicks: string[] = [];
await applySelectors(fake({ 'text=Shop': 1 }, clicks), { waitSelector: ['[data-shot="row"]', 'text=Shop'], click: { tablet: ['[data-shot="tile"]', 'text=Shop'] } }, { device: 'tablet', w: 1180, h: 820 }, 50);
expect(clicks).toEqual(['text=Shop']);
});
test('several clicks in order', async () => {
const clicks: string[] = [];
await applySelectors(fake({ a: 1, b: 1 }, clicks), { click: [{ any: 'a' }, 'b'] }, { device: 'phone', w: 440, h: 902 }, 50);
expect(clicks).toEqual(['a', 'b']);
});
test('a miss names the selector, the size and every match count', async () => {
const err = await applySelectors(fake({ '[data-shot="row"]': 3 }), { click: { tablet: ['[data-shot="tile"]', 'text=Shop'] } }, { device: 'tablet', w: 1180, h: 820 }, 30).then(() => '', e => (e as Error).message);
expect(err).toContain('click selector missed on tablet 1180x820');
expect(err).toContain('[data-shot="tile"] matched 0');
expect(err).toContain('text=Shop matched 0');
expect(missMessage('click', { device: 'phone', w: 1, h: 2 }, [{ selector: '!x', matches: -1 }])).toContain('is not a valid selector');
});
test('probe counts every candidate', async () => {
expect(await probe(fake({ a: 2 }), ['a', 'b'])).toEqual([{ selector: 'a', matches: 2 }, { selector: 'b', matches: 0 }]);
});
test('validateCapture accepts and rejects', () => {
const cap = (shot: object) => ({ source: 'web', baseUrl: 'http://127.0.0.1:1', shots: { shop: { path: '/', ...shot } } }) as never;
expect(validateCapture(cap({ waitSelector: { tablet: 'a' }, click: ['a', 'b'] }))).toEqual([]);
expect(validateCapture(cap({ click: { desktop: 'a' } }))[0]).toContain('unknown key desktop');
});
});