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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue