shots: Mac App Store screenshots (mac size, APP_DESKTOP to the macOS version), a landscape layout (Mac window or bare; Mac, Play Chromebook, Play tablets), safe margins in every template checked by shots check from the render's layout boxes, a contact sheet timeout that scales with images, sizes and load; pitfall 80

This commit is contained in:
CRTkafa 2026-10-20 03:29:19 +07:00
parent 30488c512d
commit e59539fec8
26 changed files with 532 additions and 67 deletions

View file

@ -53,6 +53,8 @@ await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
// read by `shots render` -> report.json -> shots check: the phone's rotated outline must stay inside the canvas
document.body.dataset.devices = JSON.stringify([...c.querySelectorAll('.tel')].map(t => { const r = t.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }));
if (D?.fit === 'bleed') document.body.dataset.bleed = '1';
// logo / name box for the safe-margin rule (7 % of the width left / right, 5 % of the height at the top)
document.body.dataset.boxes = JSON.stringify([...c.querySelectorAll('.logo, .name')].map(e => { const r = e.getBoundingClientRect(); return { el: e.matches('.logo') ? 'logo' : 'title', x: r.x, y: r.y, w: r.width, h: r.height }; }));
document.body.dataset.ready = '1';
</script>
</body>

View file

@ -15,9 +15,9 @@
.glow { position: absolute; left: 360px; top: 250px; width: 900px; height: 700px; transform: translate(-50%, -50%); z-index: -3;
background: radial-gradient(closest-side, var(--glow), transparent); }
.vign { position: absolute; inset: 0; z-index: -2; background: radial-gradient(90% 90% at 45% 50%, transparent 60%, rgba(0,0,0,.28) 100%); }
.logo { position: absolute; left: 360px; top: 252px; width: 600px; transform: translate(-50%, -50%) rotate(-3deg); z-index: 5;
.logo { position: absolute; left: 360px; top: 252px; width: 540px; max-height: 400px; object-fit: contain; transform: translate(-50%, -50%) rotate(-3deg); z-index: 5;
filter: drop-shadow(0 14px 22px rgba(0,0,0,.4)); }
.name { position: absolute; left: 360px; top: 250px; transform: translate(-50%, -50%) rotate(-3deg); z-index: 5; width: 640px; text-align: center;
.name { position: absolute; left: 360px; top: 250px; transform: translate(-50%, -50%) rotate(-3deg); z-index: 5; width: 540px; text-align: center;
font-family: var(--tf); font-size: 104px; line-height: .95; color: #fff; -webkit-text-stroke: 9px var(--ink); paint-order: stroke fill;
filter: drop-shadow(0 8px 0 var(--ink)) drop-shadow(0 14px 22px rgba(0,0,0,.35)); }
.tel { position: absolute; left: 748px; top: 36px; width: 232px; aspect-ratio: var(--ar); transform: rotate(7deg); z-index: 3;
@ -64,6 +64,8 @@ await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
// read by `shots render` -> report.json -> shots check: the phone's rotated outline must stay inside the canvas
document.body.dataset.devices = JSON.stringify([...c.querySelectorAll('.tel')].map(t => { const r = t.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }));
if (D?.fit === 'bleed') document.body.dataset.bleed = '1';
// logo / name box for the safe-margin rule (7 % of the width left / right, 5 % of the height at the top)
document.body.dataset.boxes = JSON.stringify([...c.querySelectorAll('.logo, .name')].map(e => { const r = e.getBoundingClientRect(); return { el: e.matches('.logo') ? 'logo' : 'title', x: r.x, y: r.y, w: r.width, h: r.height }; }));
document.body.dataset.ready = '1';
</script>
</body>

View file

@ -6,7 +6,7 @@ import type { Palette, ShipConfig, ShotScene, ShotsConfig } from '../lib/config'
* Dynamic Island, medium display (APP_IPHONE_61, only with apple.iphoneMedium), ipad13 = 13" iPad
* (APP_IPAD_PRO_3GEN_129, only for apps that run on iPad), play = Play phone 9:16, tab7 / tab10 = Play 7" / 10"
* tablets (tablet frames), feature = Play feature graphic. Rules per size: spec.ts. */
export const SIZES: Record<string, { w: number; h: number; device?: 'tablet'; feature?: true }> = {
export const SIZES: Record<string, { w: number; h: number; device?: 'tablet' | 'landscape'; feature?: true }> = {
ios69: { w: 1320, h: 2868 },
ios65: { w: 1284, h: 2778 },
'iphone-medium': { w: 1206, h: 2622 },
@ -14,12 +14,59 @@ export const SIZES: Record<string, { w: number; h: number; device?: 'tablet'; fe
// iPhone Duo (foldable), only with apple.iphoneDuo: near-tablet aspect, so the tablet frames fill it
'iphone-duo-outer': { w: 1398, h: 2034, device: 'tablet' },
'iphone-duo-inner': { w: 2007, h: 2853, device: 'tablet' },
// Mac App Store (APP_DESKTOP, only with apple.mac): 16:10, landscape layout from <frames>/<lang>/landscape/
mac: { w: 2880, h: 1800, device: 'landscape' },
play: { w: 1080, h: 1920 },
tab7: { w: 1080, h: 1920, device: 'tablet' },
tab10: { w: 1440, h: 2560, device: 'tablet' },
// Play Chromebook screenshots (only with play.chromebook): 16:9 landscape; the Play API has no Chromebook image type,
// so they stay in the renders folder for the Play Console (play images says so)
chromebook: { w: 1920, h: 1080, device: 'landscape' },
feature: { w: 1024, h: 500, feature: true },
};
/** Landscape renders of the Play tablet slots (shots.landscape.playTablets): Play takes any 320-3840 px size up to 2:1. */
export const PLAY_TABLET_LANDSCAPE: Record<string, { w: number; h: number }> = { tab7: { w: 1920, h: 1080 }, tab10: { w: 2560, h: 1440 } };
/** Pixel size and layout of one size key for a project: the landscape layout for mac / chromebook, and for the Play
* tablet slots with shots.landscape.playTablets. frame = the device drawn around a landscape frame ('window' = a Mac
* window, 'none' = the bare frame with rounded corners); default window on Mac, none on Play. Pure. */
export function renderDims(size: string, shots: Pick<ShotsConfig, 'landscape'> | undefined): { w: number; h: number; landscape: boolean; frame: 'window' | 'none' } {
const s = SIZES[size]!;
const tabLand = !!shots?.landscape?.playTablets && !!PLAY_TABLET_LANDSCAPE[size];
const landscape = s.device === 'landscape' || tabLand;
const d = tabLand ? PLAY_TABLET_LANDSCAPE[size]! : s;
const frame = shots?.landscape?.frame ?? (size === 'mac' ? 'window' : 'none');
return { w: d.w, h: d.h, landscape, frame };
}
/** Safe margins every template keeps around its text and logo (fractions of the image), and the minimum `shots check`
* accepts (a little under the target, so rounding never fails a render). */
export const SAFE_MARGIN = { x: 0.07, top: 0.05 } as const;
export const SAFE_MARGIN_MIN = { x: 0.06, top: 0.04 } as const;
export interface TextBox { el: string; x: number; y: number; w: number; h: number }
/** Text / logo boxes (from the page's own layout, report.json `boxes`) closer to the left, right or top edge than
* SAFE_MARGIN_MIN. Pure. */
export function marginViolations(boxes: TextBox[], W: number, H: number): string[] {
const mx = W * SAFE_MARGIN_MIN.x, mt = H * SAFE_MARGIN_MIN.top, out: string[] = [];
for (const b of boxes) {
const sides: string[] = [];
if (b.x < mx - 0.5) sides.push(`left ${Math.round(b.x)} px`);
if (W - (b.x + b.w) < mx - 0.5) sides.push(`right ${Math.round(W - b.x - b.w)} px`);
if (b.y < mt - 0.5) sides.push(`top ${Math.round(b.y)} px`);
if (sides.length) out.push(`${b.el} is too close to the edge (${sides.join(', ')}; minimum ${Math.round(mx)} px left / right, ${Math.round(mt)} px top)`);
}
return out;
}
/** Timeout for the contact sheet page: it decodes every image of the language, so it scales with their count and the
* sizes (big Mac / iPad tiles), times the machine's load factor (max(1-min, 5-min load) / cores, 1..5). Never a
* fixed 30 s. Pure. */
export function sheetTimeoutMs(images: number, sizes: number, load = 1): number {
return Math.round((30_000 + images * 2_000 + sizes * 5_000) * Math.min(5, Math.max(1, load)));
}
export const PALETTES: Record<string, Palette> = {
fire: { bg: ['#ff3d2e', '#ff7a1a', '#ffc43a'], glow: 'rgba(255,240,150,.95)', ink: '#6e1300', shadow: 'rgba(120,10,0,.45)' },
violet: { bg: ['#2b1676', '#6a2fe0', '#ff5fb8'], glow: 'rgba(255,170,240,.75)', ink: '#22095e', shadow: 'rgba(30,0,80,.55)' },
@ -123,7 +170,8 @@ export function frameDims(config: Pick<ShipConfig, 'apple' | 'shots'>): Record<'
export const PLAY_TABLET_SIZES: readonly string[] = ['tab7', 'tab10'];
/** Size keys this project renders by default: Apple sizes only with an apple section (iPad only with apple.ipad, the
* 6.3" iPhone only with apple.iphoneMedium, the iPhone Duo sizes only with apple.iphoneDuo),
* 6.3" iPhone only with apple.iphoneMedium, the iPhone Duo sizes only with apple.iphoneDuo, mac only with apple.mac,
* chromebook only with play.chromebook),
* Play sizes only with a play section (both when neither is configured); the Play tablet sizes only when `tablets`
* (resolved play.tablets, see resolvedTablets in report.ts; unset here = play.tablets ?? true). */
export function defaultSizes(config: Pick<ShipConfig, 'apple' | 'play'>, tablets?: boolean): string[] {
@ -131,6 +179,8 @@ export function defaultSizes(config: Pick<ShipConfig, 'apple' | 'play'>, tablets
const tabs = tablets ?? config.play?.tablets ?? true;
return Object.keys(SIZES).filter(k => (k === 'ipad13' ? apple && !!config.apple?.ipad : k === 'iphone-medium' ? apple && !!config.apple?.iphoneMedium
: k.startsWith('iphone-duo') ? apple && !!config.apple?.iphoneDuo
: k === 'mac' ? apple && !!config.apple?.mac
: k === 'chromebook' ? play && !!config.play?.chromebook
: k.startsWith('ios') ? apple : PLAY_TABLET_SIZES.includes(k) ? play && tabs : play));
}
@ -173,10 +223,10 @@ export interface SheetTile { file: string; x: number; y: number; w: number; h: n
/** Contact sheet layout: one block per size (its scenes side by side, all at the same height; the feature graphic at
* half height), blocks packed onto shelves up to `maxW` wide so a project with one or two scenes gets a compact grid
* instead of a tall strip. Pure. */
export function sheetLayout(rows: { size: string; files: string[] }[], o: { h?: number; gap?: number; maxW?: number } = {}): { w: number; h: number; tiles: SheetTile[] } {
export function sheetLayout(rows: { size: string; files: string[]; dims?: { w: number; h: number } }[], o: { h?: number; gap?: number; maxW?: number } = {}): { w: number; h: number; tiles: SheetTile[] } {
const H0 = o.h ?? 900, gap = o.gap ?? 24;
const blocks = rows.filter(r => r.files.length).map(r => {
const d = SIZES[r.size]!, h = d.feature ? Math.round(H0 / 2) : H0, w = Math.round(h * d.w / d.h);
const d = r.dims ?? SIZES[r.size]!, h = SIZES[r.size]!.feature || d.w > d.h ? Math.round(H0 / 2) : H0, w = Math.round(h * d.w / d.h);
return { r, h, tw: w, w: r.files.length * w + (r.files.length - 1) * gap };
});
const maxW = Math.max(o.maxW ?? 3600, ...blocks.map(b => b.w));

View file

@ -25,10 +25,18 @@ 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, SIZES, featureLayout, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib';
import { type CopyFile, type DeviceBox, type TextBox, SIZES, renderDims, sheetTimeoutMs, featureLayout, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib';
import { faceFilePath, fontsRoute, resolveShotFonts, staleLocalFonts } from './local-fonts';
import { type RenderReport, mergeReport, readReport, resolvedTablets } from './report';
import * as rt from '../lib/rt';
import { cpus, loadavg } from 'node:os';
/** max(1-min, 5-min load) / cores, 1..5 (the same factor tests/helpers/load.ts uses; STOREBOUND_LOAD_FACTOR overrides). */
export function loadFactor(env = process.env, load = loadavg(), cores = cpus().length): number {
const forced = Number(env.STOREBOUND_LOAD_FACTOR);
if (Number.isFinite(forced) && forced > 0) return Math.min(5, Math.max(1, forced));
return Math.min(5, Math.max(1, Math.max(load[0] ?? 0, load[1] ?? 0) / Math.max(1, cores)));
}
const TYPES: Record<string, string> = { html: 'text/html; charset=utf-8', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
webp: 'image/webp', woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', css: 'text/css', svg: 'image/svg+xml' };
@ -124,7 +132,9 @@ export async function run(ctx: Ctx): Promise<void> {
const lang = url.searchParams.get('lang') ?? '';
const { scenes, copy } = jobs.get(url.searchParams.get('v') ?? '') ?? jobs.values().next().value!;
const st = stacks(lang, copy);
const base = { style, phone, tablet, rtl: rtl.get(lang), logo: !!shots.logo, appName: config.app.name,
const ls = shots.landscape?.size;
const landscape = { w: ls?.w ?? 1440, h: ls?.h ?? 900, bar: 0 };
const base = { style, phone, tablet, landscape, side: shots.landscape?.side ?? 'left', rtl: rtl.get(lang), logo: !!shots.logo, appName: config.app.name,
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,
@ -162,22 +172,30 @@ export async function run(ctx: Ctx): Promise<void> {
const started = Date.now();
const report: RenderReport = {};
async function shoot(p: { page: any; errs: string[] }, url: string, w: number, h: number, out: string): Promise<void> { // eslint-disable-line @typescript-eslint/no-explicit-any
async function shoot(p: { page: any; errs: string[] }, url: string, w: number, h: number, out: string, timeout = 30000, what = ''): Promise<void> { // eslint-disable-line @typescript-eslint/no-explicit-any
const { page, errs } = p;
const t0 = Date.now();
// heartbeat for long waits (the contact sheet decodes every image): one line every 15 s, the budget on timeout
const beat = timeout > 30000 ? setInterval(() => log('shots', `${what || relative(root, out)}: still loading (${Math.round((Date.now() - t0) / 1000)} s of ${Math.round(timeout / 1000)} s)`), 15000) : null;
try {
errs.length = 0;
mkdirSync(join(out, '..'), { recursive: true });
await page.setViewportSize({ width: w, height: h });
await page.goto(url, { waitUntil: 'load' });
await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 30000 });
await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout }).catch((e: Error) => {
throw new Error(`${what || relative(root, out)}: not ready after ${Math.round((Date.now() - t0) / 1000)} s (budget ${Math.round(timeout / 1000)} s; ${errs.length ? errs.join(' | ') : 'no page errors'}): ${e.message.split('\n')[0]}`);
});
} finally { if (beat) clearInterval(beat); }
await page.waitForTimeout(200);
const tmp = out + '.tmp.png';
const overflow = JSON.parse((await page.evaluate('document.body.dataset.overflow')) ?? '[]') as string[];
const devices = JSON.parse((await page.evaluate('document.body.dataset.devices')) ?? '[]') as DeviceBox[];
const bleed = (await page.evaluate('document.body.dataset.bleed')) === '1';
const boxes = JSON.parse((await page.evaluate('document.body.dataset.boxes')) ?? '[]') as TextBox[];
if (!out.endsWith('sheet.png')) {
const measured = await measureFonts(page, Object.keys(faces));
const usage = fontUsage(measured, faces);
report[relative(outDir, out)] = { overflow, devices, ...(bleed ? { bleed } : {}), missing: errs.filter(e => e.startsWith('404')), fonts: usage };
report[relative(outDir, out)] = { overflow, devices, ...(bleed ? { bleed } : {}), ...(boxes.length ? { boxes } : {}), missing: errs.filter(e => e.startsWith('404')), fonts: usage };
for (const f of usage.fallback) warn('shots', `${relative(root, out)}: ${f.el} ${[...f.chars].join(' ')} drawn with ${f.family} (automatic fallback)`);
for (const f of usage.missing) warn('shots', `${relative(root, out)}: ${f.el} ${[...f.chars].join(' ')} drawn with a system font`);
}
@ -194,14 +212,14 @@ export async function run(ctx: Ctx): Promise<void> {
// every image first (in parallel), then one contact sheet per language from its finished images
const items: { url: string; w: number; h: number; out: string }[] = [];
const sheets: { dir: string; rows: { size: string; files: string[] }[] }[] = [];
const sheets: { dir: string; rows: { size: string; files: string[]; dims?: { w: number; h: number } }[] }[] = [];
for (const [v, job] of jobs) for (const lang of langs) {
const sceneIds = a.list('scenes').length ? a.list('scenes') : job.scenes.map(s => s.id);
const vq = v ? `&v=${encodeURIComponent(v)}` : '';
const dir = renderDir(outDir, lang, v || undefined);
const rows: { size: string; files: string[] }[] = [];
const rows: { size: string; files: string[]; dims?: { w: number; h: number } }[] = [];
for (const size of sizes) {
const s = SIZES[size]!;
const s = SIZES[size]!, d = renderDims(size, shots);
if (s.feature) {
const f = join(dir, 'feature', '1024x500.png');
items.push({ url: `${base}/${style === 'app' ? 'feature-app' : 'feature'}.html?lang=${encodeURIComponent(lang)}${vq}`, w: s.w, h: s.h, out: f });
@ -211,10 +229,11 @@ export async function run(ctx: Ctx): Promise<void> {
const files: string[] = [];
for (const [i, id] of sceneIds.entries()) {
const f = join(dir, size, `${String(i + 1).padStart(2, '0')}.png`);
items.push({ url: `${base}/${style === 'app' ? 'template-app' : 'template'}.html?s=${encodeURIComponent(id)}&w=${s.w}&h=${s.h}&lang=${encodeURIComponent(lang)}${s.device ? '&device=tablet' : ''}${size === 'tab7' || size === 'tab10' ? '&style=android' : ''}${vq}`, w: s.w, h: s.h, out: f });
const dev = d.landscape ? `&device=landscape&frame=${d.frame}` : s.device ? '&device=tablet' : '';
items.push({ url: `${base}/${style === 'app' ? 'template-app' : 'template'}.html?s=${encodeURIComponent(id)}&w=${d.w}&h=${d.h}&lang=${encodeURIComponent(lang)}${dev}${size === 'tab7' || size === 'tab10' ? '&style=android' : ''}${vq}`, w: d.w, h: d.h, out: f });
files.push(f);
}
rows.push({ size, files });
rows.push({ size, files, dims: { w: d.w, h: d.h } });
}
if (!a.has('no-sheet') && rows.length) sheets.push({ dir, rows });
}
@ -235,7 +254,8 @@ export async function run(ctx: Ctx): Promise<void> {
const sheet = join(dir, '.sheet.html');
writeFileSync(sheet, `<!doctype html><body style="margin:0;background:#141418;width:${lay.w}px;height:${lay.h}px;position:relative">${imgs}
<script>Promise.all([...document.images].map(i=>i.decode())).then(()=>document.body.dataset.ready='1')</script></body>`);
await withPage(p => shoot(p, `${base}/out/${relative(outDir, sheet)}`, lay.w, lay.h, join(dir, 'sheet.png')));
const ms = sheetTimeoutMs(lay.tiles.length, rows.length, loadFactor());
await withPage(p => shoot(p, `${base}/out/${relative(outDir, sheet)}`, lay.w, lay.h, join(dir, 'sheet.png'), ms, `${relative(root, join(dir, 'sheet.png'))} (${lay.tiles.length} images, ${rows.length} sizes)`));
rmSync(sheet, { force: true });
});
} finally {

View file

@ -3,7 +3,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { boxOutside, type DeviceBox, PLAY_TABLET_SIZES, SIZES } from './lib';
import { boxOutside, type DeviceBox, marginViolations, PLAY_TABLET_SIZES, SIZES, type TextBox } from './lib';
import { fontIssues, type FontUsage } from './glyphs';
import { pulledEntry, readPulled } from '../lib/art-pull';
import { checkCount, checkImage, checkVideo, type ImageInfo, imageInfo, type Issue, IMAGE_SPECS, parseProbe, PREVIEW_RULES, type ProbeInfo, VIDEO_TARGETS } from './spec';
@ -12,7 +12,14 @@ import { checkCount, checkImage, checkVideo, type ImageInfo, imageInfo, type Iss
* fonts = which face drew the text (glyphs.ts FontUsage): automatic fallbacks and system-font glyphs are errors. */
export type RenderReport = Record<string, { overflow: string[]; missing?: string[]; fonts?: FontUsage;
/** Device frames' bounding boxes (the rotated outline, from the page's layout) in image px; bleed = cropped on purpose. */
devices?: DeviceBox[]; bleed?: boolean }>;
devices?: DeviceBox[]; bleed?: boolean;
/** Text and logo boxes in image px (from the page's layout), for the safe-margin rule (lib.ts marginViolations). */
boxes?: TextBox[] }>;
/** Text or logo closer to an edge than the safe margin: an error (stores crop and round corners; it reads cramped). */
export function marginIssues(entry: RenderReport[string] | undefined, W: number, H: number): Issue[] {
return marginViolations(entry?.boxes ?? [], W, H).map(msg => ({ level: 'error' as const, code: 'margin' as const, msg: `${msg}: render again with the current templates, or shorten the copy / shrink the logo` }));
}
/** Device frames that leave the image. Feature graphic: an error unless shots.feature.fit is 'bleed' (Play shows the
* phone cut off). Screenshots: the templates let the phone run off the bottom by design, so only top / left / right
@ -80,6 +87,7 @@ export function scanRenders(dir: string, o: { langs: string[]; variants?: string
for (const msg of entry?.overflow ?? []) issues.push({ level: 'warn', code: 'overflow', msg });
issues.push(...fontIssues(entry?.fonts));
issues.push(...deviceIssues(entry, size, info.w, info.h));
issues.push(...marginIssues(entry, info.w, info.h));
if (entry && !entry.fonts) issues.push({ level: 'warn', code: 'fonts', msg: 'font usage not recorded (rendered by an older storebound): run shots fonts + shots render again so missing glyphs are caught' });
out.images.push({ rel, lang, variant, size, file, info, issues });
}
@ -114,13 +122,13 @@ export function ffprobe(file: string): ProbeInfo | null {
/** Which sets the stores require for this config (missing ones are errors, others only checked when present). With a
* language id: Apple sets only when that language has an App Store locale, Play sets only when it has a Play code.
* Play tablets (tab7, tab10) only when `tablets` (resolved play.tablets; unset here = play.tablets === true). */
export function requiredSets(c: { apple?: { ipad?: boolean; iphoneMedium?: boolean } | undefined; play?: unknown; languages?: { id: string; apple?: string; play?: string | string[] }[] },
export function requiredSets(c: { apple?: { ipad?: boolean; iphoneMedium?: boolean; mac?: boolean } | undefined; play?: unknown; languages?: { id: string; apple?: string; play?: string | string[] }[] },
lang?: string, tablets?: boolean): Record<string, boolean> {
const l = lang === undefined ? undefined : c.languages?.find(x => x.id === lang);
const apple = !!c.apple && (!l || !!l.apple);
const play = !!c.play && (!l || (Array.isArray(l.play) ? l.play.length > 0 : !!l.play));
const tabs = play && (tablets ?? (c.play as { tablets?: boolean } | undefined)?.tablets === true);
return { ios69: apple, ...(c.apple?.iphoneMedium ? { 'iphone-medium': apple } : {}), ipad13: apple && !!c.apple?.ipad, play, feature: play,
return { ios69: apple, ...(c.apple?.iphoneMedium ? { 'iphone-medium': apple } : {}), ipad13: apple && !!c.apple?.ipad, ...(c.apple?.mac ? { mac: apple } : {}), play, feature: play,
...(tabs ? { tab7: true, tab10: true } : {}) };
}

View file

@ -29,6 +29,8 @@ export interface ImageSpec {
/** Max bytes per file (null = the store does not publish one). */
maxBytes: number | null;
alpha: false;
/** Only the listed sizes as given (Mac: no portrait). */
landscapeOnly?: true;
}
const MB = 1024 * 1024;
@ -48,10 +50,14 @@ export const IMAGE_SPECS: Record<string, ImageSpec> = {
// optional (apple.iphoneDuo). `type` is not an ASC display type: see IPHONE_DUO_DISPLAY. No app preview sizes yet.
'iphone-duo-outer': { store: 'apple', type: 'IPHONE_DUO_OUTER', label: 'iPhone Duo (outer display)', sizes: [[1398, 2034]], min: 1, max: 10, maxBytes: null, alpha: false },
'iphone-duo-inner': { store: 'apple', type: 'IPHONE_DUO_INNER', label: 'iPhone Duo (inner display)', sizes: [[2007, 2853]], min: 1, max: 10, maxBytes: null, alpha: false },
// Mac (Screenshot specifications, checked 2026-10-20): 16:10 only, one of these four, landscape, 1-10, no alpha.
mac: { store: 'apple', type: 'APP_DESKTOP', label: 'Mac App Store', sizes: [[2880, 1800], [2560, 1600], [1440, 900], [1280, 800]], min: 1, max: 10, maxBytes: null, alpha: false, landscapeOnly: true },
play: { store: 'play', type: 'phoneScreenshots', label: 'Play phone', sizes: [[1080, 1920]], min: 2, max: 8, maxBytes: 8 * MB, alpha: false },
tab7: { store: 'play', type: 'sevenInchScreenshots', label: 'Play 7" tablet', sizes: [[1080, 1920]], min: 0, max: 8, maxBytes: 8 * MB, alpha: false },
tab10: { store: 'play', type: 'tenInchScreenshots', label: 'Play 10" tablet', sizes: [[1440, 2560]], min: 0, max: 8, maxBytes: 8 * MB, alpha: false },
// Play Console states 1024x500, JPEG or 24-bit PNG (no alpha); the upload dialog caps it at 15 MB.
// Chromebook (Play Console listing): 16:9 or 9:16, 1080-7680 px per side; no API image type (console only)
chromebook: { store: 'play', type: 'chromebookScreenshots', label: 'Play Chromebook', sizes: [[1920, 1080]], min: 0, max: 8, maxBytes: 8 * MB, alpha: false },
feature: { store: 'play', type: 'featureGraphic', label: 'Play feature graphic', sizes: [[1024, 500]], min: 1, max: 1, maxBytes: 15 * MB, alpha: false },
};
@ -59,7 +65,7 @@ export const IMAGE_SPECS: Record<string, ImageSpec> = {
* Connect API 4.5.1 (spec re-checked 2026-10-20: the published zip is still the 2026-10-06 one, same sha256) has no Duo value (only AppAssetLibraryDisplayClass lists IPHONE_DUO).
* When Apple adds it: regenerate (bun tools/gen-api.ts) and put the exact enum names here; uploads then include them. */
export const IPHONE_DUO_DISPLAY: Record<string, string | null> = { 'iphone-duo-outer': null, 'iphone-duo-inner': null };
export const IPHONE_DUO_NOT_IN_API = 'the App Store Connect console takes iPhone Duo screenshots, the API does not yet; storebound keeps them in store/renders';
export const IPHONE_DUO_NOT_IN_API = 'not uploaded: the App Store Connect API has no iPhone Duo display type yet (the console takes them); the renders stay in store/renders';
/** Reportedly required from this date for apps built with the iOS 27.1 SDK or later. */
export const IPHONE_DUO_REQUIRED_FROM = '2027-04-01';
const PLAY_FOLDABLE = 'Google Play has no foldable slot: foldables show the 7" / 10" tablet screenshots (tab7, tab10, play.tablets)';
@ -72,6 +78,11 @@ export function iphoneDuoNote(o: { apple: boolean; duo: boolean; play?: boolean;
return { level: late ? 'warn' : 'info', msg: `no iPhone Duo screenshots (outer 1398x2034, inner 2007x2853; apple.iphoneDuo): optional now, required from April 2027 for iOS 27.1 SDK builds; the App Store Connect console takes Duo screenshots and previews, the API does not yet (apple screenshots skips them with the console link)${o.play ? `. ${PLAY_FOLDABLE}` : ''}` };
}
/** validate / check note for an app with a macOS version but no Mac screenshots (apple.mac off or no mac renders). */
export function macNote(o: { macVersion: boolean; mac: boolean }): string | null {
return o.macVersion && !o.mac ? 'the app has a macOS version but no Mac screenshots: set apple.mac: true, add landscape frames in <frames>/<lang>/landscape/, storebound shots render, then apple screenshots --platform macos (APP_DESKTOP, 2880x1800 / 2560x1600 / 1440x900 / 1280x800)' : null;
}
/** Play screenshots in general (any size the store accepts): 320..3840 px per side, long side at most 2x the short. */
export function playScreenshotOk(w: number, h: number): boolean {
const lo = Math.min(w, h), hi = Math.max(w, h);
@ -111,7 +122,7 @@ export function imageInfo(b: Uint8Array): ImageInfo {
}
export type IssueLevel = 'error' | 'warn';
export interface Issue { level: IssueLevel; code: 'size' | 'alpha' | 'format' | 'bytes' | 'count' | 'overflow' | 'video' | 'font-fallback' | 'glyph-missing' | 'device-crop' | 'fonts'; msg: string }
export interface Issue { level: IssueLevel; code: 'size' | 'alpha' | 'format' | 'bytes' | 'count' | 'overflow' | 'video' | 'font-fallback' | 'glyph-missing' | 'device-crop' | 'fonts' | 'margin'; msg: string }
/** Problems with one image for a size key. */
export function checkImage(info: ImageInfo, sizeKey: string): Issue[] {
@ -121,11 +132,11 @@ export function checkImage(info: ImageInfo, sizeKey: string): Issue[] {
if (info.format === 'unknown') return [{ level: 'error', code: 'format', msg: 'not a PNG or JPEG' }];
// Apple: one of the listed sizes, portrait or landscape. Play screenshots: any size Play accepts (the listed one is
// what `shots render` writes). Feature graphic: exactly 1024 x 500.
const exact = s.sizes.some(([w, h]) => (w === info.w && h === info.h) || (s.store === 'apple' && w === info.h && h === info.w));
const exact = s.sizes.some(([w, h]) => (w === info.w && h === info.h) || (s.store === 'apple' && !s.landscapeOnly && w === info.h && h === info.w));
const playShot = s.store === 'play' && s.type !== 'featureGraphic';
if (!exact && !(playShot && playScreenshotOk(info.w, info.h))) {
const want = playShot ? `${s.sizes.map(([w, h]) => `${w}x${h}`).join(' or ')} (any 320-3840 px per side, long side at most 2x the short)`
: s.sizes.map(([w, h]) => `${w}x${h}`).join(' or ') + (s.store === 'apple' ? ' (or landscape)' : '');
: s.sizes.map(([w, h]) => `${w}x${h}`).join(' or ') + (s.store === 'apple' && !s.landscapeOnly ? ' (or landscape)' : '');
out.push({ level: 'error', code: 'size', msg: `${info.w}x${info.h}, ${s.label} wants ${want}` });
}
if (info.alpha) out.push({ level: 'error', code: 'alpha', msg: 'has an alpha channel / transparency (both stores reject it): re-render or flatten to RGB' });

View file

@ -10,6 +10,9 @@
sunburst, no sparkles; badges only when the copy has them, drawn as quiet chips. Same /job.json as template.html
(palette: bg = gradient stops, ink = headline, glow = accent, shadow = device shadow).
Layout: everything is in u = W / 100; the text block is measured first, then the device fills the room below it.
device=landscape: u = H / 100, copy column on one side, the landscape frame in a Mac window (frame=window) or bare
(frame=none) on the other (as template.html). Safe margins: 7 % of the width left / right, 5 % of the height at the
top; text and logo boxes go to body.dataset.boxes for shots check.
-->
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
@ -20,7 +23,7 @@
transform: translate(-50%, -50%); border-radius: 50%; background: radial-gradient(closest-side, var(--accentSoft), transparent); }
.top { position: absolute; left: 0; right: 0; top: calc(var(--u) * var(--top)); text-align: center; z-index: 6;
padding: 0 calc(var(--u) * 7); display: flex; flex-direction: column; align-items: center; }
padding: 0 calc(var(--W) * .07); display: flex; flex-direction: column; align-items: center; }
.eyebrow { margin-bottom: calc(var(--u) * 2.4); font-size: calc(var(--u) * 2.9); font-weight: 700; letter-spacing: var(--kls, .14em);
text-transform: uppercase; color: var(--accent); line-height: 1.2; }
h1 { font-family: var(--tf); font-weight: var(--tw); line-height: var(--lh); letter-spacing: var(--ls, -.02em);
@ -29,7 +32,7 @@
h1 span + span { color: var(--line2); }
p.sub { margin-top: calc(var(--u) * 2.4); max-width: calc(var(--u) * 78); font-size: calc(var(--u) * var(--sub));
line-height: 1.38; font-weight: 500; color: var(--ink); opacity: .72; text-wrap: balance; }
.logo { width: calc(var(--u) * 60); max-height: calc(var(--u) * 26); object-fit: contain; margin-bottom: calc(var(--u) * 3); }
.logo { width: min(100%, calc(var(--u) * 60)); max-height: calc(var(--u) * 26); object-fit: contain; margin-bottom: calc(var(--u) * 3); }
/* device: a plain modern phone, dark bezel, soft shadow */
.tel { position: absolute; z-index: 3; width: var(--pw); aspect-ratio: var(--ar); left: var(--px); top: var(--py);
@ -57,6 +60,14 @@
.android .island { width: calc(var(--pw) * .013); height: calc(var(--pw) * .013); top: calc(var(--pw) * .009); }
.android .bar { padding: 0 4%; }
.land .top { right: auto; width: var(--colW); padding: 0; left: var(--colL); }
.lwin { position: absolute; z-index: 3; left: var(--lx); top: var(--ly); width: var(--lw); border-radius: calc(var(--lw) * .012);
overflow: hidden; background: #1c1c1e; box-shadow: 0 calc(var(--u) * 2) calc(var(--u) * 4) rgba(0,0,0,.08), 0 calc(var(--u) * 5) calc(var(--u) * 12) var(--shadow); }
.lwin .tb { height: calc(var(--lw) * .028); display: flex; align-items: center; gap: calc(var(--lw) * .006); padding-left: calc(var(--lw) * .012); background: #e8e8ea; }
.lwin .tb i { width: calc(var(--lw) * .009); height: calc(var(--lw) * .009); border-radius: 50%; background: #ff5f57; }
.lwin .tb i:nth-child(2) { background: #febc2e; } .lwin .tb i:nth-child(3) { background: #28c840; }
.lwin img { display: block; width: 100%; aspect-ratio: var(--lar); object-fit: cover; }
.lwin.bare { border-radius: calc(var(--lw) * .02); } .lwin.bare .tb { display: none; }
.chip { position: absolute; z-index: 9; display: flex; flex-direction: column; padding: calc(var(--u) * 1.8) calc(var(--u) * 3);
border-radius: calc(var(--u) * 3); background: rgba(255,255,255,.92); color: #111827; white-space: nowrap;
box-shadow: 0 calc(var(--u) * 1) calc(var(--u) * 3.5) rgba(15,23,42,.14); }
@ -69,12 +80,14 @@
<script type="module">
const q = new URLSearchParams(location.search);
const W = +q.get('w'), H = +q.get('h'), LANG = q.get('lang'), SID = q.get('s'), V = q.get('v') ?? '';
const tablet = q.get('device') === 'tablet';
const tablet = q.get('device') === 'tablet', LAND = q.get('device') === 'landscape';
const job = await (await fetch(`/job.json?lang=${encodeURIComponent(LANG)}&s=${encodeURIComponent(SID)}&v=${encodeURIComponent(V)}`)).json();
const S = job.scene, C = job.copy, P = job.palette;
const u = W / 100, Hu = H / u;
const dev = tablet ? job.tablet : job.phone;
const FR = `/frames/${encodeURIComponent(LANG)}/${tablet ? 'tablet' : 'phone'}/`;
const u = LAND ? H / 100 : W / 100, Hu = H / u;
const dev = LAND ? job.landscape : tablet ? job.tablet : job.phone;
const FR = `/frames/${encodeURIComponent(LANG)}/${LAND ? 'landscape' : tablet ? 'tablet' : 'phone'}/`;
const MX = W * .07, MT = H * .05; // safe margins (lib.ts SAFE_MARGIN)
const colW = W * .36, colL = job.side === 'right' ? W - MX - colW : MX;
document.documentElement.lang = LANG;
const RTL = !!job.rtl;
@ -101,10 +114,11 @@ if (LANG.startsWith('ja')) document.documentElement.style.wordBreak = 'auto-phra
if (LANG.startsWith('ko')) document.documentElement.style.wordBreak = 'keep-all';
if (tablet) document.body.classList.add('tablet');
if (q.get('style') === 'android') document.body.classList.add('android');
const tall = Hu > 200, wide = Hu < 150;
R.setProperty('--top', tall ? 9.5 : wide ? 4.5 : 6);
R.setProperty('--fs', tall ? 8.6 : wide ? 5.6 : 7);
R.setProperty('--sub', tall ? 3.7 : wide ? 2.6 : 3.2);
if (LAND) { document.body.classList.add('land'); R.setProperty('--colW', colW + 'px'); R.setProperty('--colL', colL + 'px'); }
const tall = !LAND && Hu > 200, wide = !LAND && Hu < 150;
R.setProperty('--top', Math.max(tall ? 9.5 : wide ? 4.5 : 6, MT / u));
R.setProperty('--fs', LAND ? 8.5 : tall ? 8.6 : wide ? 5.6 : 7);
R.setProperty('--sub', LAND ? 3.4 : tall ? 3.7 : wide ? 2.6 : 3.2);
const AR = dev.w / (dev.h + dev.bar);
R.setProperty('--ar', `${dev.w} / ${dev.h + dev.bar}`);
R.setProperty('--appH', `${100 * dev.h / (dev.h + dev.bar)}%`);
@ -124,7 +138,7 @@ await document.fonts.ready;
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
const overflow = [];
for (const [i, sp] of [...c.querySelectorAll('h1 span')].entries()) {
const max = W - 12 * u, wd = sp.getBoundingClientRect().width;
const max = LAND ? colW : W - 2 * MX, wd = sp.getBoundingClientRect().width;
let f = 1; while (wd * f > max && f > .55) f -= .02;
if (f < 1) sp.style.fontSize = `calc(${getComputedStyle(sp).fontSize} * ${f.toFixed(2)})`;
if (wd * f > max + 1) overflow.push(`title line ${i + 1} is too long even at 55 % size: shorten it`);
@ -133,6 +147,21 @@ for (const [i, sp] of [...c.querySelectorAll('h1 span')].entries()) {
const subEl = c.querySelector('p.sub');
if (subEl) { const lines = Math.round(subEl.getBoundingClientRect().height / parseFloat(getComputedStyle(subEl).lineHeight)); if (lines > 3) overflow.push(`subtitle wraps to ${lines} lines (max 3)`); }
const textBottom = c.querySelector('.top').getBoundingClientRect().bottom / u;
portrait: {
if (LAND) {
const topEl = c.querySelector('.top'), bh = topEl.getBoundingClientRect().height;
topEl.style.top = Math.max(MT, (H - bh) / 2) + 'px';
if (bh > H - MT * 2) overflow.push('text block is taller than the image: shorten the copy');
const f = S.frames[0], src = FR + encodeURIComponent(f.src), bare = q.get('frame') === 'none';
const x0 = job.side === 'right' ? W * .04 : colL + colW + W * .05, x1 = job.side === 'right' ? colL - W * .05 : W - W * .04;
let lw = x1 - x0;
const lh = k => k / (dev.w / dev.h) + (bare ? 0 : k * .028);
if (lh(lw) > H * .88) lw = lw * H * .88 / lh(lw);
R.setProperty('--lar', `${dev.w} / ${dev.h}`); R.setProperty('--softY', H / 2 + 'px');
c.insertAdjacentHTML('beforeend', `<div class="lwin front${bare ? ' bare' : ''}" style="--lw:${lw}px;--lx:${(x0 + x1 - lw) / 2}px;--ly:${(H - lh(lw)) / 2}px"><div class="tb"><i></i><i></i><i></i></div><img src="${src}"></div>`);
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
break portrait;
}
const frames = S.frames.map(f => ({ x: 0, dy: 0, scale: 1, rotate: 0, ...f }));
const front = frames[0];
@ -177,9 +206,12 @@ c.insertAdjacentHTML('beforeend', html);
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
if (textBottom > Hu * .45) overflow.push(`text block covers ${Math.round(textBottom / Hu * 100)} % of the height (the app should be the star)`);
for (const b of c.querySelectorAll('.chip')) { const r = b.getBoundingClientRect(); if (r.left < 0 || r.right > W || r.bottom > H) overflow.push(`badge "${b.textContent.trim().slice(0, 30)}" runs off the image`); }
}
document.body.dataset.overflow = JSON.stringify(overflow);
document.body.dataset.boxes = JSON.stringify([...c.querySelectorAll('.top .logo, .top .eyebrow, .top h1 span, .top p.sub')].map(e => { const r = e.getBoundingClientRect();
return { el: e.matches('.logo') ? 'logo' : e.matches('.eyebrow') ? 'eyebrow' : e.matches('p') ? 'subtitle' : 'title', x: r.x, y: r.y, w: r.width, h: r.height }; }).filter(b => b.w > 0 && b.h > 0));
// the front device only: back phones of a fanned layout peek in from the side on purpose
document.body.dataset.devices = JSON.stringify([...c.querySelectorAll('.tel.front')].map(t => { const r = t.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }));
document.body.dataset.devices = JSON.stringify([...c.querySelectorAll('.tel.front, .lwin')].map(t => { const r = t.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }));
document.body.dataset.ready = '1';
</script>
</body>

View file

@ -9,6 +9,11 @@
style=android (Play 7" / 10" tablets): a plain Android tablet body (thin dark bezel, punch-hole camera) instead of the iPad look.
/job.json gives the scene (palette, frames, badge slots), the words for this language and the font families.
Layout: everything is in u = W / 100; the text block is measured first, then the phone fills the room below it.
device=landscape (Mac, Chromebook, Play tablets with shots.landscape.playTablets): u = H / 100, the copy in a column on
one side (job.side), the first frame of the scene from frames/<lang>/landscape/ on the other, in a Mac window
(frame=window) or bare with rounded corners (frame=none). Badges are not drawn in landscape.
Safe margins (lib.ts SAFE_MARGIN): text and logo keep 7 % of the width left and right and 5 % of the height at the
top; their boxes go to body.dataset.boxes -> report.json -> shots check.
-->
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
@ -34,7 +39,7 @@
/* text block */
.top { position: absolute; left: 0; right: 0; top: calc(var(--u) * var(--top)); text-align: center; z-index: 6;
padding: 0 calc(var(--u) * 5); display: flex; flex-direction: column; align-items: center; }
padding: 0 calc(var(--W) * .07); display: flex; flex-direction: column; align-items: center; }
.eyebrow { display: inline-flex; align-items: center; gap: calc(var(--u) * 1.6); margin-bottom: calc(var(--u) * 3);
padding: calc(var(--u) * 1.15) calc(var(--u) * 3) calc(var(--u) * 1.15) calc(var(--u) * 1.3); border-radius: 999px;
background: rgba(0,0,0,.26); box-shadow: inset 0 0 0 calc(var(--u) * .25) rgba(255,255,255,.28);
@ -51,7 +56,7 @@
p.sub { margin-top: calc(var(--u) * 2.6); max-width: calc(var(--u) * 80); font-size: calc(var(--u) * var(--sub));
line-height: 1.32; font-weight: 700; color: #fff; text-wrap: balance;
text-shadow: 0 calc(var(--u) * .3) calc(var(--u) * 1) rgba(0,0,0,.35); }
.logo { width: calc(var(--u) * 86); max-height: calc(var(--u) * 36); object-fit: contain; margin: calc(var(--u) * -1) 0 calc(var(--u) * 1);
.logo { width: 100%; max-height: calc(var(--u) * 32); object-fit: contain; margin: 0 0 calc(var(--u) * 1);
filter: drop-shadow(0 calc(var(--u) * 1.5) calc(var(--u) * 2.5) rgba(0,0,0,.35)); }
/* phone */
@ -84,6 +89,20 @@
.android .island { width: calc(var(--pw) * .013); height: calc(var(--pw) * .013); top: calc(var(--pw) * .009); }
.android .bar { padding: 0 4%; }
/* landscape: copy column + a Mac window (or the bare frame) */
.land .top { right: auto; width: var(--colW); padding: 0; left: var(--colL); }
.land h1 span { margin: 0 auto; }
.lwin { position: absolute; z-index: 3; left: var(--lx); top: var(--ly); width: var(--lw); border-radius: calc(var(--lw) * .012);
overflow: hidden; background: #1c1c1e; box-shadow: 0 0 0 calc(var(--u) * .12) rgba(255,255,255,.18),
0 calc(var(--u) * 2) calc(var(--u) * 5) rgba(0,0,0,.35), 0 calc(var(--u) * 5) calc(var(--u) * 12) var(--shadow); }
.lwin .tb { height: calc(var(--lw) * .028); display: flex; align-items: center; gap: calc(var(--lw) * .006); padding-left: calc(var(--lw) * .012);
background: linear-gradient(180deg, #3a3a3c, #2c2c2e); }
.lwin .tb i { width: calc(var(--lw) * .009); height: calc(var(--lw) * .009); border-radius: 50%; background: #ff5f57; }
.lwin .tb i:nth-child(2) { background: #febc2e; } .lwin .tb i:nth-child(3) { background: #28c840; }
.lwin img { display: block; width: 100%; aspect-ratio: var(--lar); object-fit: cover; }
.lwin.bare { border-radius: calc(var(--lw) * .02); }
.lwin.bare .tb { display: none; }
/* badges */
.badge { position: absolute; z-index: 9; display: flex; align-items: center; gap: calc(var(--u) * 2.2);
padding: calc(var(--u) * 1.7) calc(var(--u) * 3.6) calc(var(--u) * 1.7) calc(var(--u) * 1.7); border-radius: calc(var(--u) * 4);
@ -110,12 +129,14 @@
<script type="module">
const q = new URLSearchParams(location.search);
const W = +q.get('w'), H = +q.get('h'), LANG = q.get('lang'), SID = q.get('s'), V = q.get('v') ?? '';
const tablet = q.get('device') === 'tablet';
const tablet = q.get('device') === 'tablet', LAND = q.get('device') === 'landscape';
const job = await (await fetch(`/job.json?lang=${encodeURIComponent(LANG)}&s=${encodeURIComponent(SID)}&v=${encodeURIComponent(V)}`)).json();
const S = job.scene, C = job.copy, P = job.palette;
const u = W / 100, Hu = H / u;
const dev = tablet ? job.tablet : job.phone; // { w, h, bar } in CSS px of the raw frame
const FR = `/frames/${encodeURIComponent(LANG)}/${tablet ? 'tablet' : 'phone'}/`;
const u = LAND ? H / 100 : W / 100, Hu = H / u;
const dev = LAND ? job.landscape : tablet ? job.tablet : job.phone; // { w, h, bar } in CSS px of the raw frame
const FR = `/frames/${encodeURIComponent(LANG)}/${LAND ? 'landscape' : tablet ? 'tablet' : 'phone'}/`;
const MX = W * .07, MT = H * .05; // safe margins (lib.ts SAFE_MARGIN)
const colW = W * .36, colL = job.side === 'right' ? W - MX - colW : MX;
document.documentElement.lang = LANG;
const RTL = !!job.rtl;
@ -139,11 +160,12 @@ if (LANG.startsWith('ja')) document.documentElement.style.wordBreak = 'auto-phra
if (LANG.startsWith('ko')) document.documentElement.style.wordBreak = 'keep-all';
if (tablet) document.body.classList.add('tablet');
if (q.get('style') === 'android') document.body.classList.add('android');
const tall = Hu > 200; // iOS 6.9" / 6.5" vs 16:9
if (LAND) { document.body.classList.add('land'); R.setProperty('--colW', colW + 'px'); R.setProperty('--colL', colL + 'px'); }
const tall = !LAND && Hu > 200; // iOS 6.9" / 6.5" vs 16:9
const wide = Hu < 150; // 3:4 iPad
R.setProperty('--top', tall ? 9 : wide ? 4 : 5.5);
R.setProperty('--fs', S.hero ? (tall ? 10.5 : wide ? 7 : 8.6) : (tall ? 12 : wide ? 7.6 : 9.8));
R.setProperty('--sub', tall ? 3.75 : wide ? 2.7 : 3.3);
R.setProperty('--top', Math.max(tall ? 9 : wide ? 4 : 5.5, MT / u));
R.setProperty('--fs', LAND ? (S.hero ? 9 : 10) : S.hero ? (tall ? 10.5 : wide ? 7 : 8.6) : (tall ? 12 : wide ? 7.6 : 9.8));
R.setProperty('--sub', LAND ? 3.6 : tall ? 3.75 : wide ? 2.7 : 3.3);
const AR = dev.w / (dev.h + dev.bar);
R.setProperty('--ar', `${dev.w} / ${dev.h + dev.bar}`);
R.setProperty('--appH', `${100 * dev.h / (dev.h + dev.bar)}%`);
@ -188,7 +210,7 @@ await document.fonts.ready;
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
const overflow = []; // read by `shots render` -> report.json -> shots check / gallery
for (const [i, sp] of [...c.querySelectorAll('h1 span')].entries()) { // shrink a line that would overflow (long words)
const max = W - 10 * u, wd = sp.getBoundingClientRect().width;
const max = LAND ? colW : W - 2 * MX, wd = sp.getBoundingClientRect().width;
let f = 1; while (wd * f > max && f > .55) f -= .02;
if (f < 1) sp.style.fontSize = `calc(${getComputedStyle(sp).fontSize} * ${f.toFixed(2)})`;
if (wd * f > max + 1) overflow.push(`title line ${i + 1} is too long even at 55 % size: shorten it`);
@ -197,8 +219,24 @@ for (const [i, sp] of [...c.querySelectorAll('h1 span')].entries()) { // shrink
const subEl = c.querySelector('p.sub');
if (subEl) { const lines = Math.round(subEl.getBoundingClientRect().height / parseFloat(getComputedStyle(subEl).lineHeight)); if (lines > 3) overflow.push(`subtitle wraps to ${lines} lines (max 3)`); }
const eb = c.querySelector('.eyebrow');
if (eb && eb.getBoundingClientRect().width > W - 8 * u) overflow.push('eyebrow is wider than the image');
if (eb && eb.getBoundingClientRect().width > (LAND ? colW : W - 2 * MX)) overflow.push('eyebrow is wider than the image');
const textBottom = c.querySelector('.top').getBoundingClientRect().bottom / u;
portrait: {
if (LAND) {
const topEl = c.querySelector('.top'), bh = topEl.getBoundingClientRect().height;
topEl.style.top = Math.max(MT, (H - bh) / 2) + 'px';
if (bh > H - MT * 2) overflow.push('text block is taller than the image: shorten the copy');
const f = S.frames[0], src = FR + encodeURIComponent(f.src), bare = q.get('frame') === 'none';
const x0 = job.side === 'right' ? W * .04 : colL + colW + W * .05, x1 = job.side === 'right' ? colL - W * .05 : W - W * .04;
let lw = x1 - x0;
const lh = k => k / (dev.w / dev.h) + (bare ? 0 : k * .028);
if (lh(lw) > H * .88) lw = lw * H * .88 / lh(lw);
R.setProperty('--lar', `${dev.w} / ${dev.h}`);
R.setProperty('--rayY', H / 2 + 'px'); R.setProperty('--rayRot', (rnd() * 20) + 'deg');
c.insertAdjacentHTML('beforeend', `<div class="lwin front${bare ? ' bare' : ''}" style="--lw:${lw}px;--lx:${(x0 + x1 - lw) / 2}px;--ly:${(H - lh(lw)) / 2}px"><div class="tb"><i></i><i></i><i></i></div><img src="${src}"></div>`);
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
break portrait;
}
const frames = S.frames.map(f => ({ x: 0, dy: 0, scale: 1, rotate: 0, ...f }));
const front = frames[0];
@ -244,9 +282,13 @@ c.insertAdjacentHTML('beforeend', html);
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
if (textBottom > Hu * .48) overflow.push(`text block covers ${Math.round(textBottom / Hu * 100)} % of the height (the game should be the star)`);
for (const b of c.querySelectorAll('.badge')) { const r = b.getBoundingClientRect(); if (r.left < 0 || r.right > W || r.bottom > H) overflow.push(`badge "${b.textContent.trim().slice(0, 30)}" runs off the image`); }
}
document.body.dataset.overflow = JSON.stringify(overflow);
// text and logo boxes for the safe-margin rule (shots check)
document.body.dataset.boxes = JSON.stringify([...c.querySelectorAll('.top .logo, .top .eyebrow, .top h1 span, .top p.sub')].map(e => { const r = e.getBoundingClientRect();
return { el: e.matches('.logo') ? 'logo' : e.matches('.eyebrow') ? 'eyebrow' : e.matches('p') ? 'subtitle' : 'title', x: r.x, y: r.y, w: r.width, h: r.height }; }).filter(b => b.w > 0 && b.h > 0));
// the front device only: back phones of a fanned layout peek in from the side on purpose
document.body.dataset.devices = JSON.stringify([...c.querySelectorAll('.tel.front')].map(t => { const r = t.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }));
document.body.dataset.devices = JSON.stringify([...c.querySelectorAll('.tel.front, .lwin')].map(t => { const r = t.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }));
document.body.dataset.ready = '1';
</script>
</body>