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:
parent
30488c512d
commit
e59539fec8
26 changed files with 532 additions and 67 deletions
12
CHANGELOG.md
12
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`
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
// storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]
|
||||
// Source per language: <renders>/<language id>/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 <id> uploads <renders>/<id>/<lang>/... 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<string, string> = { ipad13: 'APP_IPAD_PRO_3GEN
|
|||
/** 6.3" iPhone (Dynamic Island, medium display), uploaded only when apple.iphoneMedium is true. */
|
||||
export const MEDIUM_DISPLAY: Record<string, string> = { '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<string, string> =>
|
||||
({ ...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<string, string> = { 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<string, string> =>
|
||||
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<void> {
|
|||
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<void> {
|
|||
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; }
|
||||
|
|
|
|||
|
|
@ -408,7 +408,7 @@ type LiveSet = LiveArtSet;
|
|||
type LiveResult = LiveArtResult;
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
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',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export interface RemoteArt {
|
|||
}
|
||||
|
||||
/** App Store display type -> renders size folder. */
|
||||
export const APPLE_SHOT_DIR: Record<string, string> = { 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<string, string> = { 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<string, string> = { 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). */
|
||||
|
|
|
|||
|
|
@ -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: <frames>/<lang>/landscape/<name>.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 <out>/<variant>/<lang>/... (`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_<number>" (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 <frames>/<lang>/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
|
||||
* <renders>/<lang>/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'");
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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 <renders>/<lang>/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<void> {
|
|||
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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 } : {}) };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
|||
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
|
||||
requiredDisplayTypes: requiredDisplayTypes(config.apple),
|
||||
iphoneDuo: !!config.apple?.iphoneDuo, play: !!config.play,
|
||||
macVersion: every.some(x => platformOf(x) === 'MAC_OS'), mac: !!config.apple?.mac,
|
||||
priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'),
|
||||
availability: !!avail?.data,
|
||||
firstProductGaps,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ fails without the fix.
|
|||
|
||||
| # | date | lesson | guard |
|
||||
|---|---|---|---|
|
||||
| 80 | 2026-10 | A game's hero logo and titles sat against the screenshot edges, a game with a macOS version had no Mac screenshots, and the contact sheet timed out at a flat 30 s with 9 sizes. | Templates keep 7 % / 5 % safe margins and `shots check` fails text or logo boxes under 6 % / 4 % (`marginViolations`, report.json `boxes`); `apple.mac` adds the `mac` size (APP_DESKTOP, macOS version); `sheetTimeoutMs` scales with images, sizes and load. `tests/mac-landscape.test.ts`, `tests/browser/landscape.browser.ts`. |
|
||||
| 78 | 2026-10 | `storebound api <write> --yes` with stdout piped or redirected printed a dry run and exited 0 (twice); the owner took it as sent. | `decide` (`cli/api/gate.ts`) refuses with `NOT_A_TTY`: exit 1, "refused: writes through `storebound api` need an interactive terminal ... Nothing was sent." on stderr, the dedicated command named; without `--yes` still a dry run. `tests/api.test.ts`. |
|
||||
| 77 | 2026-10 | A Mac App Store version was rejected under Guideline 2.3.10: its What's New came from release notes shared with Google Play ("Google Play Games", "Android"); storebound wrote it without a word. Play's Metadata policy is the mirror case. | `cli/lib/cross-store.ts`: other platforms per locale, field and line (App Store text: Android, Google Play (Games), Play Store, Galaxy Store, Amazon Appstore, Steam, Microsoft Store, AppGallery, localized forms; Play text: iOS, iPhone, iPad, App Store, Apple Arcade, Game Center, TestFlight). Every text write refuses (apple / play listing, play release / upload notes, listing set, whats-new check / apply, the desktop editor and storebound edit); check / validate blockers; scan `other-platforms` (2.3.10) and `play-other-platforms` (Play: Metadata). `listing.allowPlatformMentions` for the owner's functional mentions; whats-new splits `[apple]` / `[play]` lines. `tests/cross-store.test.ts`. |
|
||||
| 74 | 2026-10 | Apple sharpened Guideline 2.3.1 (hidden, dormant or undocumented features; obfuscation that hides features is grounds for removal). Apps with remote config / feature flags or remote paywalls whose review notes said nothing risk a rejection or removal when a feature or a hard paywall is switched on after review; Play's Deceptive behavior policy covers the same. Paywall tests and designs are fine once review knows; a business-model change needs a new review and a description update. | `remoteFeatureFindings` (`cli/lib/scan/rules.ts`, SDK table `REMOTE_SDKS` in `cli/lib/scan/sdks.ts`): scan / validate report `remote-features` for Apple (2.3.1) and Play (Deceptive behavior), medium, unless the review notes (apple.info.reviewNotes, reviewAccess.instructions, accounts.inApp) mention remote config, flags, A/B, a paywall test, an experiment or "remotely"; check prints it as a warning. Warn only: storebound never writes the note; the templates carry a commented example line. Code obfuscators are a low note, minify alone nothing. `tests/remote-features.test.ts`. |
|
||||
|
|
|
|||
|
|
@ -88,6 +88,16 @@ skills.
|
|||
|
||||
[All docs](README.md) · [README](../README.md)
|
||||
|
||||
## Mac, landscape and safe margins
|
||||
- **Mac App Store:** `apple.mac: true` renders `mac` (2880 x 1800; Apple takes the four 16:10 sizes 2880 x 1800,
|
||||
2560 x 1600, 1440 x 900, 1280 x 800, no alpha), checks it, and `apple screenshots --platform macos` uploads it as
|
||||
`APP_DESKTOP` to the macOS version. `validate` notes a macOS version without Mac screenshots.
|
||||
- **Landscape layout:** copy on one side, the game or app on the other, in a Mac window (`shots.landscape.frame:
|
||||
'window'`) or bare (`'none'`). Used for `mac`, for Play's `chromebook` size (`play.chromebook`, console only: no API
|
||||
type) and, with `shots.landscape.playTablets`, for the Play 7" / 10" tablet slots. Frames: `<frames>/<lang>/landscape/`.
|
||||
- **Safe margins:** every template keeps text and logo 7 % of the width from the sides and 5 % of the height from the
|
||||
top; `shots check` measures the layout boxes recorded at render time and fails anything under 6 % / 4 %.
|
||||
|
||||
## Header and Search Results (App Store creative assets, iOS / iPadOS 27+)
|
||||
Since 5 Oct 2026 the App Store version page has a "Header and Search Results" tab next to "App Previews and
|
||||
Screenshots". Optional: without these assets the App Store uses in-app events, app previews and screenshots.
|
||||
|
|
|
|||
|
|
@ -978,3 +978,19 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
every version; validate and release status warn about another platform's editable or rejected version.
|
||||
- **Guard:** `cli/apple/platform.ts` (`pickPlatform`, `parsePlatform`), `tests/apple-platforms.test.ts` (fake App Store
|
||||
Connect: iOS 0.8.7 live, macOS 0.8.7 rejected in UNRESOLVED_ISSUES).
|
||||
|
||||
## 80. Screenshot text and logo hugging the edges; a Mac app with no screenshots; sheet.png timing out
|
||||
- **Symptom:** a game's hero logo and titles touched the left / right / top edge of the screenshots (the stores crop
|
||||
and round the corners, it reads cramped); a game with a macOS version had no Mac screenshots (storebound had no Mac
|
||||
size); `shots render` stopped with "sheet.png ... Timeout 30000ms" on a project with 9 sizes.
|
||||
- **Cause:** the templates padded the text by 5 % and drew the logo 86 % wide with a negative top margin, nothing
|
||||
measured it; the size table only knew iPhone, iPad and Play; the contact sheet used the same flat 30 s wait as one
|
||||
screenshot while decoding every image of the language.
|
||||
- **Do:** keep the 7 % / 5 % safe margins (`shots check` code `margin` fails anything under 6 % / 4 %, measured from the
|
||||
page's layout boxes in report.json); set `apple.mac: true` for a Mac app (`mac` size, 16:10, `APP_DESKTOP`, uploaded
|
||||
with `apple screenshots --platform macos`) and capture landscape frames; the sheet's wait now scales with images,
|
||||
sizes and load and logs a heartbeat (or use `--no-sheet`).
|
||||
- **Guard:** `marginViolations` / `SAFE_MARGIN` and `renderDims` in `cli/shots/lib.ts`, `marginIssues` in
|
||||
`cli/shots/report.ts`, `IMAGE_SPECS.mac` and `macNote` in `cli/shots/spec.ts`, `MAC_DISPLAY` in
|
||||
`cli/apple/screenshots.ts`, `sheetTimeoutMs`; `tests/mac-landscape.test.ts`, `tests/browser/landscape.browser.ts`,
|
||||
the macOS case in `tests/idempotency.test.ts`.
|
||||
|
|
|
|||
|
|
@ -36,9 +36,11 @@ or uploaded as another size. `store/renders` can be large (hundreds of MB) and n
|
|||
| iphone-duo-outer | 1398 x 2034 (or 2034 x 1398) | App Store "iPhone Duo" outer display; optional, only with `apple.iphoneDuo: true` (tablet frames) |
|
||||
| iphone-duo-inner | 2007 x 2853 (or 2853 x 2007) | App Store "iPhone Duo" inner display; same switch |
|
||||
| ipad13 | 2064 x 2752 | App Store 13" iPad (`APP_IPAD_PRO_3GEN_129`), required when the app runs on iPad; only with `apple.ipad: true` |
|
||||
| mac | 2880 x 1800 (also 2560 x 1600, 1440 x 900, 1280 x 800) | Mac App Store (`APP_DESKTOP`), 16:10 landscape only; only with `apple.mac: true`, uploaded to the macOS version |
|
||||
| play | 1080 x 1920 | Play phone (2-8, ratio at most 2:1) |
|
||||
| tab7 | 1080 x 1920 | Play 7" tablet (tablet frames, Android tablet body); optional, see `play.tablets` |
|
||||
| tab10 | 1440 x 2560 | Play 10" tablet; optional, see `play.tablets` |
|
||||
| chromebook | 1920 x 1080 | Play Chromebook screenshots (landscape layout); only with `play.chromebook: true`; console only (no API image type) |
|
||||
| feature | 1024 x 500 | Play feature graphic (required) |
|
||||
|
||||
Sources (re-check before a release; Apple renames display classes often):
|
||||
|
|
@ -75,7 +77,30 @@ console's iPhone Duo tab until then (one constant, `IPHONE_DUO_DISPLAY` in `cli/
|
|||
the enum has them). No Duo app preview sizes are published. Google Play has no foldable slot: foldables show the
|
||||
7" / 10" tablet sets below.
|
||||
|
||||
**Play tablets are optional.** Play requires the 512 x 512 icon, the 1024 x 500 feature graphic and at least 2
|
||||
**Mac App Store.** Apple's Screenshot specifications (checked 2026-10-20) list four 16:10 Mac sizes: 1280 x 800,
|
||||
1440 x 900, 2560 x 1600 and 2880 x 1800, PNG / JPEG without alpha, 1-10. `apple.mac: true` adds the `mac` size key
|
||||
(2880 x 1800): `shots render` lays it out in landscape, `shots check` accepts exactly those four sizes (no portrait, no
|
||||
alpha) and requires the set, and `apple screenshots --platform macos` uploads it as `APP_DESKTOP` to the macOS
|
||||
version (versions are per platform, pitfall 79; the iOS run uploads the iOS sets only and says where the Mac set
|
||||
goes). `validate` prints an info note when the app has a macOS version but no Mac screenshots. Frames: landscape
|
||||
captures in `<frames>/<lang>/landscape/<name>.png`, `shots.landscape.size` CSS px (default 1440 x 900 @2x).
|
||||
|
||||
**Landscape layout (Mac, Chromebook, Play tablets).** Copy in a column on one side (`shots.landscape.side`, default
|
||||
left), the scene's first frame on the other. `shots.landscape.frame`: `'window'` draws a Mac window (title bar with
|
||||
the three buttons, the default for `mac`), `'none'` the bare frame with rounded corners (the default for Play sizes).
|
||||
Both templates (game and app) have it. Play: `play.chromebook: true` renders the `chromebook` size (1920 x 1080; the
|
||||
Play API has no Chromebook image type, so `play images` prints the Main store listing deep link instead of
|
||||
uploading), and `shots.landscape.playTablets: true` renders tab7 / tab10 in landscape (1920 x 1080 / 2560 x 1440),
|
||||
which Play's tablet slots accept. Badges are not drawn in landscape.
|
||||
|
||||
**Safe margins (every template, every size, both stores).** Text and logo keep about 7 % of the width left and right
|
||||
and 5 % of the height at the top; the logo scales to fit inside. Each render records the layout boxes of the logo,
|
||||
eyebrow, title lines and subtitle (and the feature graphic's logo or name) in `report.json`, and `shots check` fails
|
||||
any box closer than 6 % of the width to the left / right edge or 4 % of the height to the top (code `margin`). The
|
||||
measurement comes from the page's own layout, not from a pixel scan. Renders from older templates fail it: render
|
||||
again.
|
||||
|
||||
**Play tablets are optional.****Play tablets are optional.** Play requires the 512 x 512 icon, the 1024 x 500 feature graphic and at least 2
|
||||
screenshots (answer 9866151 above); 7" / 10" tablet screenshots are optional, but without them the app is not eligible
|
||||
for tablet featuring. `play.tablets` decides: unset = true when tablet renders (tab7/ or tab10/) already exist, else
|
||||
false; `false` = `shots render`, `shots check`, `shots gallery`, `validate` and `play images` never ask for them;
|
||||
|
|
@ -238,6 +263,9 @@ Screenshots". Optional: without these assets the App Store uses in-app events, a
|
|||
many languages takes minutes: in a terminal prefer `storebound shots render > .storebound/render.log 2>&1 &`.
|
||||
|
||||
## Gotchas
|
||||
- `shots render`'s contact sheet (`sheet.png`) waits for every image to decode: its timeout grows with the images and
|
||||
sizes on the sheet and with the machine's load (`max(1-min, 5-min load) / cores`, 1-5x), with a "still loading" line
|
||||
every 15 s. A timeout names the sheet, the budget and the page errors. `--no-sheet` skips it.
|
||||
- Output is 24-bit RGB PNG: Play rejects alpha / palette PNGs, Apple rejects alpha.
|
||||
- Arabic: `rtl: true` on the language; numbers stay LTR inside RTL text automatically.
|
||||
- Japanese breaks by phrase, Korean at spaces; long words shrink the line rather than wrap.
|
||||
|
|
|
|||
74
tests/browser/landscape.browser.ts
Normal file
74
tests/browser/landscape.browser.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// The screenshot templates in a real page: the landscape layout (Mac window or bare frame, copy column) renders with
|
||||
// its device inside the canvas, and every template keeps its text and logo inside the safe margins (7 % of the width
|
||||
// left / right, 5 % of the height at the top), measured from the page's own layout (body.dataset.boxes). A page with
|
||||
// the margins taken away fails the same rule. Gated like feature-fit: skipped without Chromium.
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { boxOutside, DEFAULT_PHONE, DEFAULT_TABLET, marginViolations, renderDims, type TextBox } from '../../cli/shots/lib';
|
||||
import { AFTER_ALL_MS, launchTestBrowser, scaled } from '../helpers/browser';
|
||||
|
||||
const shared = await launchTestBrowser('playwright-core');
|
||||
const { browser } = shared;
|
||||
if (!browser) console.warn(`landscape / safe-margin render test skipped: no Chromium installed (${shared.skipWhy}). Run \`bunx playwright install chromium\`.`);
|
||||
afterAll(() => shared.close(), AFTER_ALL_MS);
|
||||
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
|
||||
const dir = join(import.meta.dir, '..', '..', 'cli', 'shots');
|
||||
|
||||
async function render(style: 'game' | 'app', w: number, h: number, o: { landscape?: boolean; frame?: string; hero?: boolean; css?: string; title?: [string, string] } = {}) {
|
||||
const page = await browser.newPage({ viewport: { width: w, height: h } });
|
||||
try {
|
||||
let html = readFileSync(join(dir, style === 'app' ? 'template-app.html' : 'template.html'), 'utf8');
|
||||
if (o.css) html = html.replace('</style>', `${o.css}</style>`);
|
||||
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<void> }) => {
|
||||
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));
|
||||
});
|
||||
|
|
@ -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<string, Uint8Array> = {};
|
||||
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 } });
|
||||
|
|
|
|||
105
tests/mac-landscape.test.ts
Normal file
105
tests/mac-landscape.test.ts
Normal file
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue