--variant on play images, apple screenshots and shots jpeg; shared set up-to-date check

This commit is contained in:
CRTkafa 2026-10-10 22:16:38 +08:00
parent 595bdbcf78
commit bd12bd0690
4 changed files with 35 additions and 17 deletions

View file

@ -14,6 +14,15 @@ export type Asc = Awaited<ReturnType<typeof ascClient>>;
/** Versions that can still be edited (being prepared or sent back). */ /** Versions that can still be edited (being prepared or sent back). */
export const EDITABLE = new Set(['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY']); export const EDITABLE = new Set(['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY']);
/** Delivery states after which an uploaded screenshot / preview counts as there. */
const DELIVERED = ['COMPLETE', 'UPLOAD_COMPLETE', 'PROCESSING'];
/** A screenshot / preview set already holds exactly these files (same MD5s, same order, none failed or half uploaded):
* then a sync leaves it alone. Pure. */
export function setUpToDate(existing: { checksum?: string | null; state?: string | null }[], want: string[]): boolean {
return existing.length === want.length && existing.every((e, i) => e.checksum === want[i] && DELIVERED.includes(e.state ?? ''));
}
export async function ascClient(root: string, bundleId: string, appIdHint?: string) { export async function ascClient(root: string, bundleId: string, appIdHint?: string) {
const k = ascKey(root); const k = ascKey(root);
const key = createPrivateKey(k.pem); const key = createPrivateKey(k.pem);
@ -70,11 +79,12 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
return infos.find(i => !['READY_FOR_DISTRIBUTION', 'REPLACED_WITH_NEW_INFO'].includes(i.attributes.state ?? i.attributes.appStoreState)) ?? infos[0]; return infos.find(i => !['READY_FOR_DISTRIBUTION', 'REPLACED_WITH_NEW_INFO'].includes(i.attributes.state ?? i.attributes.appStoreState)) ?? infos[0];
} }
/** Reserve -> PUT the parts -> commit with the MD5 (screenshots, IAP review screenshots). */ /** Reserve -> PUT the parts -> commit with the MD5 (screenshots, IAP review screenshots, app previews). `attrs` go into
async function uploadAsset(type: string, rel: Record<string, unknown>, file: string): Promise<string> { * the reservation (app previews: mimeType, previewFrameTimeCode). */
async function uploadAsset(type: string, rel: Record<string, unknown>, file: string, attrs: Record<string, unknown> = {}): Promise<string> {
const buf = readFileSync(file); const buf = readFileSync(file);
const name = file.split('/').pop()!; const name = file.split('/').pop()!;
const res = (await asc('POST', `/v1/${type}`, { data: { type, attributes: { fileName: name, fileSize: buf.length }, relationships: rel } })).data; const res = (await asc('POST', `/v1/${type}`, { data: { type, attributes: { fileName: name, fileSize: buf.length, ...attrs }, relationships: rel } })).data;
for (const op of res.attributes.uploadOperations as J[]) { for (const op of res.attributes.uploadOperations as J[]) {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
for (const h of op.requestHeaders) headers[h.name] = h.value; for (const h of op.requestHeaders) headers[h.name] = h.value;

View file

@ -1,10 +1,11 @@
// Store screenshots of every locale on the editable App Store version (idempotent: a set whose files already match // Store screenshots of every locale on the editable App Store version (idempotent: a set whose files already match
// by MD5 and order is left alone, otherwise it is emptied and re-uploaded). // by MD5 and order is left alone, otherwise it is emptied and re-uploaded).
// storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] // 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), // 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 -> // 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). Up to 10
// per set. Apple processes uploads asynchronously; failed // 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. // ones are reported at the end. Long: run in the background with a log file.
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
@ -12,7 +13,8 @@ import type { Ctx } from '../lib/ctx';
import { byAppleLocale, need } from '../lib/ctx'; import { byAppleLocale, need } from '../lib/ctx';
import { done, log, warn } from '../lib/log'; import { done, log, warn } from '../lib/log';
import { numberedImages } from '../play/images'; import { numberedImages } from '../play/images';
import { ascClient, md5, type J } from './asc'; import { variantRoot } from '../shots/lib';
import { ascClient, md5, setUpToDate, type J } from './asc';
/** Render size key -> ASC screenshotDisplayType (iPhone sets). */ /** Render size key -> ASC screenshotDisplayType (iPhone sets). */
export const DISPLAY: Record<string, string> = { ios69: 'APP_IPHONE_67', ios65: 'APP_IPHONE_65' }; export const DISPLAY: Record<string, string> = { ios69: 'APP_IPHONE_67', ios65: 'APP_IPHONE_65' };
@ -28,7 +30,8 @@ export const requiredDisplayTypes = (apple: { ipad?: boolean; screenshotTypes?:
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded; const { config, root, resolve } = ctx.loaded;
const src = resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'); 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('apple', `variant ${ctx.args.get('variant')}: ${src}`);
const only = ctx.args.list('only'); const only = ctx.args.list('only');
const jobs = ctx.args.num('jobs', 4); const jobs = ctx.args.num('jobs', 4);
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
@ -43,9 +46,7 @@ export async function run(ctx: Ctx): Promise<void> {
relationships: { appStoreVersionLocalization: { data: { type: 'appStoreVersionLocalizations', id: locId } } } } })).data; relationships: { appStoreVersionLocalization: { data: { type: 'appStoreVersionLocalizations', id: locId } } } } })).data;
const existing = await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots`); const existing = await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots`);
const want = files.map(f => md5(readFileSync(f))); const want = files.map(f => md5(readFileSync(f)));
const have = existing.map(s => s.attributes.sourceFileChecksum); if (setUpToDate(existing.map(s => ({ checksum: s.attributes.sourceFileChecksum, state: s.attributes.assetDeliveryState?.state })), want)) { log('apple', `${tag}: ${files.length} up to date`); return; }
const ok = existing.every(s => ['COMPLETE', 'UPLOAD_COMPLETE'].includes(s.attributes.assetDeliveryState?.state));
if (ok && have.length === want.length && have.every((h, i) => h === want[i])) { log('apple', `${tag}: ${files.length} up to date`); return; }
if (ctx.args.has('dry')) { log('apple', `${tag}: would replace ${existing.length} with ${files.length}`); return; } if (ctx.args.has('dry')) { log('apple', `${tag}: would replace ${existing.length} with ${files.length}`); return; }
for (const s of existing) await c.asc('DELETE', `/v1/appScreenshots/${s.id}`); for (const s of existing) await c.asc('DELETE', `/v1/appScreenshots/${s.id}`);
for (const f of files) await c.uploadAsset('appScreenshots', { appScreenshotSet: { data: { type: 'appScreenshotSets', id: set.id } } }, f); for (const f of files) await c.uploadAsset('appScreenshots', { appScreenshotSet: { data: { type: 'appScreenshotSets', id: set.id } } }, f);

