diff --git a/CHANGELOG.md b/CHANGELOG.md index a6238d4..9c01f9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +- **Mac App Store screenshots.** `apple.mac: true` adds the `mac` size (2880 x 1800; `shots check` takes exactly + Apple's four 16:10 sizes, no alpha) and `apple screenshots --platform macos` uploads it as `APP_DESKTOP` to the macOS + version. `validate` notes a macOS version without Mac screenshots. Art pull maps `APP_DESKTOP` to `mac/`. +- **Landscape layout** in both templates: copy column plus the frame in a Mac window or bare (`shots.landscape`), for + Mac, Play Chromebook (`play.chromebook`; no API type, `play images` links the console) and, with + `shots.landscape.playTablets`, Play's 7" / 10" tablet slots. +- **Safe margins:** text and logo keep 7 % of the width left / right and 5 % of the height at the top in every + template (feature graphics too); `shots check` fails boxes under 6 % / 4 %, measured from the render's layout data. +- **Contact sheet:** the `sheet.png` wait scales with the images, sizes and machine load, with a heartbeat, instead of + a flat 30 s. +- iPhone Duo skip message says plainly that the API has no Duo type and names the console page. + ## 0.8.14 · 2026-10-19 - **Header and Search Results: upload, add for review, place once approved.** `storebound apple creative-assets` diff --git a/cli/apple/screenshots.ts b/cli/apple/screenshots.ts index 3d484ab..3d45e1f 100644 --- a/cli/apple/screenshots.ts +++ b/cli/apple/screenshots.ts @@ -3,7 +3,9 @@ // storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry] // Source per language: //ios69/NN.png (6.9", 1320 x 2868 -> APP_IPHONE_67), // ios65/NN.png (6.5", 1284 x 2778 -> APP_IPHONE_65) and, only with apple.ipad, ipad13/NN.png (13" iPad, 2064 x 2752 -> -// APP_IPAD_PRO_3GEN_129: the API has no "13 inch" value, the 13" display set keeps the 3rd gen 12.9" name). Up to 10 +// APP_IPAD_PRO_3GEN_129: the API has no "13 inch" value, the 13" display set keeps the 3rd gen 12.9" name). With +// --platform macos (or when only the macOS version is editable) and apple.mac: mac/NN.png (2880 x 1800 -> APP_DESKTOP) +// to the macOS version. iPhone Duo sets are skipped with the console deep link (no API display type yet). Up to 10 // per set. --variant uploads ///... instead (an A/B variant from shots.variants, e.g. for a // Product Page Optimization treatment you then set up in App Store Connect). Apple processes uploads asynchronously; failed // ones are reported at the end. Long: run in the background with a log file. @@ -29,10 +31,13 @@ export const IPAD_DISPLAY: Record = { ipad13: 'APP_IPAD_PRO_3GEN /** 6.3" iPhone (Dynamic Island, medium display), uploaded only when apple.iphoneMedium is true. */ export const MEDIUM_DISPLAY: Record = { 'iphone-medium': 'APP_IPHONE_61' }; -/** Display sets this project uploads: the iPhone sets, plus the 6.3" set with apple.iphoneMedium and the 13" iPad set - * with apple.ipad. */ -export const displaySets = (apple: { ipad?: boolean; iphoneMedium?: boolean; iphoneDuo?: boolean } | undefined): Record => - ({ ...DISPLAY, ...(apple?.iphoneMedium ? MEDIUM_DISPLAY : {}), ...(apple?.ipad ? IPAD_DISPLAY : {}), +/** Mac App Store set (APP_DESKTOP, 16:10), uploaded to the macOS version only, with apple.mac. */ +export const MAC_DISPLAY: Record = { mac: 'APP_DESKTOP' }; + +/** Display sets this project uploads to a version of `platform`: macOS -> the Mac set (apple.mac); iOS (default) -> the + * iPhone sets, plus the 6.3" set with apple.iphoneMedium and the 13" iPad set with apple.ipad. */ +export const displaySets = (apple: { ipad?: boolean; iphoneMedium?: boolean; iphoneDuo?: boolean; mac?: boolean } | undefined, platform: string = 'IOS'): Record => + platform === 'MAC_OS' ? (apple?.mac ? MAC_DISPLAY : {}) : ({ ...DISPLAY, ...(apple?.iphoneMedium ? MEDIUM_DISPLAY : {}), ...(apple?.ipad ? IPAD_DISPLAY : {}), ...(apple?.iphoneDuo ? Object.fromEntries(Object.entries(IPHONE_DUO_DISPLAY).filter((e): e is [string, string] => !!e[1])) : {}) }); /** iPhone Duo size keys `apple screenshots` skips (apple.iphoneDuo on, no API display type yet), with the message. */ @@ -89,8 +94,10 @@ export async function run(ctx: Ctx): Promise { const version = await c.editableVersion(platform); if (!version) throw new Error(`no editable ${platformLabel(platform)} App Store version (run apple listing --version x.y.z first)`); log('apple', `screenshots for version ${versionLabel(version)}`); - const duoSkip = skippedDuoSets(config.apple); + const duoSkip = platform === 'MAC_OS' ? null : skippedDuoSets(config.apple); if (duoSkip) warn('apple', duoSkip.msg); + if (platform === 'MAC_OS' && !config.apple?.mac) throw new Error('macOS version: set apple.mac: true and render the mac size (storebound shots render --size mac) first'); + if (platform !== 'MAC_OS' && config.apple?.mac) log('apple', 'Mac screenshots (mac/, APP_DESKTOP) go to the macOS version: run apple screenshots --platform macos'); const langOf = byAppleLocale(config.languages); const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`); @@ -105,7 +112,7 @@ export async function run(ctx: Ctx): Promise { if (!lang) { warn('apple', `${locale}: no language with apple: "${locale}", skipped`); continue; } tasks.push(async () => { const sets = await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`); - for (const [dir, type] of Object.entries(displaySets(config.apple))) { + for (const [dir, type] of Object.entries(displaySets(config.apple, platform))) { const d = join(src, lang.id, dir); const files = numberedImages(d).filter(f => f.endsWith('.png')).slice(0, 10); if (!existsSync(d) || !files.length) { warn('apple', `${locale}: nothing in ${d}, skipped`); continue; } diff --git a/cli/art.ts b/cli/art.ts index 87dc882..52489d9 100644 --- a/cli/art.ts +++ b/cli/art.ts @@ -408,7 +408,7 @@ type LiveSet = LiveArtSet; type LiveResult = LiveArtResult; const LABELS: Record = { - APP_IPHONE_67: 'App Store iPhone 6.9"', APP_IPHONE_65: 'App Store iPhone 6.5"', APP_IPHONE_61: 'iPhone with Dynamic Island (medium display)', APP_IPHONE_55: 'App Store iPhone 5.5"', APP_IPAD_PRO_3GEN_129: 'iPad 13" display', APP_IPAD_PRO_129: 'App Store iPad 12.9" (2nd gen)', + APP_IPHONE_67: 'App Store iPhone 6.9"', APP_IPHONE_65: 'App Store iPhone 6.5"', APP_IPHONE_61: 'iPhone with Dynamic Island (medium display)', APP_IPHONE_55: 'App Store iPhone 5.5"', APP_IPAD_PRO_3GEN_129: 'iPad 13" display', APP_DESKTOP: 'Mac App Store', APP_IPAD_PRO_129: 'App Store iPad 12.9" (2nd gen)', IPHONE_67: 'App Store iPhone app preview', IPAD_PRO_3GEN_129: 'App Store iPad app preview', featureGraphic: 'Play feature graphic', phoneScreenshots: 'Play phone', sevenInchScreenshots: 'Play 7" tablet', tenInchScreenshots: 'Play 10" tablet', tvScreenshots: 'Play TV', wearScreenshots: 'Play Wear OS', }; diff --git a/cli/lib/art-pull.ts b/cli/lib/art-pull.ts index 3ad9358..38e0c5b 100644 --- a/cli/lib/art-pull.ts +++ b/cli/lib/art-pull.ts @@ -45,7 +45,7 @@ export interface RemoteArt { } /** App Store display type -> renders size folder. */ -export const APPLE_SHOT_DIR: Record = { APP_IPHONE_67: 'ios69', APP_IPHONE_65: 'ios65', APP_IPHONE_61: 'iphone-medium', APP_IPAD_PRO_3GEN_129: 'ipad13' }; +export const APPLE_SHOT_DIR: Record = { APP_IPHONE_67: 'ios69', APP_IPHONE_65: 'ios65', APP_IPHONE_61: 'iphone-medium', APP_IPAD_PRO_3GEN_129: 'ipad13', APP_DESKTOP: 'mac' }; /** App Store preview type -> preview folder. */ export const APPLE_PREVIEW_DIR: Record = { IPHONE_67: 'iphone', IPAD_PRO_3GEN_129: 'ipad' }; /** Play image type -> renders size folder (screenshots; the feature graphic and icon are files of their own). */ diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 8811f7b..b7a0287 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -164,6 +164,11 @@ export interface ShotsConfig { phone?: { w: number; h: number; dpr?: number; bar?: number }; /** Tablet frames: default 1032 x 1352 @2x (13" iPad minus its 24 px bar) with apple.ipad, else 800 x 1240 @2x. */ tablet?: { w: number; h: number; dpr?: number; bar?: number }; + /** Landscape layout (Mac, Chromebook, and the Play tablet slots with playTablets): copy on one side, the frame on the + * other. Frames: //landscape/.png at `size` CSS px (default 1440 x 900 @2x). frame: 'window' (a + * Mac window around the game, the default for mac) or 'none' (the bare frame, the default for Play sizes). side: + * where the copy goes (default 'left'). playTablets: render tab7 / tab10 landscape (1920 x 1080 / 2560 x 1440). */ + landscape?: { frame?: 'window' | 'none'; side?: 'left' | 'right'; playTablets?: boolean; size?: { w: number; h: number; dpr?: number } }; /** Optional capture of raw frames (web build, iOS Simulator, Android emulator / device, or your own folder). */ capture?: ShotsCapture; /** A/B variants: other scene orders / copy, rendered into ///... (`shots render --variant b`). */ @@ -574,6 +579,9 @@ export interface ShipConfig { * tablet renders exist under the renders folder, else false. false = `shots render` / `shots check`, `validate` and * `play images` never ask for them; true = missing tablet sets are errors. */ tablets?: boolean; + /** Also render Chromebook screenshots (size key `chromebook`, 1920 x 1080, landscape layout). The Play API has no + * Chromebook image type: add them in the Play Console's main store listing (play images says so). */ + chromebook?: boolean; /** Promo video: a YouTube URL (public or unlisted, ads off, embeddable). Play takes no video upload. Sent by `play listing`. */ video?: string; /** Cloud Storage bucket of the Play Console report exports, e.g. "pubsite_prod_rev_" (Play Console > @@ -626,6 +634,10 @@ export interface ShipConfig { * Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds. Not uploaded while the API has no * display type for them (cli/shots/spec.ts IPHONE_DUO_DISPLAY). */ iphoneDuo?: boolean; + /** Also render, check and upload Mac App Store screenshots (size key `mac`, 2880 x 1800, 16:10; Apple also takes + * 2560 x 1600, 1440 x 900, 1280 x 800). Landscape layout from //landscape/ (shots.landscape). Uploaded + * as APP_DESKTOP to the macOS version: `apple screenshots --platform macos`. */ + mac?: boolean; /** "Header and Search Results" creative assets (`storebound apple creative-assets`, iOS / iPadOS 27+). Files under * //creative/: header.{png,jpg,mov,m4v,mp4} (3840x1646, 21:9), search.{...} (3:2, 1920x1280 to * 3840x2560) or universal.png (5244x2950) for both. `header` / `search` false skip that slot; `universal` is a @@ -1185,6 +1197,8 @@ export function validateConfig(c: unknown): string[] { for (const k of ['copy', 'frames', 'out'] as const) if (!s[k]) e.push(`shots.${k}: missing`); if (s.style !== undefined && s.style !== 'game' && s.style !== 'app') e.push('shots.style: game | app'); if (!s.scenes?.length) e.push('shots.scenes: at least one scene'); + if (s.landscape?.frame !== undefined && s.landscape.frame !== 'window' && s.landscape.frame !== 'none') e.push("shots.landscape.frame: 'window' | 'none'"); + if (s.landscape?.side !== undefined && s.landscape.side !== 'left' && s.landscape.side !== 'right') e.push("shots.landscape.side: 'left' | 'right'"); 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'"); diff --git a/cli/lib/embedded.ts b/cli/lib/embedded.ts index ddf1d2f..1a18a42 100644 --- a/cli/lib/embedded.ts +++ b/cli/lib/embedded.ts @@ -38,10 +38,10 @@ export const TEMPLATE_TEXT: Record = { /** The pages shots render / video load (cli/shots/*.html), by file name. */ export const SHOTS_PAGES: Record = { "caption.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", - "feature-app.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", - "feature.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", - "template-app.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", - "template.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", + "feature-app.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", + "feature.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", + "template-app.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", + "template.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", }; /** Play Console's Data safety form export (cli/data/play-data-safety-form.csv). */ diff --git a/cli/lib/validate-rules.ts b/cli/lib/validate-rules.ts index 3c9e93a..491fb2a 100644 --- a/cli/lib/validate-rules.ts +++ b/cli/lib/validate-rules.ts @@ -3,7 +3,7 @@ import { consoleLink, consoleUrl } from './console-links'; import { type FixAction, fixAppleListing, fixPlayListing, fixReviewDetails } from './fix-actions'; -import { iphoneDuoNote } from '../shots/spec'; +import { iphoneDuoNote, macNote } from '../shots/spec'; export type Level = 'blocker' | 'warn' | 'ok'; @@ -70,6 +70,9 @@ export interface AppleSnapshot { requiredDisplayTypes: string[]; /** apple.iphoneDuo (iPhone Duo renders on) and whether the project has a play section, for the Duo note. */ iphoneDuo?: boolean; + /** The app has a macOS App Store version (any state), and apple.mac (Mac screenshots on), for the Mac note. */ + macVersion?: boolean; + mac?: boolean; play?: boolean; today?: Date; reviewDetail: { contactEmail?: string | null; contactPhone?: string | null; contactFirstName?: string | null; contactLastName?: string | null; demoAccountRequired?: boolean; demoAccountName?: string | null; notes?: string | null } | null; @@ -280,6 +283,9 @@ export function appleRules(s: AppleSnapshot): Finding[] { // iPhone Duo: optional until April 2027, so a passing note (never a blocker); a warning after that date const duo = iphoneDuoNote({ apple: true, duo: !!s.iphoneDuo, play: s.play, today: s.today }); if (duo) add(duo.level === 'info' ? 'ok' : 'warn', 'screenshots-duo', duo.msg, 'apple.iphoneDuo: true, then storebound shots render'); + // a macOS version without Mac screenshots: an info note (the macOS version's own checks run with --platform macos) + const mac = macNote({ macVersion: !!s.macVersion, mac: !!s.mac }); + if (mac) add('ok', 'screenshots-mac', mac, 'apple.mac: true, storebound shots render --size mac, apple screenshots --platform macos'); return sortFindings(f); } diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index 70aa50b..9a40eda 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -230,7 +230,7 @@ export const TOOLS: ToolDef[] = [ ...(i.concurrency ? ['--concurrency', String(i.concurrency)] : []), ...(i.mode === 'force' ? ['--force'] : i.mode === 'only-missing' ? ['--only-missing'] : [])] }, { name: 'shots_render', title: 'Render store screenshots', command: ['shots', 'render'], write: true, network: false, idempotent: true, description: 'Store screenshots + Play feature graphic in every size and language from the frames and the copy file (local PNGs, needs Playwright + ffmpeg; slow for many languages). dry_run lists what would be rendered.', - props: { lang, size: { type: 'string', description: 'Comma-separated size keys: ios69, ios65, iphone-medium, iphone-duo-outer, iphone-duo-inner, ipad13, play, tab7, tab10, feature' }, + props: { lang, size: { type: 'string', description: 'Comma-separated size keys: ios69, ios65, iphone-medium, iphone-duo-outer, iphone-duo-inner, ipad13, mac, play, tab7, tab10, chromebook, feature' }, scenes: { type: 'string', description: 'Comma-separated scene ids' }, variant, all_variants: { type: 'boolean', default: false, description: 'Base + every variant' }, concurrency: { type: 'integer', minimum: 1, maximum: 32, description: 'Parallel pages (default min(4, CPU cores / 2))' } }, args: i => [...opt('lang', i.lang), ...opt('size', i.size), ...opt('scenes', i.scenes), ...(i.all_variants === true ? ['--variants'] : opt('variant', i.variant)), diff --git a/cli/play/images.ts b/cli/play/images.ts index 6de73ef..8ee5940 100644 --- a/cli/play/images.ts +++ b/cli/play/images.ts @@ -29,6 +29,12 @@ import { pulledChecksum } from '../lib/art-pull'; import { readPlayCopy } from './listing'; import { variantRoot } from '../shots/lib'; import { resolvedTablets } from '../shots/report'; +import { consoleLink } from '../lib/console-links'; + +/** play.chromebook on: the Chromebook renders (chromebook/NN.png) are not uploaded (the Play API has no Chromebook + * image type); the message names the console page. */ +export const chromebookSkip = (on: boolean | undefined): string | null => on + ? `chromebook: not uploaded (the Play Developer API has no Chromebook screenshot type); add //chromebook/*.png in ${consoleLink('play.store-listing', '> Chromebook screenshots')}` : null; export const PLAY_IMAGE_DIRS = { phoneScreenshots: 'play', sevenInchScreenshots: 'tab7', tenInchScreenshots: 'tab10', tvScreenshots: 'tv', wearScreenshots: 'wear' } as const; export const PLAY_TABLET_TYPES: readonly string[] = ['sevenInchScreenshots', 'tenInchScreenshots']; @@ -125,6 +131,8 @@ export async function run(ctx: Ctx): Promise { const src = variantRoot(resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'), ctx.args.get('variant'), config.shots?.variants); if (ctx.args.get('variant')) log('play', `variant ${ctx.args.get('variant')}: ${src}`); const icon = resolve(config.play?.icon ?? join(config.icons?.out ?? 'store/icons', 'play-512.png')); + const cbSkip = chromebookSkip(config.play?.chromebook); + if (cbSkip) log('play', cbSkip); const langOf = byPlayCode(config.languages); // the languages `play listing` pushes: a draft or untranslated section has no Play listing yet (unless --include-drafts) const drafts = draftSections(readFileSync(resolve(config.play?.listing ?? 'store/listing.md'), 'utf8'), 'play'); diff --git a/cli/shots/feature-app.html b/cli/shots/feature-app.html index cf9e210..8a065f5 100644 --- a/cli/shots/feature-app.html +++ b/cli/shots/feature-app.html @@ -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'; diff --git a/cli/shots/feature.html b/cli/shots/feature.html index 0b51ac2..792cd3b 100644 --- a/cli/shots/feature.html +++ b/cli/shots/feature.html @@ -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'; diff --git a/cli/shots/lib.ts b/cli/shots/lib.ts index a173e0c..0e4abfd 100644 --- a/cli/shots/lib.ts +++ b/cli/shots/lib.ts @@ -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 = { +export const SIZES: Record = { ios69: { w: 1320, h: 2868 }, ios65: { w: 1284, h: 2778 }, 'iphone-medium': { w: 1206, h: 2622 }, @@ -14,12 +14,59 @@ export const SIZES: Record//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 = { 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 | 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 = { 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): 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, tablets?: boolean): string[] { @@ -131,6 +179,8 @@ export function defaultSizes(config: Pick, 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)); diff --git a/cli/shots/render.ts b/cli/shots/render.ts index a0eea9d..3ab7876 100644 --- a/cli/shots/render.ts +++ b/cli/shots/render.ts @@ -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 = { 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 { 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 { const started = Date.now(); const report: RenderReport = {}; - async function shoot(p: { page: any; errs: string[] }, url: string, w: number, h: number, out: string): Promise { // 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 { // 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 { // 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 { 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 { const sheet = join(dir, '.sheet.html'); writeFileSync(sheet, `${imgs} `); - 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 { diff --git a/cli/shots/report.ts b/cli/shots/report.ts index d5e4baf..4cc9fbf 100644 --- a/cli/shots/report.ts +++ b/cli/shots/report.ts @@ -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; + 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 { 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 } : {}) }; } diff --git a/cli/shots/spec.ts b/cli/shots/spec.ts index 89813d9..bbb21be 100644 --- a/cli/shots/spec.ts +++ b/cli/shots/spec.ts @@ -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 = { // 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 = { * 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 = { '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 //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' }); diff --git a/cli/shots/template-app.html b/cli/shots/template-app.html index 30accd0..b72dac3 100644 --- a/cli/shots/template-app.html +++ b/cli/shots/template-app.html @@ -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. --> ', `${o.css}`); + const job = { style, phone: DEFAULT_PHONE, tablet: DEFAULT_TABLET, landscape: { w: 1440, h: 900, bar: 0 }, side: 'left', logo: !!o.hero, appName: 'Demo', + fonts: { title: 'X', body: 'Y' }, number: 1, scene: { id: 'a', hero: !!o.hero, frames: [{ src: 'a.png' }] }, + palette: { bg: ['#123456', '#345678', '#567890'], glow: '#ffffff', ink: '#000000', shadow: 'rgba(0,0,0,.4)' }, + copy: { eyebrow: 'Daily puzzles', title: o.title ?? ['Match three', 'Win big'], sub: 'Hundreds of levels, no wifi needed.' } }; + await page.route('http://t.test/**', (r: { request(): { url(): string }; fulfill(o: unknown): Promise }) => { + const u = new URL(r.request().url()); + if (u.pathname.endsWith('.html')) 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/') || u.pathname === '/logo') return r.fulfill({ contentType: 'image/png', body: PNG }); + return r.fulfill({ contentType: 'text/css', body: '' }); + }); + const dev = o.landscape ? `&device=landscape&frame=${o.frame ?? 'window'}` : ''; + await page.goto(`http://t.test/t.html?s=a&w=${w}&h=${h}&lang=en${dev}`); + await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: scaled(15000) }); + const read = async (k: string) => JSON.parse((await page.evaluate(`document.body.dataset.${k}`)) ?? '[]'); + return { boxes: await read('boxes') as TextBox[], devices: await read('devices') as { x: number; y: number; w: number; h: number }[], + frameUrl: await page.evaluate("document.querySelector('.lwin img, .tel img.app')?.getAttribute('src') ?? ''") as string, + titlebar: await page.evaluate("!!document.querySelector('.lwin:not(.bare) .tb')") as boolean }; + } finally { await page.close(); } +} + +describe.skipIf(!browser)('landscape layout', () => { + for (const style of ['game', 'app'] as const) for (const [size, frame] of [['mac', 'window'], ['chromebook', 'none'], ['tab10', 'none']] as const) test(`${style} ${size} (${frame})`, async () => { + const d = renderDims(size, { landscape: { playTablets: true } }); + const r = await render(style, d.w, d.h, { landscape: true, frame }); + expect(r.frameUrl).toContain('/landscape/'); + expect(r.titlebar).toBe(frame === 'window'); + expect(r.devices).toHaveLength(1); + expect(boxOutside(r.devices[0]!, d.w, d.h)).toEqual([]); + // copy on the left, device on the right + expect(Math.max(...r.boxes.map(b => b.x + b.w))).toBeLessThan(r.devices[0]!.x); + expect(r.boxes.length).toBeGreaterThanOrEqual(3); + expect(marginViolations(r.boxes, d.w, d.h)).toEqual([]); + }, scaled(30_000)); +}); + +describe.skipIf(!browser)('safe margins on the portrait templates', () => { + for (const style of ['game', 'app'] as const) for (const [w, h] of [[1320, 2868], [1080, 1920], [2064, 2752]] as const) test(`${style} ${w}x${h}, hero with logo and a scene`, async () => { + for (const hero of [true, false]) { + const r = await render(style, w, h, { hero, title: ['Wonderful long words', 'Everywhere'] }); + expect(r.boxes.length).toBeGreaterThan(0); + if (hero) expect(r.boxes.some(b => b.el === 'logo')).toBe(true); + expect(marginViolations(r.boxes, w, h)).toEqual([]); + } + }, scaled(30_000)); + test('a fixture with the margins taken away fails', async () => { + const r = await render('game', 1080, 1920, { hero: true, css: '.top { padding: 0 !important; top: 0 !important } .logo { width: 100% !important }' }); + const v = marginViolations(r.boxes, 1080, 1920); + expect(v.some(m => m.startsWith('logo is too close to the edge (left 0 px, right 0 px, top 0 px'))).toBe(true); + }, scaled(30_000)); +}); diff --git a/tests/idempotency.test.ts b/tests/idempotency.test.ts index b8b2cb6..670f19e 100644 --- a/tests/idempotency.test.ts +++ b/tests/idempotency.test.ts @@ -170,6 +170,22 @@ describe('App Store Connect: a second run writes nothing', () => { expect(count('appScreenshots')).toBe(12); }); + test('apple screenshots --platform macos: the Mac set (APP_DESKTOP) on the macOS version only', async () => { + const files: Record = {}; + for (const l of ['en', 'de']) for (const n of [1, 2]) files[`store/renders/${l}/mac/0${n}.png`] = png(n + (l === 'de' ? 40 : 30)); + const p = project({ ...CONFIG, apple: { ...CONFIG.apple!, mac: true } }, files); + const ios = s.asc.put({ type: 'appStoreVersions', id: 'ver-ios', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } }); + const mac = s.asc.put({ type: 'appStoreVersions', id: 'ver-mac', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'MAC_OS' }, rels: { app: APP } }); + for (const v of [ios, mac]) for (const loc of ['en-US', 'de-DE']) s.asc.put({ type: 'appStoreVersionLocalizations', id: `loc-${v.id}-${loc}`, attributes: { locale: loc }, rels: { appStoreVersion: v.id } }); + const r = await twice(() => appleScreenshots(p.ctx(['--platform', 'macos']))); + expect(r.first.filter(x => x === 'POST /v1/appScreenshotSets')).toHaveLength(2); + expect(r.second).toEqual([]); + const sets = s.asc.all('appScreenshotSets'); + expect(sets.map(x => x.attributes.screenshotDisplayType)).toEqual(['APP_DESKTOP', 'APP_DESKTOP']); + expect(sets.every(x => JSON.stringify(x).includes('loc-ver-mac-'))).toBe(true); + expect(count('appScreenshots')).toBe(4); + }); + test('apple cpp create, events create, testflight groups, review-details, bundle, offer-codes, ppo create', async () => { const p = project({ ...CONFIG, shots: { variants: { b: {} } } as never }, { '.storebound/credentials.json': JSON.stringify({ reviewContact: { firstName: 'Ex', lastName: 'Ample', phone: '+1 555 0100', email: 'review@example.com' } }) }); s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } }); diff --git a/tests/mac-landscape.test.ts b/tests/mac-landscape.test.ts new file mode 100644 index 0000000..7a5ec32 --- /dev/null +++ b/tests/mac-landscape.test.ts @@ -0,0 +1,105 @@ +// Mac App Store screenshots (APP_DESKTOP, 16:10), the landscape layout's sizes, the safe-margin rule and the contact +// sheet timeout. The page-level render of the landscape layout and the margins: tests/browser/landscape.browser.ts. +import { describe, expect, test } from 'bun:test'; +import { checkImage, IMAGE_SPECS, imageInfo, macNote } from '../cli/shots/spec'; +import { defaultSizes, marginViolations, renderDims, SAFE_MARGIN_MIN, sheetLayout, sheetTimeoutMs, SIZES } from '../cli/shots/lib'; +import { marginIssues, requiredSets } from '../cli/shots/report'; +import { displaySets, MAC_DISPLAY } from '../cli/apple/screenshots'; +import { chromebookSkip } from '../cli/play/images'; +import { APPLE_SHOT_DIR } from '../cli/lib/art-pull'; +import { validateConfig } from '../cli/lib/config'; +import { loadFactor } from '../cli/shots/render'; +import { png } from './fixtures'; + +describe('Mac sizes', () => { + test('the four 16:10 sizes Apple lists, landscape only, APP_DESKTOP', () => { + expect(IMAGE_SPECS.mac!.sizes).toEqual([[2880, 1800], [2560, 1600], [1440, 900], [1280, 800]]); + expect(IMAGE_SPECS.mac!.type).toBe('APP_DESKTOP'); + expect(SIZES.mac).toMatchObject({ w: 2880, h: 1800, device: 'landscape' }); + for (const [w, h] of IMAGE_SPECS.mac!.sizes) expect(checkImage(imageInfo(png(w, h)), 'mac')).toEqual([]); + expect(checkImage(imageInfo(png(1800, 2880)), 'mac').map(i => i.code)).toEqual(['size']); + expect(checkImage(imageInfo(png(1920, 1080)), 'mac').map(i => i.code)).toEqual(['size']); + expect(checkImage(imageInfo(png(2880, 1800, { colorType: 6 })), 'mac').map(i => i.code)).toEqual(['alpha']); + }); + test('opt-in with apple.mac; required then', () => { + expect(defaultSizes({ apple: { bundleId: 'x' } } as never)).not.toContain('mac'); + expect(defaultSizes({ apple: { bundleId: 'x', mac: true } } as never)).toContain('mac'); + expect(requiredSets({ apple: { mac: true } }).mac).toBe(true); + expect(requiredSets({ apple: {} }).mac).toBeUndefined(); + }); + test('uploads: the Mac set only to a macOS version', () => { + expect(MAC_DISPLAY).toEqual({ mac: 'APP_DESKTOP' }); + expect(displaySets({ mac: true }, 'MAC_OS')).toEqual({ mac: 'APP_DESKTOP' }); + expect(displaySets({ mac: true }, 'IOS')).not.toHaveProperty('mac'); + expect(displaySets({}, 'MAC_OS')).toEqual({}); + expect(APPLE_SHOT_DIR.APP_DESKTOP).toBe('mac'); + }); + test('validate note: a macOS version without Mac screenshots', () => { + expect(macNote({ macVersion: true, mac: false })).toContain('apple.mac: true'); + expect(macNote({ macVersion: true, mac: true })).toBeNull(); + expect(macNote({ macVersion: false, mac: false })).toBeNull(); + }); +}); + +describe('landscape layout sizes', () => { + test('mac: window frame by default; chromebook and Play tablets: bare', () => { + expect(renderDims('mac', undefined)).toEqual({ w: 2880, h: 1800, landscape: true, frame: 'window' }); + expect(renderDims('chromebook', undefined)).toEqual({ w: 1920, h: 1080, landscape: true, frame: 'none' }); + expect(renderDims('tab10', undefined)).toMatchObject({ w: 1440, h: 2560, landscape: false }); + expect(renderDims('tab10', { landscape: { playTablets: true } })).toMatchObject({ w: 2560, h: 1440, landscape: true, frame: 'none' }); + expect(renderDims('mac', { landscape: { frame: 'none' } }).frame).toBe('none'); + for (const k of ['tab7', 'tab10']) { const d = renderDims(k, { landscape: { playTablets: true } }); expect(checkImage(imageInfo(png(d.w, d.h)), k)).toEqual([]); } + }); + test('chromebook: opt-in with play.chromebook, a check, and a console note instead of an upload', () => { + expect(defaultSizes({ play: { packageName: 'x' } } as never)).not.toContain('chromebook'); + expect(defaultSizes({ play: { packageName: 'x', chromebook: true } } as never)).toContain('chromebook'); + expect(checkImage(imageInfo(png(1920, 1080)), 'chromebook')).toEqual([]); + expect(chromebookSkip(true)).toContain('Main store listing'); + expect(chromebookSkip(false)).toBeNull(); + }); + test('config: frame and side are validated', () => { + const errs = (landscape: unknown) => validateConfig({ app: { name: 'X', profile: 'game' }, languages: [{ id: 'en' }], + shots: { copy: 'c.json', frames: 'f', out: 'o', scenes: [{ id: 'a', frames: [{ src: 'a.png' }] }], landscape } } as never).filter(e => e.startsWith('shots.landscape')); + expect(errs({ frame: 'window', side: 'right', playTablets: true })).toEqual([]); + expect(errs({ frame: 'macbook' })).toEqual(["shots.landscape.frame: 'window' | 'none'"]); + }); +}); + +describe('safe margins', () => { + const W = 1320, H = 2868; + test('boxes inside the margins pass; text at the edge fails', () => { + expect(marginViolations([{ el: 'title', x: W * .07, y: H * .05, w: W * .86, h: 200 }], W, H)).toEqual([]); + const v = marginViolations([{ el: 'title', x: 12, y: 300, w: 1290, h: 200 }, { el: 'logo', x: 200, y: 20, w: 900, h: 300 }], W, H); + expect(v).toHaveLength(2); + expect(v[0]).toContain('title is too close to the edge (left 12 px, right 18 px'); + expect(v[1]).toContain('logo is too close to the edge (top 20 px'); + expect(Math.round(W * SAFE_MARGIN_MIN.x)).toBe(79); + }); + test('shots check: an error per box, none without layout data', () => { + const e = marginIssues({ overflow: [], boxes: [{ el: 'subtitle', x: 0, y: 900, w: 500, h: 60 }] }, W, H); + expect(e.map(i => [i.level, i.code])).toEqual([['error', 'margin']]); + expect(marginIssues({ overflow: [] }, W, H)).toEqual([]); + expect(marginIssues(undefined, W, H)).toEqual([]); + }); +}); + +describe('contact sheet timeout', () => { + test('scales with images, sizes and load; never a flat 30 s', () => { + const nine = sheetTimeoutMs(9 * 5, 9); + expect(nine).toBeGreaterThan(30_000 * 3); + expect(sheetTimeoutMs(90, 9)).toBeGreaterThan(nine); + expect(sheetTimeoutMs(45, 9, 3)).toBe(nine * 3); + expect(sheetTimeoutMs(45, 9, 50)).toBe(nine * 5); + expect(sheetTimeoutMs(1, 1, 0.2)).toBe(sheetTimeoutMs(1, 1)); + }); + test('load factor: forced by env, clamped 1..5', () => { + expect(loadFactor({ STOREBOUND_LOAD_FACTOR: '3' })).toBe(3); + expect(loadFactor({}, [40, 20, 0], 10)).toBe(4); + expect(loadFactor({}, [0, 0, 0], 10)).toBe(1); + expect(loadFactor({}, [500, 0, 0], 10)).toBe(5); + }); + test('landscape tiles take half height on the sheet', () => { + const l = sheetLayout([{ size: 'mac', files: ['a'], dims: { w: 2880, h: 1800 } }]); + expect(l.tiles[0]).toMatchObject({ h: 450, w: 720 }); + }); +}); diff --git a/tools/ci-plan.ts b/tools/ci-plan.ts index 6d577e2..cb8973f 100644 --- a/tools/ci-plan.ts +++ b/tools/ci-plan.ts @@ -15,7 +15,7 @@ export const TIMINGS = join(ROOT, 'node_modules/.cache/storebound-ci/test-timing export const HEAVY = [ 'tests/browser/console-fill.browser.ts', 'tests/browser/site-overflow.browser.ts', 'tests/browser/local-fonts.browser.ts', 'tests/browser/glyphs.browser.ts', 'tests/browser/capture-web.browser.ts', 'tests/browser/console-session.browser.ts', 'tests/browser/console-persist.browser.ts', 'tests/browser/console-flow.browser.ts', - 'tests/browser/glyph-ligatures.browser.ts', 'tests/browser/feature-fit.browser.ts', + 'tests/browser/glyph-ligatures.browser.ts', 'tests/browser/feature-fit.browser.ts', 'tests/browser/landscape.browser.ts', ]; /** Browser tests of one file at the same time (bun test --max-concurrency for the browser shards; the tests opt in