// App preview (App Store) and promo video (Play, via YouTube) from app or gameplay clips, with ffmpeg. // storebound shots video [--lang en,tr] [--target iphone,ipad,youtube] [--seconds 25] [--no-captions] [--dry] // Clips: //*.mp4|mov|webm (or /*.* shared by every language), joined in name order. // Record them with `storebound shots record` (simulator, emulator or web build) or bring your own. // Out: //preview//01.mp4 + 01-poster.png. Every file is checked afterwards (`shots check` rules): // iphone 886 x 1920 (accepted for 6.9" / 6.5" / 6.1" iPhones) ipad 1200 x 1600 (13" iPad, only with apple.ipad) // youtube 1080 x 1920 (or youtube-landscape 1920 x 1080): upload it to YouTube yourself and put the URL in play.video. // Apple: H.264 High <= level 4.0, 30 fps, 15-30 s, stereo AAC 256 kbps 48 kHz (a silent track when there is no audio), // <= 500 MB, up to 3 per device size and language. Captions are transparent PNGs rendered from the screenshot copy // (scene titles) with the same fonts (so RTL / CJK work), overlaid per clip. import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { done, log, warn } from '../lib/log'; import { launch } from './browser'; import { shotsStyle } from '../lib/config'; import { readManifest } from './fonts'; import { captionsFor, type CopyFile, copyText, palette, renderDir, scriptFont } from './lib'; import { FONT_TYPES, fontsRoute, resolveShotFonts } from './local-fonts'; import { ffprobe } from './report'; import { checkVideo, PREVIEW_RULES, VIDEO_TARGETS } from './spec'; import { SHOTS_PAGES } from '../lib/embedded'; import * as rt from '../lib/rt'; export const CLIP_RE = /\.(mp4|mov|m4v|webm)$/i; /** Clip files for a language: // first, else the shared / folder. */ export function clipFiles(clipsDir: string, lang: string, list: (d: string) => string[] = d => (existsSync(d) ? readdirSync(d) : [])): string[] { for (const d of [join(clipsDir, lang), clipsDir]) { const f = list(d).filter(x => CLIP_RE.test(x)).sort(); if (f.length) return f.map(x => join(d, x)); } return []; } export interface ClipPlan { file: string; dur: number; start: number } /** Clips in order until `seconds` is filled (the last one is cut; clips under half a second are skipped). Total below * 15 s is an error (App Store minimum). */ export function planClips(clips: { file: string; dur: number }[], seconds: number): { plan: ClipPlan[]; total: number } { const want = Math.min(PREVIEW_RULES.maxSec, Math.max(PREVIEW_RULES.minSec, seconds)); const plan: ClipPlan[] = []; let t = 0; for (const c of clips) { if (t >= want - 0.05) break; if (c.dur < 0.5) continue; const d = Math.min(c.dur, want - t); plan.push({ file: c.file, dur: +d.toFixed(3), start: +t.toFixed(3) }); t += d; } if (t < PREVIEW_RULES.minSec - 0.01) throw new Error(`clips add up to ${t.toFixed(1)} s; app previews need at least 15 s (record longer or more clips)`); return { plan, total: +t.toFixed(3) }; } export interface VideoJob { plan: ClipPlan[]; total: number; w: number; h: number; fps?: number; fit?: 'cover' | 'contain'; background?: string; /** Transparent PNG per clip (same index), shown while that clip plays. */ captions?: (string | null)[]; audio?: string; out: string; } /** The ffmpeg argv for one preview: scale / crop (or pad) every clip to W x H at 30 fps, join, overlay captions, add music * or a silent stereo track, encode H.264 High 4.0 + AAC 256k 48 kHz stereo, faststart. Pure. */ export function videoArgs(j: VideoJob): string[] { const fps = j.fps ?? 30; const args = ['-v', 'error', '-y']; for (const c of j.plan) args.push('-i', c.file); const caps = (j.captions ?? []).map((c, i) => [c, i] as const).filter((x): x is [string, number] => !!x[0]); for (const [c] of caps) args.push('-i', c); const audioIdx = j.plan.length + caps.length; if (j.audio) args.push('-stream_loop', '-1', '-i', j.audio); else args.push('-f', 'lavfi', '-t', String(j.total), '-i', 'anullsrc=channel_layout=stereo:sample_rate=48000'); const fit = (j.fit ?? 'cover') === 'cover' ? `scale=${j.w}:${j.h}:force_original_aspect_ratio=increase,crop=${j.w}:${j.h}` : `scale=${j.w}:${j.h}:force_original_aspect_ratio=decrease,pad=${j.w}:${j.h}:(ow-iw)/2:(oh-ih)/2:color=${j.background ?? 'black'}`; const f: string[] = j.plan.map((c, i) => `[${i}:v]trim=duration=${c.dur},setpts=PTS-STARTPTS,fps=${fps},${fit},setsar=1,format=yuv420p[v${i}]`); f.push(`${j.plan.map((_, i) => `[v${i}]`).join('')}concat=n=${j.plan.length}:v=1:a=0[base]`); let last = 'base'; caps.forEach(([, clip], k) => { const c = j.plan[clip]!; const a = +(c.start + Math.min(0.3, c.dur / 6)).toFixed(3), b = +(c.start + c.dur - Math.min(0.25, c.dur / 6)).toFixed(3); f.push(`[${last}][${j.plan.length + k}:v]overlay=0:0:enable='between(t,${a},${b})'[c${k}]`); last = `c${k}`; }); f.push(`[${last}]format=yuv420p[vout]`); const fade = Math.min(1.5, j.total / 10); f.push(`[${audioIdx}:a]atrim=0:${j.total},asetpts=PTS-STARTPTS,aformat=sample_rates=48000:channel_layouts=stereo${j.audio ? `,afade=t=out:st=${+(j.total - fade).toFixed(3)}:d=${fade}` : ''}[aout]`); args.push('-filter_complex', f.join(';'), '-map', '[vout]', '-map', '[aout]', '-c:v', 'libx264', '-profile:v', 'high', '-level:v', '4.0', '-pix_fmt', 'yuv420p', '-r', String(fps), '-b:v', '10M', '-maxrate', '12M', '-bufsize', '24M', '-c:a', 'aac', '-b:a', '256k', '-ar', '48000', '-ac', '2', '-t', String(j.total), '-movflags', '+faststart', j.out); return args; } /** ffmpeg argv for the poster frame PNG. */ export const posterArgs = (video: string, at: number, out: string): string[] => ['-v', 'error', '-y', '-ss', String(at), '-i', video, '-frames:v', '1', '-pix_fmt', 'rgb24', out]; /** App Store Connect previewFrameTimeCode (hours:minutes:seconds:frames) for a time in seconds. */ export function timeCode(sec: number, fps = 30): string { const total = Math.max(0, Math.round(sec * fps)); const f = total % fps, s = Math.floor(total / fps); const p = (n: number) => String(n).padStart(2, '0'); return `${p(Math.floor(s / 3600))}:${p(Math.floor(s / 60) % 60)}:${p(s % 60)}:${p(f)}`; } /** Targets for this project: config, else iphone (+ ipad with apple.ipad) and youtube with a play section. */ export function defaultTargets(c: { apple?: { ipad?: boolean }; play?: unknown; shots?: { video?: { targets?: string[] } } }): string[] { if (c.shots?.video?.targets?.length) return c.shots.video.targets; return [...(c.apple || !c.play ? ['iphone'] : []), ...(c.apple?.ipad ? ['ipad'] : []), ...(c.play ? ['youtube'] : [])]; } const duration = (file: string): number => { const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file]); const d = Number(r.stdout?.toString().trim()); if (r.status !== 0 || !(d > 0)) throw new Error(`ffprobe could not read ${file}`); return d; }; export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const shots = need(config.shots, 'shots'); const vc = shots.video ?? {}; const a = ctx.args; const langs = a.list('lang').length ? a.list('lang') : config.languages.map(l => l.id); const targets = a.list('target').length ? a.list('target') : defaultTargets(config); for (const t of targets) if (!VIDEO_TARGETS[t]) throw new Error(`unknown target ${t} (${Object.keys(VIDEO_TARGETS).join(', ')})`); const seconds = a.num('seconds', vc.seconds ?? 25); const clipsDir = resolve(vc.clips ?? 'store/video/clips'); const outDir = resolve(config.renders ?? shots.out); const dry = a.has('dry'); const captions = vc.captions !== false && !a.has('no-captions') && !dry; const copy = JSON.parse(readFileSync(resolve(shots.copy), 'utf8')) as CopyFile; const sceneIds = vc.captionScenes ?? shots.scenes.map(s => s.id); const rtl = new Map(config.languages.map(l => [l.id, !!l.rtl] as const)); const tmp = join(root, '.storebound', 'tmp', 'captions'); if (!dry) mkdirSync(tmp, { recursive: true }); // caption PNGs come from a local page (fonts from `shots fonts`), like the screenshots let server: rt.Server | null = null, browser: { close(): Promise; newContext(o: unknown): Promise<{ newPage(): Promise }> } | null = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any let page: any = null; if (captions) { const fontsDir = join(root, '.storebound', 'fonts'); if (!existsSync(join(fontsDir, 'fonts.css'))) warn('shots', 'no .storebound/fonts/fonts.css: run `storebound shots fonts` first (captions fall back to system fonts)'); server = await rt.serve({ port: a.num('port', 8793), hostname: '127.0.0.1', async fetch(req) { const url = new URL(req.url); if (url.pathname === '/caption.json') { const lang = url.searchParams.get('lang') ?? ''; const texts = captionsFor(copy[lang], sceneIds, 64); const script = scriptFont(lang, copyText(lang, copy[lang])); const style = shotsStyle(ctx.loaded.config); const pal = palette(shots.scenes[0]?.palette, style); return Response.json({ text: texts[Number(url.searchParams.get('i'))] ?? '', ink: style === 'app' ? '#111827' : pal.ink, rtl: rtl.get(lang), fonts: { title: resolveShotFonts(shots, style, ctx.loaded.resolve, root).title.family, script: script ? `Store ${script.slug}` : null, titleStack: readManifest(fontsDir)?.langs[lang]?.title } }); } if (url.pathname === '/caption.html') return new Response(SHOTS_PAGES['caption.html']!, { headers: { 'content-type': 'text/html; charset=utf-8' } }); if (url.pathname.startsWith('/fonts/')) { const file = fontsRoute(decodeURIComponent(url.pathname.slice(7)), fontsDir, root, readManifest(fontsDir)); const f = file ? rt.fileResponse(file, { 'content-type': FONT_TYPES[file.split('.').pop()!.toLowerCase()] ?? 'font/woff2' }) : null; if (f) return f; } return new Response('no', { status: 404 }); } }); browser = await launch(root, { headless: true }); page = await (await browser!.newContext({ deviceScaleFactor: 1 })).newPage(); } let made = 0, bad = 0; try { for (const lang of langs) { const files = clipFiles(clipsDir, lang); if (!files.length) { warn('shots', `${lang}: no clips in ${clipsDir}/${lang} or ${clipsDir} (storebound shots record)`); continue; } const { plan, total } = planClips(files.map(file => ({ file, dur: duration(file) })), seconds); const texts = captions ? captionsFor(copy[lang], sceneIds, plan.length) : []; if (dry) { for (const c of plan) log('shots', `${lang}: ${c.start.toFixed(1)}-${(c.start + c.dur).toFixed(1)} s ${c.file.slice(clipsDir.length + 1)}`); for (const key of targets) log('shots', `${lang}/${key}: would write ${join(renderDir(outDir, lang), 'preview', key, '01.mp4')} (${VIDEO_TARGETS[key]!.w}x${VIDEO_TARGETS[key]!.h}, ${total.toFixed(1)} s${vc.captions !== false && !a.has('no-captions') ? ', captions' : ''})`); made += targets.length; continue; } for (const key of targets) { const t = VIDEO_TARGETS[key]!; const dir = join(renderDir(outDir, lang), 'preview', key); mkdirSync(dir, { recursive: true }); const caps: (string | null)[] = []; if (captions) for (const [i, text] of texts.entries()) { if (!text) { caps.push(null); continue; } const png = join(tmp, `${lang}-${key}-${i}.png`); await page.setViewportSize({ width: t.w, height: t.h }); await page.goto(`http://127.0.0.1:${server!.port}/caption.html?w=${t.w}&h=${t.h}&lang=${encodeURIComponent(lang)}&i=${i}`, { waitUntil: 'load' }); await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 30000 }); await page.screenshot({ path: png, omitBackground: true, clip: { x: 0, y: 0, width: t.w, height: t.h } }); caps.push(png); } const out = join(dir, '01.mp4'); log('shots', `${lang}/${key}: ${plan.length} clips, ${total.toFixed(1)} s -> ${t.w}x${t.h}`); const r = spawnSync('ffmpeg', videoArgs({ plan, total, w: t.w, h: t.h, fit: vc.fit, background: vc.background, captions: caps, audio: vc.audio ? resolve(vc.audio) : undefined, out }), { maxBuffer: 64 * 1024 * 1024 }); if (r.status !== 0) throw new Error(`ffmpeg (${lang}/${key}): ${r.stderr.toString().slice(-800)}`); const poster = Math.min(vc.poster ?? 5, total - 0.5); spawnSync('ffmpeg', posterArgs(out, poster, join(dir, '01-poster.png'))); const probe = ffprobe(out); const issues = probe ? checkVideo(probe, key) : []; for (const i of issues) warn('shots', `${lang}/${key}: ${i.msg}`); if (issues.some(i => i.level === 'error')) bad++; made++; log('shots', `${lang}/${key}/01.mp4 ${(statSync(out).size / 1e6).toFixed(1)} MB${issues.length ? '' : ', store checks ok'}${t.store === 'play' ? ' (upload to YouTube, then set play.video)' : ''}`); } } } finally { await browser?.close(); server?.stop(true); rmSync(tmp, { recursive: true, force: true }); } if (bad) throw new Error(`${bad} videos fail the store checks`); done('shots', dry ? `video planned (dry run): ${made} files` : `video done: ${made} files`); }