play images: tablet, TV and Wear sets are optional; play.tablets switch (default: tablet renders exist); a missing optional set never deletes Play's images without --prune
A phone-only project aborted with 'no sevenInchScreenshots'. Play requires the 512 icon, the 1024x500 feature graphic and at least 2 screenshots (Play Console Help answer 9866151). play images uploads what exists and prints an info line for missing optional sets; play.tablets true makes missing tablet sets an error in shots render/check/gallery, validate and play images. Pitfall 19.
This commit is contained in:
parent
18fde37b9b
commit
a9261a8052
18 changed files with 364 additions and 83 deletions
|
|
@ -3,7 +3,8 @@
|
|||
// Per image: pixel size for its store type (Apple: a listed size, portrait or landscape; Play screenshots: 320-3840 px,
|
||||
// at most 2:1; feature graphic exactly 1024x500), no alpha (both stores), Play: JPEG or 24-bit PNG, 8 MB per screenshot.
|
||||
// --variants also checks the A/B variant folders (<renders>/<variant>/<lang>/...).
|
||||
// Per set: Play 2-8 phone screenshots, Apple 1-10 per display type, 13" iPad set when apple.ipad, feature graphic.
|
||||
// Per set: Play 2-8 phone screenshots, Apple 1-10 per display type, 13" iPad set when apple.ipad, feature graphic,
|
||||
// Play 7" / 10" tablet sets only when play.tablets (default: tablet renders exist; optional on Play).
|
||||
// Per app preview: 886x1920 / 1200x1600, H.264, <= 30 fps, 15-30 s, stereo AAC track, <= 500 MB, <= 3 per set.
|
||||
// Text overflow flags come from the last `shots render` (report.json). Frames: with capture.source "folder" every frame
|
||||
// the scenes use must exist with the right aspect. Local font files (shots.fonts paths) without a license note: warn.
|
||||
|
|
@ -16,43 +17,45 @@ import { log, printJson } from '../lib/log';
|
|||
import { checkFrames, type FrameIssue } from './devices';
|
||||
import { defaultSizes, frameDims, SIZES } from './lib';
|
||||
import type { LocalIssue, RendersCheck } from '../lib/validate-rules';
|
||||
import { allIssues, requiredSets, type RendersScan, scanRenders, storeOf } from './report';
|
||||
import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders, storeOf } from './report';
|
||||
import { imageInfo } from './spec';
|
||||
import { fontsDirOf, readManifest } from './fonts';
|
||||
import { localFontIssues } from './local-fonts';
|
||||
|
||||
/** Frame files the scenes (and the feature graphic) use, per language and device class. */
|
||||
export function framesNeeded(config: ShipConfig, langs: string[]): { lang: string; device: 'phone' | 'tablet'; name: string }[] {
|
||||
export function framesNeeded(config: ShipConfig, langs: string[], tablets?: boolean): { lang: string; device: 'phone' | 'tablet'; name: string }[] {
|
||||
const shots = config.shots!;
|
||||
const sizes = defaultSizes(config);
|
||||
const sizes = defaultSizes(config, tablets);
|
||||
const devices: ('phone' | 'tablet')[] = ['phone', ...(sizes.some(s => SIZES[s]!.device === 'tablet') ? ['tablet' as const] : [])];
|
||||
const scene = [...new Set(shots.scenes.flatMap(s => s.frames.map(f => f.src)))];
|
||||
const phone = [...new Set([...scene, ...(shots.feature?.frame && sizes.includes('feature') ? [shots.feature.frame] : [])])];
|
||||
return langs.flatMap(lang => devices.flatMap(device => (device === 'phone' ? phone : scene).map(name => ({ lang, device, name }))));
|
||||
}
|
||||
|
||||
export function checkFrameFolder(config: ShipConfig, dir: string, langs: string[]): FrameIssue[] {
|
||||
export function checkFrameFolder(config: ShipConfig, dir: string, langs: string[], tablets?: boolean): FrameIssue[] {
|
||||
const found = new Map<string, { w: number; h: number }>();
|
||||
for (const lang of langs) for (const device of ['phone', 'tablet']) {
|
||||
const d = join(dir, lang, device);
|
||||
if (!existsSync(d)) continue;
|
||||
for (const f of readdirSync(d).filter(x => /\.png$/i.test(x))) { const i = imageInfo(readFileSync(join(d, f))); found.set(`${lang}/${device}/${f}`, i); }
|
||||
}
|
||||
return checkFrames(found, framesNeeded(config, langs), frameDims(config));
|
||||
return checkFrames(found, framesNeeded(config, langs, tablets), frameDims(config));
|
||||
}
|
||||
|
||||
export function scanProject(ctx: Ctx): RendersScan {
|
||||
const { config, resolve } = ctx.loaded;
|
||||
const langs = ctx.args.list('lang').length ? ctx.args.list('lang') : config.languages.map(l => l.id);
|
||||
const dir = resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders');
|
||||
return scanRenders(dir, { langs, variants: ctx.args.has('variants') ? Object.keys(config.shots?.variants ?? {}) : [], required: lang => requiredSets(config, lang) });
|
||||
const tablets = resolvedTablets(config, dir);
|
||||
return scanRenders(dir, { langs, variants: ctx.args.has('variants') ? Object.keys(config.shots?.variants ?? {}) : [], required: lang => requiredSets(config, lang, tablets) });
|
||||
}
|
||||
|
||||
/** Local renders check split per store, for `validate` (base renders only, no variants). null = no renders folder. */
|
||||
export function rendersCheck(config: ShipConfig, dir: string): Record<'apple' | 'play', RendersCheck> | null {
|
||||
if (!existsSync(dir)) return null;
|
||||
const langs = config.languages.map(l => l.id);
|
||||
const scan = scanRenders(dir, { langs, required: lang => requiredSets(config, lang) });
|
||||
const tablets = resolvedTablets(config, dir);
|
||||
const scan = scanRenders(dir, { langs, required: lang => requiredSets(config, lang, tablets) });
|
||||
const out = { apple: { dir, images: 0, issues: [] as LocalIssue[] }, play: { dir, images: 0, issues: [] as LocalIssue[] } };
|
||||
for (const img of scan.images) out[storeOf(img.rel)].images++;
|
||||
for (const v of scan.videos) out[storeOf(v.rel)].images++;
|
||||
|
|
@ -65,7 +68,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const scan = scanProject(ctx);
|
||||
const issues = [...allIssues(scan), ...localFontIssues(readManifest(fontsDirOf(root))).map(issue => ({ where: '.storebound/fonts/fonts.json', issue }))];
|
||||
const cap = config.shots?.capture;
|
||||
const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, resolve(cap.folder ?? config.shots!.frames), scan.langs) : [];
|
||||
const tablets = resolvedTablets(config, resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'));
|
||||
const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, resolve(cap.folder ?? config.shots!.frames), scan.langs, tablets) : [];
|
||||
if (ctx.args.has('json')) {
|
||||
printJson({ images: scan.images.length, videos: scan.videos.length, issues, frames: frameIssues });
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { join, relative } from 'node:path';
|
|||
import type { Ctx } from '../lib/ctx';
|
||||
import { done, log, printJson, warn } from '../lib/log';
|
||||
import { SIZES } from './lib';
|
||||
import { allIssues, requiredSets, type RendersScan, scanRenders } from './report';
|
||||
import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders } from './report';
|
||||
import { type Issue, IMAGE_SPECS, VIDEO_TARGETS } from './spec';
|
||||
|
||||
export interface GalleryTile {
|
||||
|
|
@ -241,7 +241,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const dir = resolve(a.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders');
|
||||
const out = a.get('out') ? resolve(a.get('out')!) : join(dir, 'index.html');
|
||||
const variants = a.has('no-variants') ? [] : Object.keys(config.shots?.variants ?? {});
|
||||
const scan = scanRenders(dir, { langs, variants, required: lang => requiredSets(config, lang) });
|
||||
const tablets = resolvedTablets(config, dir);
|
||||
const scan = scanRenders(dir, { langs, variants, required: lang => requiredSets(config, lang, tablets) });
|
||||
if (!scan.images.length && !scan.videos.length) warn('shots', `no renders in ${dir} (storebound shots render)`);
|
||||
const data = galleryData(scan, { at: join(out, '..'), title: `${config.app.name} · store renders`, exists: existsSync });
|
||||
// --json: the data only (the desktop gallery), with the renders folder relative to the app folder; writes nothing
|
||||
|
|
|
|||
|
|
@ -116,12 +116,18 @@ export function frameDims(config: Pick<ShipConfig, 'apple' | 'shots'>): Record<'
|
|||
return { phone: { ...DEFAULT_PHONE, ...config.shots?.phone }, tablet: { ...tablet, ...config.shots?.tablet } };
|
||||
}
|
||||
|
||||
/** Play tablet size keys (7" and 10"): optional on Play, switched by play.tablets. */
|
||||
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),
|
||||
* Play sizes only with a play section (both when neither is configured). */
|
||||
export function defaultSizes(config: Pick<ShipConfig, 'apple' | 'play'>): string[] {
|
||||
* 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[] {
|
||||
const apple = !!config.apple || !config.play, play = !!config.play || !config.apple;
|
||||
return Object.keys(SIZES).filter(k => (k === 'ipad13' ? apple && !!config.apple?.ipad : k === 'iphone-medium' ? apple && !!config.apple?.iphoneMedium : k.startsWith('ios') ? apple : play));
|
||||
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('ios') ? apple : PLAY_TABLET_SIZES.includes(k) ? play && tabs : play));
|
||||
}
|
||||
|
||||
/** Scenes of a variant (its order / subset, palette overrides), or the base scenes. */
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
// Out: <shots.out>/<lang>/<size>/NN.png (24-bit RGB: Play rejects alpha / palette PNGs), <lang>/feature/1024x500.png,
|
||||
// <lang>/sheet.png (contact sheet of every size packed into a grid, for a quick review) and <shots.out>/report.json (text overflow flags
|
||||
// per image, read by `shots check` and `shots gallery`). Variants go to <shots.out>/<variant>/<lang>/...
|
||||
// Default sizes: defaultSizes() (ipad13 only with apple.ipad). Needs playwright + ffmpeg.
|
||||
// Default sizes: defaultSizes() (ipad13 only with apple.ipad; tab7 / tab10 only with play.tablets, default: tablet renders
|
||||
// already exist). Needs playwright + ffmpeg.
|
||||
// Fonts: the per-language stacks from .storebound/fonts/fonts.json (`shots fonts`). Before rendering, every character
|
||||
// of the copy is checked against the cmaps of the downloaded faces (stale fonts fail with the language and the
|
||||
// characters); while rendering, the page measures which face drew every character, and report.json records it per
|
||||
|
|
@ -25,7 +26,7 @@ import { defaultConcurrency, pool, progressLine } from './capture-web';
|
|||
import { shotsStyle } from '../lib/config';
|
||||
import { type CopyFile, SIZES, 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 } from './report';
|
||||
import { type RenderReport, mergeReport, readReport, resolvedTablets } from './report';
|
||||
|
||||
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' };
|
||||
|
|
@ -35,7 +36,10 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const shots = need(config.shots, 'shots');
|
||||
const a = ctx.args;
|
||||
const langs = a.list('lang').length ? a.list('lang') : config.languages.map(l => l.id);
|
||||
const sizes = a.list('size').length ? a.list('size') : defaultSizes(config);
|
||||
const tablets = resolvedTablets(config, resolve(shots.out));
|
||||
const sizes = a.list('size').length ? a.list('size') : defaultSizes(config, tablets);
|
||||
if (config.play && !tablets && !a.list('size').length)
|
||||
log('shots', `Play tablet screenshots: not rendered (${config.play.tablets === false ? 'play.tablets is false' : 'no tablet renders yet; set play.tablets: true to render tab7 / tab10'}; optional on Play)`);
|
||||
for (const s of sizes) if (!SIZES[s]) throw new Error(`unknown size ${s} (${Object.keys(SIZES).join(', ')})`);
|
||||
const variants: (string | undefined)[] = a.has('variants') ? [undefined, ...Object.keys(shots.variants ?? {})] : [a.get('variant')];
|
||||
const baseCopy = JSON.parse(readFileSync(resolve(shots.copy), 'utf8')) as CopyFile;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { SIZES } from './lib';
|
||||
import { PLAY_TABLET_SIZES, SIZES } from './lib';
|
||||
import { fontIssues, type FontUsage } from './glyphs';
|
||||
import { checkCount, checkImage, checkVideo, type ImageInfo, imageInfo, type Issue, IMAGE_SPECS, parseProbe, PREVIEW_RULES, type ProbeInfo, VIDEO_TARGETS } from './spec';
|
||||
|
||||
|
|
@ -86,13 +86,29 @@ 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. */
|
||||
* 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[] }[] },
|
||||
lang?: string): Record<string, boolean> {
|
||||
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));
|
||||
return { ios69: apple, ...(c.apple?.iphoneMedium ? { 'iphone-medium': apple } : {}), ipad13: apple && !!c.apple?.ipad, play, feature: 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,
|
||||
...(tabs ? { tab7: true, tab10: true } : {}) };
|
||||
}
|
||||
|
||||
/** Whether a language (or variant/language) folder under the renders dir has Play tablet renders (tab7/ or tab10/ with
|
||||
* NN.png|jpg), for the play.tablets default. Reads the folder tree two levels deep. */
|
||||
export function tabletRendersExist(dir: string): boolean {
|
||||
const sub = (d: string): string[] => { try { return readdirSync(d, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => join(d, e.name)); } catch { return []; } };
|
||||
const has = (d: string) => PLAY_TABLET_SIZES.some(s => existsSync(join(d, s)) && readdirSync(join(d, s)).some(f => /^\d+\.(png|jpe?g)$/i.test(f)));
|
||||
return sub(dir).some(l => has(l) || sub(l).some(has));
|
||||
}
|
||||
|
||||
/** play.tablets for a project, with the default (tablet renders exist under `rendersDir`) filled in. */
|
||||
export function resolvedTablets(config: { play?: { tablets?: boolean } }, rendersDir: string): boolean {
|
||||
return config.play?.tablets ?? tabletRendersExist(rendersDir);
|
||||
}
|
||||
|
||||
export const allIssues = (s: RendersScan): { where: string; issue: Issue }[] => [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue