iPhone Duo: limit from Apple's reference data instead of a cut to 10, --duo-only; shared asset library helpers in their own module

This commit is contained in:
CRTkafa 2026-11-09 06:35:00 +07:00
parent 5cc3da486d
commit 108b362cd9
7 changed files with 102 additions and 75 deletions

View file

@ -3,6 +3,8 @@
## Unreleased
- **iPhone Duo:** outer and inner files are no longer cut to the first 10: the limit comes from Apple's reference data, and over it nothing changes and nothing is dropped. `apple screenshots --duo-only` writes only the Duo set. `apple cpp` no longer lists creative-assets flags.
## 0.8.76 · 2026-11-08
- 0.8.75 was tagged but not published (GitHub 502/504 in the Linux AppImage bundling). CI now takes the AppImage tools from a checked mirror instead of GitHub.

View file

@ -0,0 +1,57 @@
// App Asset Library states and upsert rules shared by creative assets and iPhone Duo screenshots. No command flags here.
import * as rt from '../lib/rt';
import type { J } from './asc';
export const REF = (sum: string): string => `storebound ${sum}`;
export const sumOf = (a: J): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
/** Asset states (AppAssetLibraryAssetState, API 4.5.1) by what storebound may do with them. */
export const ASSET_STATES = {
/** still uploading / processing: wait (bounded), then select */
processing: ['AWAITING_UPLOAD', 'UPLOAD_COMPLETE'],
/** processed and usable in a slot; not reviewed yet: goes into a review submission */
unreviewed: ['COMPLETE', 'PREPARE_FOR_SUBMISSION'],
/** in a submission or reviewed: usable in a slot, nothing to add */
reviewing: ['READY_FOR_REVIEW', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'ACCEPTED', 'APPROVED'],
} as const;
export type AssetClass = 'processing' | 'unreviewed' | 'reviewing' | 'failed' | 'rejected' | 'archived' | 'unknown';
/** What a library asset's state means for storebound. Pure. */
export function classify(state: string | undefined): AssetClass {
const s = String(state ?? '');
for (const [k, v] of Object.entries(ASSET_STATES)) if ((v as readonly string[]).includes(s)) return k as AssetClass;
if (s === 'FAILED') return 'failed';
if (s === 'REJECTED') return 'rejected';
if (s === 'ARCHIVED') return 'archived';
return s ? 'unknown' : 'processing';
}
/** Selectable for a slot: processed and not failed / rejected / archived (Prepare for Submission is enough). */
export const selectable = (c: AssetClass): boolean => c === 'unreviewed' || c === 'reviewing';
/** Stop re-uploading a file whose earlier uploads failed processing this many times. */
export const MAX_FAILED = 2;
/** The library asset to use for one MD5: a live one first (newest state wins: reviewing > unreviewed > processing),
* else an archived one (unarchive it), else none; plus why not to upload (a rejected file, repeated failures). Pure. */
export function pickAsset(assets: J[], sum: string): { asset?: J; unarchive?: boolean; refuse?: string } {
const mine = assets.filter(x => sumOf(x) === sum);
const rank: Record<AssetClass, number> = { reviewing: 0, unreviewed: 1, processing: 2, unknown: 3, archived: 4, rejected: 5, failed: 6 };
const by = (c: AssetClass) => mine.filter(x => classify(x.attributes?.state) === c);
const live = mine.filter(x => rank[classify(x.attributes?.state)] <= 3).sort((a, b) => rank[classify(a.attributes?.state)] - rank[classify(b.attributes?.state)]);
if (live[0]) return { asset: live[0] };
if (by('archived')[0]) return { asset: by('archived')[0], unarchive: true };
const rej = by('rejected')[0];
if (rej) return { refuse: `rejected by App Review${reason(rej)}: uploading the same file again changes nothing; change the file` };
if (by('failed').length >= MAX_FAILED) return { refuse: `${by('failed').length} earlier uploads of this file failed processing${reason(by('failed')[0]!)}: check the file (format, size) before trying again` };
return {};
}
export const reason = (a: J): string => {
const d = ((a.attributes?.stateDetails ?? []) as J[]).map(x => x?.code ?? x?.message ?? x?.description).filter(Boolean);
return d.length ? ` (${d.join(', ')})` : '';
};
/** Bounded wait for processing (every wait has a timeout and a heartbeat); tests set waitMs 0 / timeoutMs 0. */
export const processingTiming = { pollMs: 3000, timeoutMs: 120_000, heartbeatMs: 20_000, sleep: (ms: number): Promise<void> => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) };

