--variant on play images, apple screenshots and shots jpeg; shared set up-to-date check
This commit is contained in:
parent
595bdbcf78
commit
bd12bd0690
4 changed files with 35 additions and 17 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue