storebound/cli/apple/previews.ts

132 lines
9.1 KiB
TypeScript

// App preview videos of every locale on the editable App Store version (idempotent like `apple screenshots`: a set
// whose files already match by MD5 and order is left alone, otherwise it is emptied and re-uploaded).
// storebound apple previews [--src store/renders] [--only en-US,tr] [--poster 5] [--dry]
// Source per language: <renders>/<language id>/preview/iphone/NN.mp4|mov (886 x 1920 -> IPHONE_67, shown for 6.9" and
// scaled for the smaller iPhones) and, only with apple.ipad, preview/ipad/NN.mp4 (1200 x 1600 -> IPAD_PRO_3GEN_129).
// Up to 3 per set. Make them with `storebound shots video`; every file is checked first (size, H.264, <= 30 fps,
// 15-30 s, stereo AAC track, <= 500 MB) and a failing file stops the run before anything is sent.
// The poster frame (previewFrameTimeCode, hh:mm:ss:ff at 30 fps) comes from --poster or shots.video.poster (default
// 5 s, Apple's default). --dry checks the files and, with credentials, shows what would change in App Store Connect.
// Apple processes videos for a while after the upload; failed ones are listed at the end (re-run to retry).
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { byAppleLocale, need } from '../lib/ctx';
import { done, log, warn } from '../lib/log';
import { ffprobe } from '../shots/report';
import { checkVideo, PREVIEW_RULES, VIDEO_TARGETS } from '../shots/spec';
import { timeCode } from '../shots/video';
import { findOrCreate } from '../lib/upsert';
import { pulledChecksum } from '../lib/art-pull';
import { ascClient, keptPrefix, md5, setUpToDate, type J } from './asc';
/** Video target folder -> ASC previewType, for this project (iPad only with apple.ipad). */
export const previewSets = (apple: { ipad?: boolean } | undefined): Record<string, string> =>
Object.fromEntries(Object.values(VIDEO_TARGETS).filter(t => t.store === 'apple' && (t.key !== 'ipad' || apple?.ipad)).map(t => [t.key, t.previewType!]));
/** NN.mp4 / NN.mov / NN.m4v in a folder, sorted, at most 3 (Apple's limit per set). */
export function previewFiles(dir: string, list: (d: string) => string[] = d => (existsSync(d) ? readdirSync(d) : [])): string[] {
return list(dir).filter(f => /^\d+\.(mp4|mov|m4v)$/i.test(f)).sort((a, b) => parseInt(a) - parseInt(b)).slice(0, PREVIEW_RULES.maxPerSet).map(f => join(dir, f));
}
export const mimeType = (file: string): string => (/\.mov$/i.test(file) ? 'video/quicktime' : 'video/mp4');
/** Reservation attributes for one preview file. */
export const previewAttrs = (file: string, posterSec: number): Record<string, unknown> => ({ mimeType: mimeType(file), previewFrameTimeCode: timeCode(posterSec) });
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
const dry = a.has('dry');
const src = resolve(a.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders');
const only = a.list('only');
const poster = a.num('poster', config.shots?.video?.poster ?? 5);
const sets = previewSets(config.apple);
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
// 1. local files + store checks, before any network call
const local = new Map<string, [string, string, string[]][]>();
let bad = 0, files = 0;
for (const l of config.languages) {
if (!l.apple || (only.length && !only.includes(l.apple))) continue;
const per: [string, string, string[]][] = [];
for (const [key, type] of Object.entries(sets)) {
const fs = previewFiles(join(src, l.id, 'preview', key));
if (!fs.length) continue;
for (const f of fs) {
const p = ffprobe(f);
const issues = p ? checkVideo(p, key) : [{ level: 'error' as const, msg: 'ffprobe could not read it (is ffmpeg installed?)' }];
for (const i of issues) warn('apple', `${f.slice(src.length + 1)}: ${i.msg}`);
if (issues.some(i => i.level === 'error')) bad++;
if (p && poster > p.duration - 0.1) { warn('apple', `${f.slice(src.length + 1)}: poster at ${poster} s is past the end (${p.duration.toFixed(1)} s)`); bad++; }
}
files += fs.length;
per.push([key, type, fs]);
}
if (per.length) local.set(l.apple, per);
}
if (bad) throw new Error(`${bad} preview problems: fix them first (storebound shots video, storebound shots check)`);
if (!files) { warn('apple', `no previews in ${src}/<lang>/preview/{${Object.keys(sets).join(',')}}/ (storebound shots video)`); return; }
log('apple', `${files} previews for ${local.size} locales pass the checks; poster frame ${timeCode(poster)}`);
let c: Awaited<ReturnType<typeof ascClient>>;
try { c = await ascClient(root, bundleId, config.apple?.appId); }
catch (e) {
if (!dry) throw e;
for (const [loc, per] of local) for (const [, type, fs] of per) log('apple', `${loc} ${type}: would sync ${fs.length} (${fs.map(f => f.split(/[\\/]/).pop()).join(', ')})`);
return done('apple', `previews planned (dry run, no App Store Connect comparison: ${(e as Error).message.split('\n')[0]})`);
}
const version = await c.editableVersion();
if (!version) throw new Error('no editable App Store version (run apple listing --version x.y.z first)');
const langOf = byAppleLocale(config.languages);
const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`);
for (const loc of locs) {
const locale: string = loc.attributes.locale;
const per = local.get(locale);
if (!per) { if (langOf.get(locale) && (!only.length || only.includes(locale))) warn('apple', `${locale}: no previews, skipped`); continue; }
const existingSets = await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appPreviewSets`);
for (const [, type, fs] of per) {
const tag = `${locale} ${type}`;
let set: J = existingSets.find(s => s.attributes.previewType === type);
const existing: J[] = set ? await c.all(`/v1/appPreviewSets/${set.id}/appPreviews`) : [];
const want = fs.map(f => pulledChecksum(f, 'apple') ?? md5(readFileSync(f)));
const state = (p: J) => p.attributes.videoDeliveryState?.state ?? p.attributes.assetDeliveryState?.state;
const sameFiles = setUpToDate(existing.map(p => ({ checksum: p.attributes.sourceFileChecksum, state: state(p) })), want);
if (sameFiles) {
const tc = timeCode(poster);
const off = existing.filter(p => p.attributes.previewFrameTimeCode && p.attributes.previewFrameTimeCode !== tc);
if (!off.length) { log('apple', `${tag}: ${fs.length} up to date`); continue; }
if (dry) { log('apple', `${tag}: would set the poster frame to ${tc} on ${off.length}`); continue; }
for (const p of off) await c.asc('PATCH', `/v1/appPreviews/${p.id}`, { data: { type: 'appPreviews', id: p.id, attributes: { previewFrameTimeCode: tc } } });
log('apple', `${tag}: poster frame ${tc}`);
continue;
}
const kept = keptPrefix(existing.map(p => ({ checksum: p.attributes.sourceFileChecksum, state: state(p) })), want);
if (dry) { log('apple', `${tag}: would keep ${kept}, replace ${existing.length - kept} with ${fs.length - kept}`); continue; }
if (!set) {
const r = await findOrCreate<J>({ what: `${tag} preview set`, tag: 'apple', unique: true,
find: async () => (await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appPreviewSets`)).find((s: J) => s.attributes.previewType === type) ?? null,
create: async () => (await c.asc('POST', '/v1/appPreviewSets', { data: { type: 'appPreviewSets', attributes: { previewType: type },
relationships: { appStoreVersionLocalization: { data: { type: 'appStoreVersionLocalizations', id: loc.id } } } } })).data });
set = r.value;
if (!r.created) existing.push(...await c.all(`/v1/appPreviewSets/${set.id}/appPreviews`));
}
const r = await c.syncAssets({ set: { type: 'appPreviewSets', id: set.id }, existing, files: fs, want, state, attrs: f => previewAttrs(f, poster),
onUpload: f => log('apple', `${tag}: uploading ${f.slice(src.length + 1)}`) });
const tc = timeCode(poster);
for (const p of existing.slice(0, r.kept).filter(p => p.attributes.previewFrameTimeCode && p.attributes.previewFrameTimeCode !== tc))
await c.asc('PATCH', `/v1/appPreviews/${p.id}`, { data: { type: 'appPreviews', id: p.id, attributes: { previewFrameTimeCode: tc } } });
log('apple', `${tag}: uploaded ${r.uploaded}${r.kept ? `, kept ${r.kept}` : ''} (removed ${r.removed})`);
}
}
if (dry) return done('apple', 'previews checked (dry run, nothing sent)');
let failed = 0;
for (const loc of locs) for (const set of await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appPreviewSets`))
for (const p of await c.all(`/v1/appPreviewSets/${set.id}/appPreviews`)) {
const st = p.attributes.videoDeliveryState ?? p.attributes.assetDeliveryState;
if (st?.state === 'FAILED') { failed++; warn('apple', `${loc.attributes.locale} ${set.attributes.previewType} ${p.attributes.fileName}: ${JSON.stringify(st.errors)}`); }
}
done('apple', failed ? `${failed} previews failed processing (re-run to retry)` : 'previews done (Apple keeps processing them for a while)');
}