View file

@ -29,61 +29,17 @@ import { need } from '../lib/ctx';
import { consoleLink } from '../lib/console-links';
import { done, log, warn } from '../lib/log';
import { elapsed } from '../lib/progress';
import * as rt from '../lib/rt';
import { ffprobe } from '../shots/report';
import { imageInfo } from '../shots/spec';
import { checkCreativeImage, checkCreativeVideo, type CreativeFile, creativeFiles, PLACEMENT_TYPE, type Placement } from '../shots/creative';
import { type Asc, ascClient, md5, type J } from './asc';
import { type AssetClass, ASSET_STATES, classify, MAX_FAILED, pickAsset, processingTiming, reason, REF, selectable, sumOf } from './asset-library';
export { type AssetClass, ASSET_STATES, classify, MAX_FAILED, pickAsset, processingTiming, REF, selectable };
import { partHeaders } from './screenshot-health';
import { AmbiguousWriteError } from '../lib/upsert';
import { addItem, ensureDraft, OtherSubmissionError } from './submission';
const TAG = 'apple';
export const REF = (sum: string): string => `storebound ${sum}`;
const sumOf = (a: J): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
/** Asset states (AppAssetLibraryAssetState, API 4.5.1) by what storebound may do with them. */
export const ASSET_STATES = {
/** still uploading / processing: wait (bounded), then select */
processing: ['AWAITING_UPLOAD', 'UPLOAD_COMPLETE'],
/** processed and usable in a slot; not reviewed yet: goes into a review submission */
unreviewed: ['COMPLETE', 'PREPARE_FOR_SUBMISSION'],
/** in a submission or reviewed: usable in a slot, nothing to add */
reviewing: ['READY_FOR_REVIEW', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'ACCEPTED', 'APPROVED'],
} as const;
export type AssetClass = 'processing' | 'unreviewed' | 'reviewing' | 'failed' | 'rejected' | 'archived' | 'unknown';
/** What a library asset's state means for storebound. Pure. */
export function classify(state: string | undefined): AssetClass {
const s = String(state ?? '');
for (const [k, v] of Object.entries(ASSET_STATES)) if ((v as readonly string[]).includes(s)) return k as AssetClass;
if (s === 'FAILED') return 'failed';
if (s === 'REJECTED') return 'rejected';
if (s === 'ARCHIVED') return 'archived';
return s ? 'unknown' : 'processing';
}
/** Selectable for a slot: processed and not failed / rejected / archived (Prepare for Submission is enough). */
export const selectable = (c: AssetClass): boolean => c === 'unreviewed' || c === 'reviewing';
/** Stop re-uploading a file whose earlier uploads failed processing this many times. */
export const MAX_FAILED = 2;
/** The library asset to use for one MD5: a live one first (newest state wins: reviewing > unreviewed > processing),
* else an archived one (unarchive it), else none; plus why not to upload (a rejected file, repeated failures). Pure. */
export function pickAsset(assets: J[], sum: string): { asset?: J; unarchive?: boolean; refuse?: string } {
const mine = assets.filter(x => sumOf(x) === sum);
const rank: Record<AssetClass, number> = { reviewing: 0, unreviewed: 1, processing: 2, unknown: 3, archived: 4, rejected: 5, failed: 6 };
const by = (c: AssetClass) => mine.filter(x => classify(x.attributes?.state) === c);
const live = mine.filter(x => rank[classify(x.attributes?.state)] <= 3).sort((a, b) => rank[classify(a.attributes?.state)] - rank[classify(b.attributes?.state)]);
if (live[0]) return { asset: live[0] };
if (by('archived')[0]) return { asset: by('archived')[0], unarchive: true };
const rej = by('rejected')[0];
if (rej) return { refuse: `rejected by App Review${reason(rej)}: uploading the same file again changes nothing; change the file` };
if (by('failed').length >= MAX_FAILED) return { refuse: `${by('failed').length} earlier uploads of this file failed processing${reason(by('failed')[0]!)}: check the file (format, size) before trying again` };
return {};
}
/** "uploaded 2026-10-19, waiting for Apple's approval (IN_REVIEW); run `storebound apple creative-assets` again later".
* The version page's header / search slots offer only approved assets ("Browse approved assets"), so an asset that
* is not APPROVED waits; a later run places it. Pure. */
@ -107,14 +63,6 @@ export async function creativeWaiting(c: Asc, appId: string, local: Map<string,
return out;
}
const reason = (a: J): string => {
const d = ((a.attributes?.stateDetails ?? []) as J[]).map(x => x?.code ?? x?.message ?? x?.description).filter(Boolean);
return d.length ? ` (${d.join(', ')})` : '';
};
/** Bounded wait for processing (every wait has a timeout and a heartbeat); tests set waitMs 0 / timeoutMs 0. */
export const processingTiming = { pollMs: 3000, timeoutMs: 120_000, heartbeatMs: 20_000, sleep: (ms: number): Promise<void> => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) };
export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'reuse' | 'wait' | 'select' | 'replace' | 'approve' | 'refuse'; state?: string; asset?: J }
/** Local files per App Store locale, checked; throws on any error before a network call. */

