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

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