View file

@ -1,10 +1,12 @@
// Play store graphics per language, one edit per language (a Console save in parallel then only costs a retry of that // Play store graphics per language, one edit per language (a Console save in parallel then only costs a retry of that
// language, not of the whole upload). // language, not of the whole upload).
// storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] // storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b]
// Per Play language, from <renders>/<language id>/: icon (play.icon, 512x512), featureGraphic (feature/1024x500.*), // Per Play language, from <renders>/<language id>/: icon (play.icon, 512x512), featureGraphic (feature/1024x500.*),
// phoneScreenshots (play/NN.*), sevenInchScreenshots (tab7/NN.*), tenInchScreenshots (tab10/NN.*). A .jpg next to a // phoneScreenshots (play/NN.*), sevenInchScreenshots (tab7/NN.*), tenInchScreenshots (tab10/NN.*). A .jpg next to a
// .png wins (JPEG q92 uploads about 4x faster; `storebound shots jpeg` makes them). Each image type is cleared // .png wins (JPEG q92 uploads about 4x faster; `storebound shots jpeg` makes them). Each image type is cleared
// before uploading, so re-runs are idempotent. Uploads are slow: run in the background with a log file. // before uploading, so re-runs are idempotent. --variant <id> uploads <renders>/<id>/<lang>/... (an A/B variant from
// shots.variants) to the main listing: Play store listing experiments themselves are set up in Play Console (no API).
// Uploads are slow: run in the background with a log file.
import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
@ -12,6 +14,7 @@ import { byPlayCode, need } from '../lib/ctx';
import { done, log, step } from '../lib/log'; import { done, log, step } from '../lib/log';
import { playClient } from './client'; import { playClient } from './client';
import { readPlayCopy } from './listing'; import { readPlayCopy } from './listing';
import { variantRoot } from '../shots/lib';
export const PLAY_IMAGE_DIRS = { phoneScreenshots: 'play', sevenInchScreenshots: 'tab7', tenInchScreenshots: 'tab10' } as const; export const PLAY_IMAGE_DIRS = { phoneScreenshots: 'play', sevenInchScreenshots: 'tab7', tenInchScreenshots: 'tab10' } as const;
@ -31,7 +34,8 @@ export function numberedImages(dir: string): string[] {
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded; const { config, root, resolve } = ctx.loaded;
const pkg = need(config.play?.packageName, 'play.packageName'); const pkg = need(config.play?.packageName, 'play.packageName');
const src = resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'); 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 icon = resolve(config.play?.icon ?? join(config.icons?.out ?? 'store/icons', 'play-512.png'));
const langOf = byPlayCode(config.languages); const langOf = byPlayCode(config.languages);
const codes = readPlayCopy(ctx).map(c => c.lang); const codes = readPlayCopy(ctx).map(c => c.lang);

View file

@ -1,23 +1,26 @@
// JPEG copies of the Play renders (play, tab7, tab10, feature): about 4x smaller, so `play images` uploads much faster. // JPEG copies of the Play renders (play, tab7, tab10, feature): about 4x smaller, so `play images` uploads much faster.
// The App Store screenshots stay PNG. // The App Store screenshots stay PNG.
// storebound shots jpeg [--q 92] [--lang en,tr] // storebound shots jpeg [--q 92] [--lang en,tr] [--variant b | --variants]
import { existsSync, readdirSync } from 'node:fs'; import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { done, log } from '../lib/log'; import { done, log } from '../lib/log';
import { variantRoot } from './lib';
/** ffmpeg's mjpeg -q:v (2 best .. 31 worst) for a 0-100 quality. */ /** ffmpeg's mjpeg -q:v (2 best .. 31 worst) for a 0-100 quality. */
export const ffmpegQ = (q: number): number => Math.max(2, Math.min(31, Math.round(2 + (100 - q) * 0.29))); export const ffmpegQ = (q: number): number => Math.max(2, Math.min(31, Math.round(2 + (100 - q) * 0.29)));
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
const { config, resolve } = ctx.loaded; const { config, resolve } = ctx.loaded;
const src = resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'); const base = resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders');
const roots = ctx.args.has('variants') ? [base, ...Object.keys(config.shots?.variants ?? {}).map(v => variantRoot(base, v, config.shots?.variants))]
: [variantRoot(base, ctx.args.get('variant'), config.shots?.variants)];
const q = ctx.args.num('q', 92); const q = ctx.args.num('q', 92);
const langs = ctx.args.list('lang').length ? ctx.args.list('lang') : config.languages.map(l => l.id); const langs = ctx.args.list('lang').length ? ctx.args.list('lang') : config.languages.map(l => l.id);
const sips = spawnSync('sh', ['-c', 'command -v sips']).status === 0; const sips = spawnSync('sh', ['-c', 'command -v sips']).status === 0;
let n = 0; let n = 0;
for (const lang of langs) for (const sub of ['play', 'tab7', 'tab10', 'feature']) { for (const src of roots) for (const lang of langs) for (const sub of ['play', 'tab7', 'tab10', 'feature']) {
const dir = join(src, lang, sub); const dir = join(src, lang, sub);
if (!existsSync(dir)) continue; if (!existsSync(dir)) continue;
for (const f of readdirSync(dir).filter(f => f.endsWith('.png'))) { for (const f of readdirSync(dir).filter(f => f.endsWith('.png'))) {
@ -28,7 +31,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (r.status !== 0) throw new Error(`jpeg failed for ${inp}`); if (r.status !== 0) throw new Error(`jpeg failed for ${inp}`);
n++; n++;
} }
log('shots', `${lang}/${sub}: jpeg q${q}`); log('shots', `${src === base ? '' : src.slice(base.length + 1) + '/'}${lang}/${sub}: jpeg q${q}`);
} }
done('shots', `${n} JPEG copies`); done('shots', `${n} JPEG copies`);
} }