View file

@ -8,18 +8,18 @@
// 3. POST appAssetLibraryPlacements (placementType APP_SCREENSHOT, placementGroup, the version localization).
// Screenshots are reviewed with the version, so a processed asset is placed at once (no approval wait).
// Upsert: a group whose placements already show the local files in order is left alone; otherwise its placements are
// removed and the files placed in order (outer display files first, then inner; at most 10). Read back at the end.
// removed and the files placed in order (outer display files first, then inner). Never truncated: more files than
// the limit Apple publishes for the group (refData groupLimits) changes nothing and says so. Read back at the end.
import { readFileSync } from 'node:fs';
import { basename } from 'node:path';
import { log, warn } from '../lib/log';
import { elapsed } from '../lib/progress';
import { classify, pickAsset, processingTiming, REF, selectable } from './creative-assets';
import { classify, pickAsset, processingTiming, REF, selectable } from './asset-library';
import { type Asc, md5, type J } from './asc';
import { partHeaders } from './screenshot-health';
const TAG = 'apple';
export const DUO_CATEGORY = 'APP_SCREENSHOTS_AND_PREVIEWS';
export const DUO_MAX = 10;
const sumOf = (a: J | undefined): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
/** iPhone placement profile group ids (platform IOS or IPHONE_APP_STORE, as the live API answers) of the IPHONE_DUO display class in an appAssetLibraryRefData response. Pure. */
@ -30,26 +30,44 @@ export function duoGroups(refData: J[]): string[] {
return [...ids];
}
/** Apple's screenshot limit for a placement group on a version page, or null when refData publishes none. Pure. */
export function duoLimit(refData: J[], group: string): number | null {
for (const r of refData) for (const f of (r?.attributes?.features ?? []) as J[]) {
if (f?.featureId !== 'APP_STORE_VERSIONS') continue;
for (const p of (f.placementPolicies ?? []) as J[]) {
if (p?.placementType !== 'APP_SCREENSHOT') continue;
for (const l of (p.groupLimits ?? []) as J[]) if ((l?.groupIds ?? []).includes(group) && typeof l.maxCount === 'number') return l.maxCount;
}
}
return null;
}
/** Whether a group's current placements (asset MD5s in order) already show `want`. Pure. */
export const duoUpToDate = (shown: (string | undefined)[], want: string[]): boolean => shown.length === want.length && shown.every((s, i) => s === want[i]);
export interface DuoResult { locale: string; action: 'keep' | 'place' | 'wait' | 'refuse' | 'plan'; files: number }
/** Resolves the Duo placement group once per run; throws with the guardrail text when the API lists none. */
export async function duoGroup(c: Asc): Promise<string> {
export async function duoGroup(c: Asc): Promise<{ id: string; limit: number | null }> {
const ref = await c.all('/v1/appAssetLibraryRefData?filter[placementTypes]=APP_SCREENSHOT');
const g = duoGroups(ref);
if (!g.length) throw new Error('appAssetLibraryRefData lists no iOS IPHONE_DUO placement group for APP_SCREENSHOT: report it (pitfall 130); the console version page > App Previews and Screenshots > iPhone Duo takes them meanwhile');
if (g.length > 1) log(TAG, `iPhone Duo: ${g.length} placement groups (${g.join(', ')}), using ${g[0]}`);
return g[0]!;
const limit = duoLimit(ref, g[0]!);
log(TAG, `iPhone Duo: placement group ${g[0]}, limit ${limit ?? 'not published'}`);
return { id: g[0]!, limit };
}
/** Makes the iPhone Duo screenshots of one version localization show `files` in order. */
export async function syncDuo(c: Asc, o: { appId: string; group: string; loc: J; files: string[]; dry: boolean }): Promise<DuoResult> {
export async function syncDuo(c: Asc, o: { appId: string; group: string; limit: number | null; loc: J; files: string[]; dry: boolean }): Promise<DuoResult> {
const locale: string = o.loc.attributes.locale;
const tag = `${locale} IPHONE_DUO`;
const files = o.files.slice(0, DUO_MAX);
if (o.files.length > DUO_MAX) warn(TAG, `${tag}: ${o.files.length} files, only the first ${DUO_MAX} are used`);
const files = o.files;
if (o.limit !== null && files.length > o.limit) {
warn(TAG, `${tag}: ${files.length} files (outer + inner) but Apple allows ${o.limit} in this group: nothing changed, none dropped. Keep at most ${o.limit} across iphone-duo-outer/ and iphone-duo-inner/`);
process.exitCode = 1;
return { locale, action: 'refuse', files: files.length };
}
const lib = (await c.asc('GET', `/v1/apps/${o.appId}/assetLibrary`)).data as J;
const library = () => c.all(`/v1/appAssetLibraries/${lib.id}/images?filter[category]=${DUO_CATEGORY}&limit=200`);
let assets = await library();

View file

@ -1,6 +1,6 @@
// 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).
// storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]
// storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--duo-only] [--dry]
// storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)
// 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 ->
@ -98,7 +98,7 @@ export async function run(ctx: Ctx): Promise<void> {
log('apple', `screenshots for version ${versionLabel(version)}`);
let duo = platform === 'MAC_OS' ? [] : duoDirs(config.apple);
// a Duo problem never stops the other sets
const duoGroupId = duo.length ? await duoGroup(c).catch((e: Error) => { warn('apple', `iPhone Duo skipped: ${e.message}`); duo = []; return ''; }) : '';
const duoGroupId = duo.length ? await duoGroup(c).catch((e: Error) => { warn('apple', `iPhone Duo skipped: ${e.message}`); duo = []; return null; }) : null;
const appId = duo.length ? await c.app() : '';
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');
@ -121,16 +121,16 @@ 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, platform))) {
for (const [dir, type] of Object.entries(ctx.args.has('duo-only') ? {} : 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; }
await syncSet(loc.id, sets, type, files, `${locale} ${type}`);
}
if (duo.length) {
if (duo.length && duoGroupId) {
const files = duo.flatMap(dir => numberedImages(join(src, lang.id, dir)).filter(f => f.endsWith('.png')));
if (!files.length) warn('apple', `${locale}: nothing in ${duo.map(d => join(src, lang.id, d)).join(' or ')}, iPhone Duo skipped`);
else await syncDuo(c, { appId, group: duoGroupId, loc, files, dry: ctx.args.has('dry') }).catch((e: Error) => { warn('apple', `${locale} iPhone Duo: ${e.message}`); process.exitCode = 1; });
else await syncDuo(c, { appId, group: duoGroupId.id, limit: duoGroupId.limit, loc, files, dry: ctx.args.has('dry') }).catch((e: Error) => { warn('apple', `${locale} iPhone Duo: ${e.message}`); process.exitCode = 1; });
}
});
}

