132 lines
9.1 KiB
TypeScript
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)');
|
|
}
|