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

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