// 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: //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 => 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 => ({ mimeType: mimeType(file), previewFrameTimeCode: timeCode(posterSec) }); export async function run(ctx: Ctx): Promise { 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(); 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}//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>; 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({ 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)'); }