225 lines
13 KiB
TypeScript
225 lines
13 KiB
TypeScript
// 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: <shots.video.clips>/<lang>/*.mp4|mov|webm (or <clips>/*.* shared by every language), joined in name order.
|
|
// Record them with `storebound shots record` (simulator, emulator or web build) or bring your own.
|
|
// Out: <renders>/<lang>/preview/<target>/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';
|
|
|
|
export const CLIP_RE = /\.(mp4|mov|m4v|webm)$/i;
|
|
|
|
/** Clip files for a language: <clips>/<lang>/ first, else the shared <clips>/ 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<void> {
|
|
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: ReturnType<typeof Bun.serve> | null = null, browser: { close(): Promise<void>; newContext(o: unknown): Promise<{ newPage(): Promise<unknown> }> } | 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 = Bun.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(Bun.file(join(import.meta.dir, '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 ? Bun.file(file) : null;
|
|
if (f && await f.exists()) return new Response(f, { headers: { 'content-type': FONT_TYPES[file!.split('.').pop()!.toLowerCase()] ?? 'font/woff2' } });
|
|
}
|
|
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`);
|
|
}
|