181 lines
11 KiB
TypeScript
181 lines
11 KiB
TypeScript
#!/usr/bin/env bun
|
|
// Renders the storebound teaser (16:9 only): frames from index.html (Playwright, headless Chrome) piped into ffmpeg,
|
|
// plus the original music and sound design (music.js, OfflineAudioContext in the same browser), mastered to about
|
|
// -16 LUFS. Frames are captured at 60 fps and blended in pairs down to 30 fps (a 180° shutter): slow moves stay smooth.
|
|
// bun media/teaser/render.ts master 1920x1080, sound, poster, 1280x720 web version -> media/teaser/out/
|
|
// bun media/teaser/render.ts --stills 1,9.5,30 PNG stills only (out/stills/), quick layout check
|
|
// bun media/teaser/render.ts --sound the music and sound only (out/music.wav)
|
|
// bun media/teaser/render.ts --desktop capture the desktop app frames again first (desktop.ts)
|
|
// bun media/teaser/render.ts --serve preview server: open /index.html?play=1 (or ?t=12.5)
|
|
// Needs ffmpeg and Playwright (devDependency; Playwright's Chromium or an installed Google Chrome), and the demo game's
|
|
// store renders (examples/demo-game: `shots capture`, `shots fonts`, `shots render`, see its README).
|
|
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { launch } from '../../cli/shots/browser';
|
|
import { captureDesktop } from './desktop';
|
|
|
|
const DIR = import.meta.dir;
|
|
const ROOT = join(DIR, '../..');
|
|
const OUT = join(DIR, 'out');
|
|
const ASSETS = join(OUT, 'assets');
|
|
const args = process.argv.slice(2);
|
|
const opt = (k: string): string | undefined => { const i = args.indexOf(`--${k}`); return i >= 0 ? args[i + 1] : undefined; };
|
|
/** 16:9 only. */
|
|
const FORMAT = { w: 1920, h: 1080 };
|
|
/** The web version: 1280x720 H.264, two-pass to about this many bytes (about 830 kbps video for 58 s). */
|
|
const WEB_BYTES = 6.7e6;
|
|
const CAPTURE_FPS = 60;
|
|
const log = (m: string): void => console.log(`${new Date().toISOString().slice(11, 19)} [teaser] ${m}`);
|
|
|
|
function sh(cmd: string[], input?: Uint8Array): string {
|
|
const r = Bun.spawnSync(cmd, { stdin: input, stdout: 'pipe', stderr: 'pipe' });
|
|
if (r.exitCode !== 0) throw new Error(`${cmd.slice(0, 3).join(' ')} failed:\n${r.stderr.toString().slice(-2000)}`);
|
|
return r.stderr.toString();
|
|
}
|
|
const ff = (...a: string[]): string => sh(['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', ...a]);
|
|
|
|
/** The screenshot tiles: the demo game's real store renders, the first image of every size in every language; and the
|
|
* game's own screens. */
|
|
function prepAssets(): void {
|
|
const dir = join(ASSETS, 'shots');
|
|
rmSync(dir, { recursive: true, force: true });
|
|
mkdirSync(dir, { recursive: true });
|
|
const renders = join(ROOT, 'examples/demo-game/store/renders');
|
|
const missing: string[] = [];
|
|
let n = 0;
|
|
for (const lang of ['en', 'tr', 'ja', 'ar']) for (const size of ['ios69', 'ios65', 'ipad13', 'play', 'tab7', 'tab10', 'feature']) {
|
|
const src = join(renders, lang, size, size === 'feature' ? '1024x500.png' : '01.png');
|
|
if (!existsSync(src)) { missing.push(`${lang}/${size}`); continue; }
|
|
ff('-i', src, '-vf', size === 'feature' ? 'scale=600:-2:flags=lanczos' : 'scale=-2:360:flags=lanczos', '-q:v', '3', join(dir, `${lang}-${size}.jpg`));
|
|
n++;
|
|
}
|
|
// the game's own screens (its store captures, before any framing): the opening scene
|
|
const game = join(ASSETS, 'game');
|
|
rmSync(game, { recursive: true, force: true });
|
|
mkdirSync(game, { recursive: true });
|
|
for (const id of ['play', 'combo', 'map', 'stars']) {
|
|
const src = join(ROOT, 'examples/demo-game/store/shots/frames/en/phone', `${id}.png`);
|
|
if (!existsSync(src)) { missing.push(`frames/en/phone/${id}`); continue; }
|
|
ff('-i', src, '-vf', 'scale=-2:1100:flags=lanczos', '-q:v', '2', join(game, `${id}.jpg`));
|
|
n++;
|
|
}
|
|
if (missing.length) throw new Error(`demo game renders missing (${missing.slice(0, 4).join(', ')}${missing.length > 4 ? ', ...' : ''}): run its shots capture, fonts and render first (examples/demo-game/README.md)`);
|
|
log(`assets: ${n} demo game renders and screens (examples/demo-game/store)`);
|
|
}
|
|
|
|
function serve(): ReturnType<typeof Bun.serve> {
|
|
return Bun.serve({
|
|
port: Number(opt('port') ?? 0),
|
|
fetch(req) {
|
|
const p = decodeURIComponent(new URL(req.url).pathname).replace(/^\/+/, '') || 'index.html';
|
|
if (p.includes('..')) return new Response('no', { status: 400 });
|
|
// the logo comes from the repo's brand/ folder (tools/brand-assets.py) and the app icon from the demo game, not copies
|
|
const f = Bun.file(p.startsWith('brand/') || p.startsWith('examples/demo-game/store/icon/') ? join(ROOT, p) : join(DIR, p));
|
|
return f.size ? new Response(f) : new Response('not found', { status: 404 });
|
|
},
|
|
});
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
mkdirSync(OUT, { recursive: true });
|
|
prepAssets();
|
|
if (args.includes('--desktop') || !existsSync(join(OUT, 'desktop/boxes.json'))) await captureDesktop();
|
|
const server = serve();
|
|
const base = `http://127.0.0.1:${server.port}/index.html`;
|
|
if (args.includes('--serve')) { log(`preview: ${base}?play=1`); return; }
|
|
|
|
const browser = await launch(ROOT, { headless: true });
|
|
try {
|
|
const stills = opt('stills');
|
|
const soundOnly = args.includes('--sound');
|
|
if (!soundOnly) {
|
|
const f = FORMAT;
|
|
const page = await browser.newPage({ viewport: { width: f.w, height: f.h }, deviceScaleFactor: 1 });
|
|
const errors: string[] = [];
|
|
page.on('pageerror', (e: Error) => errors.push(e.message));
|
|
page.on('response', (r: any) => { if (r.status() >= 400 && !r.url().endsWith('/favicon.ico')) errors.push(`${r.status()} ${r.url()}`); });
|
|
await page.goto(base);
|
|
await page.waitForFunction('window.teaser', null, { timeout: 30_000 });
|
|
await page.evaluate('window.teaser.ready');
|
|
const { DURATION, FPS } = await page.evaluate('({ DURATION: teaser.DURATION, FPS: teaser.FPS })') as { DURATION: number; FPS: number };
|
|
const shot = async (t: number): Promise<Buffer> => {
|
|
await page.evaluate(`teaser.seek(${t})`);
|
|
return page.screenshot({ type: 'png' });
|
|
};
|
|
if (stills) {
|
|
mkdirSync(join(OUT, 'stills'), { recursive: true });
|
|
for (const t of stills.split(',').map(Number)) await Bun.write(join(OUT, 'stills', `${t.toFixed(2)}.png`), await shot(t));
|
|
log(`${stills.split(',').length} stills in out/stills`);
|
|
if (errors.length) throw new Error(`page errors:\n ${errors.join('\n ')}`);
|
|
await page.close();
|
|
return;
|
|
}
|
|
// 60 fps capture, then each pair of frames is averaged into one 30 fps frame (motion blur on fast moves)
|
|
const frames = Math.round(DURATION * CAPTURE_FPS);
|
|
const silent = join(OUT, `teaser-${f.w}x${f.h}-silent.mp4`);
|
|
const enc = Bun.spawn(['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', '-f', 'image2pipe', '-framerate', String(CAPTURE_FPS), '-c:v', 'png', '-i', '-',
|
|
'-vf', `tmix=frames=2:weights='1 1',select='not(mod(n\\,2))',setpts=N/${FPS}/TB`, '-r', String(FPS),
|
|
'-c:v', 'libx264', '-profile:v', 'high', '-preset', 'slow', '-crf', '16', '-pix_fmt', 'yuv420p',
|
|
'-g', String(FPS * 2), '-movflags', '+faststart', silent], { stdin: 'pipe', stdout: 'inherit', stderr: 'inherit' });
|
|
const t0 = Date.now();
|
|
for (let i = 0; i < frames; i++) {
|
|
// frame i shows t = (i + 1) / 60 blended with i / 60: the pair (2k, 2k+1) becomes output frame k
|
|
enc.stdin.write(await shot(i / CAPTURE_FPS));
|
|
if (i % 120 === 0 || i === frames - 1) {
|
|
const el = (Date.now() - t0) / 1000;
|
|
log(`frame ${i + 1}/${frames} · ${el.toFixed(0)}s · eta ${((frames - i - 1) * el / (i + 1)).toFixed(0)}s`);
|
|
}
|
|
}
|
|
await enc.stdin.end();
|
|
if (await enc.exited !== 0) throw new Error('ffmpeg encode failed');
|
|
if (errors.length) throw new Error(`page errors:\n ${[...new Set(errors)].join('\n ')}`);
|
|
ff('-ss', String(DURATION - 0.5), '-i', silent, '-frames:v', '1', '-q:v', '3', join(OUT, `poster-${f.w}x${f.h}.jpg`));
|
|
log(`${silent} (${(statSync(silent).size / 1e6).toFixed(1)} MB)`);
|
|
await page.close();
|
|
}
|
|
if (args.includes('--no-audio')) return;
|
|
|
|
// music + sound design
|
|
const page = await browser.newPage();
|
|
await page.goto(base);
|
|
log('sound: rendering (OfflineAudioContext)');
|
|
const wav = await page.evaluate(`import('./music.js').then(m => m.renderWav())`) as { base64: string; peak: number; seconds: number };
|
|
log(`sound: ${wav.seconds.toFixed(2)} s, raw peak ${wav.peak.toFixed(3)}`);
|
|
const raw = join(OUT, 'music-raw.wav'), music = join(OUT, 'music.wav');
|
|
await Bun.write(raw, Buffer.from(wav.base64, 'base64'));
|
|
const dur = wav.seconds;
|
|
// loudness: measured with loudnorm (EBU R128), then ONE gain to about -16 LUFS that keeps the true peak at or below
|
|
// -1.5 dBTP, and a short fade out. No dynamic normalization: the near silence and the stop are part of the story
|
|
const m = Bun.spawnSync(['ffmpeg', '-hide_banner', '-i', raw, '-af', 'loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json', '-f', 'null', '-'], { stderr: 'pipe' });
|
|
const j = JSON.parse(m.stderr.toString().match(/\{[\s\S]*?\}/)![0]) as Record<string, string>;
|
|
const gain = Math.min(-16 - Number(j.input_i), -1.5 - Number(j.input_tp));
|
|
log(`sound: ${j.input_i} LUFS, LRA ${j.input_lra} LU, true peak ${j.input_tp} dBTP; gain ${gain.toFixed(2)} dB -> ${(Number(j.input_i) + gain).toFixed(1)} LUFS`);
|
|
ff('-i', raw, '-af', `volume=${gain.toFixed(2)}dB,afade=t=out:st=${(dur - 1.5).toFixed(2)}:d=1.5,aresample=48000`,
|
|
'-c:a', 'pcm_s16le', '-ar', '48000', '-ac', '2', music);
|
|
if (!soundOnly) {
|
|
const silent = join(OUT, `teaser-${FORMAT.w}x${FORMAT.h}-silent.mp4`), out = join(OUT, `teaser-${FORMAT.w}x${FORMAT.h}.mp4`);
|
|
if (!existsSync(silent)) throw new Error(`${silent} missing`);
|
|
ff('-i', silent, '-i', music, '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2', '-shortest', '-movflags', '+faststart', out);
|
|
log(`${out} (${(statSync(out).size / 1e6).toFixed(1)} MB)`);
|
|
}
|
|
if (soundOnly) return;
|
|
|
|
// web version: 1280x720 H.264, about 6 MB (two-pass), plus its poster
|
|
const h = join(OUT, 'teaser-1920x1080.mp4');
|
|
if (existsSync(h)) {
|
|
const web = join(OUT, 'teaser-web.mp4'), pass = join(OUT, 'web-pass');
|
|
const kbps = Math.floor((WEB_BYTES * 8) / dur / 1000) - 96;
|
|
const common = ['-vf', 'scale=1280:720:flags=lanczos', '-c:v', 'libx264', '-profile:v', 'high', '-preset', 'slow', '-b:v', `${kbps}k`, '-maxrate', `${Math.round(kbps * 1.8)}k`, '-bufsize', `${kbps * 3}k`, '-pix_fmt', 'yuv420p', '-g', '60', '-passlogfile', pass];
|
|
ff('-i', h, ...common, '-pass', '1', '-an', '-f', 'mp4', '/dev/null');
|
|
ff('-i', h, ...common, '-pass', '2', '-c:a', 'aac', '-b:a', '96k', '-ac', '2', '-movflags', '+faststart', web);
|
|
for (const x of readdirSync(OUT)) if (x.startsWith('web-pass')) rmSync(join(OUT, x));
|
|
ff('-ss', String(dur - 0.5), '-i', web, '-frames:v', '1', '-q:v', '4', join(OUT, 'poster-web.jpg'));
|
|
log(`${web} (${(statSync(web).size / 1e6).toFixed(2)} MB, ${kbps} kbps video) + poster-web.jpg`);
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
server.stop(true);
|
|
}
|
|
}
|
|
|
|
await main();
|