View file

@ -106,7 +106,7 @@
"name"
],
"apple cpp": [
"add-for-review",
"duo-only",
"include-drafts",
"jobs",
"name",
@ -117,7 +117,6 @@
"repair-stuck",
"src",
"variant",
"verify",
"write"
],
"apple creative-assets": [
@ -206,7 +205,7 @@
"write"
],
"apple ppo": [
"add-for-review",
"duo-only",
"experiment",
"include-drafts",
"jobs",
@ -218,7 +217,6 @@
"src",
"traffic",
"variant",
"verify",
"write"
],
"apple preorder": [
@ -274,7 +272,7 @@
"platform"
],
"apple screenshots": [
"add-for-review",
"duo-only",
"include-drafts",
"jobs",
"not-sent-for-review",
@ -283,7 +281,6 @@
"repair-stuck",
"src",
"variant",
"verify",
"write"
],
"apple signing": [
@ -1119,6 +1116,7 @@
"cordova-android",
"cordova-ios",
"days",
"duo-only",
"expo",
"flutter",
"godot",
@ -1250,6 +1248,7 @@
"device",
"dir",
"download",
"duo-only",
"enable",
"end",
"entitlements",
@ -1594,7 +1593,7 @@
"storebound apple review-details [--platform ios|macos|tvos|visionos] [--contact-from <Apple ID of another app>] [--dry]"
],
"apple screenshots": [
"storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]",
"storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--duo-only] [--dry]",
"storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)"
],
"apple signing": [

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test';
import { displaySets, duoDirs } from '../cli/apple/screenshots';
import { duoGroups, duoUpToDate } from '../cli/apple/duo-library';
import { duoGroups, duoLimit, duoUpToDate } from '../cli/apple/duo-library';
import { appleRules } from '../cli/lib/validate-rules';
import { checkCreate, createExperimentReq, defaultName, experimentFromApi, pickExperiment, planTreatmentLocales, planTreatments, startStopReq } from '../cli/lib/ppo';
import { validateConfig } from '../cli/lib/config';
@ -105,6 +105,9 @@ describe('iPhone Duo (foldable) sizes', () => {
expect(duoGroups(ref)).toEqual(['g-duo']);
expect(duoGroups([])).toEqual([]);
expect(duoGroups([{ attributes: { placementProfileGroups: [{ placementProfileGroupId: 'IPHONE_DUO_PROFILE', platform: 'IPHONE_APP_STORE', displayClassId: 'IPHONE_DUO' }] } }])).toEqual(['IPHONE_DUO_PROFILE']);
const lim = [{ attributes: { features: [{ featureId: 'IN_APP_EVENTS', placementPolicies: [{ placementType: 'APP_SCREENSHOT', groupLimits: [{ groupIds: ['g'], maxCount: 1 }] }] }, { featureId: 'APP_STORE_VERSIONS', placementPolicies: [{ placementType: 'APP_SCREENSHOT', groupLimits: [{ groupIds: ['g'], maxCount: 20 }] }] }] } }];
expect(duoLimit(lim, 'g')).toBe(20);
expect(duoLimit(lim, 'other')).toBeNull();
expect(duoUpToDate(['a', 'b'], ['a', 'b'])).toBe(true);
expect(duoUpToDate(['b', 'a'], ['a', 'b'])).toBe(false);
expect(duoUpToDate(['a', undefined], ['a', 'b'])).toBe(false);