storebound/media/teaser/render.ts
CRT b142cf6d21 Teaser video source: scripted terminal timeline, procedural music, render script (media/teaser)
HTML/CSS/JS timeline rendered frame by frame in headless Chrome, original music synthesized with OfflineAudioContext, ffmpeg encode + loudnorm. Outputs go to media/teaser/out (gitignored); poster.jpg is the end card.
2026-10-11 01:02:17 +08:00

133 lines
7.5 KiB
TypeScript

#!/usr/bin/env bun
// Renders the storebound teaser: frames from index.html (Playwright, headless Chrome) piped into ffmpeg, plus the
// original music (music.js, OfflineAudioContext in the same browser), mastered to about -14 LUFS.
// bun media/teaser/render.ts both formats, music, posters -> media/teaser/out/
// bun media/teaser/render.ts --only v vertical only (v = 1080x1920, h = 1920x1080)
// bun media/teaser/render.ts --stills 1,9.5,30 PNG stills only (out/stills/), quick layout check
// bun media/teaser/render.ts --serve preview server: open /index.html?w=1080&h=1920&play=1
// Needs ffmpeg and Playwright (devDependency; Playwright's Chromium or an installed Google Chrome).
import { existsSync, mkdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { launch } from '../../cli/shots/browser';
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; };
const FORMATS = [{ id: 'v', w: 1080, h: 1920 }, { id: 'h', w: 1920, h: 1080 }].filter(f => !opt('only') || opt('only') === f.id);
const log = (m: string): void => console.log(`${new Date().toISOString().slice(11, 19)} [teaser] ${m}`);
function sh(cmd: string[], input?: Uint8Array): void {
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)}`);
}
const ff = (...a: string[]): void => sh(['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', ...a]);
/** Screenshots used by the store-art scene: the demo game's real renders, or crops of docs/img/showcase.jpg. */
function prepAssets(): void {
mkdirSync(ASSETS, { recursive: true });
const names = ['en-01', 'en-02', 'en-03', 'en-04', 'tr-01', 'ja-01', 'ar-01'];
const renders = join(ROOT, 'examples/demo-game/store/renders');
const src = (n: string): string => { const [l, k] = n.split('-'); return join(renders, l!, 'play', `${k}.png`); };
if (names.every(n => existsSync(src(n)))) {
for (const n of names) ff('-i', src(n), '-vf', 'scale=720:1280:flags=lanczos', '-q:v', '2', join(ASSETS, `${n}.jpg`));
log('assets: demo game renders (examples/demo-game/store/renders)');
} else {
// showcase.jpg: 7 panes (en 01-04, tr, ja, ar hero), 263 x 571 each, 283 px apart, from (20, 20)
const show = join(ROOT, 'docs/img/showcase.jpg');
names.forEach((n, i) => ff('-i', show, '-vf', `crop=263:468:${20 + i * 283}:20,scale=720:1280:flags=lanczos`, '-q:v', '2', join(ASSETS, `${n}.jpg`)));
log('assets: demo renders not found, using crops of docs/img/showcase.jpg (run the demo\'s `shots render` for sharper art)');
}
}
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 });
const f = Bun.file(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();
const server = serve();
const base = `http://127.0.0.1:${server.port}/index.html`;
if (args.includes('--serve')) { log(`preview: ${base}?w=1080&h=1920&play=1`); return; }
const browser = await launch(ROOT, { headless: true });
try {
const stills = opt('stills');
for (const f of FORMATS) {
const page = await browser.newPage({ viewport: { width: f.w, height: f.h }, deviceScaleFactor: 1 });
page.on('pageerror', (e: Error) => console.error('page error:', e.message));
await page.goto(`${base}?w=${f.w}&h=${f.h}`);
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', `${f.id}-${t.toFixed(2)}.png`), await shot(t));
}
log(`${f.id}: ${stills.split(',').length} stills in out/stills`);
await page.close();
continue;
}
const frames = Math.round(DURATION * 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(FPS), '-c:v', 'png', '-i', '-',
'-c:v', 'libx264', '-profile:v', 'high', '-preset', 'slow', '-crf', '17', '-pix_fmt', 'yuv420p', '-r', String(FPS),
'-g', String(FPS * 2), '-movflags', '+faststart', silent], { stdin: 'pipe', stdout: 'inherit', stderr: 'inherit' });
const t0 = Date.now();
for (let i = 0; i < frames; i++) {
enc.stdin.write(await shot(i / FPS));
if (i % 60 === 0 || i === frames - 1) {
const el = (Date.now() - t0) / 1000;
log(`${f.id}: 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');
ff('-ss', String(DURATION - 0.5), '-i', silent, '-frames:v', '1', '-q:v', '3', join(OUT, `poster-${f.w}x${f.h}.jpg`));
log(`${f.id}: ${silent} (${(statSync(silent).size / 1e6).toFixed(1)} MB)`);
await page.close();
}
if (stills || args.includes('--no-audio')) return;
// music
const page = await browser.newPage();
await page.goto(base);
log('music: rendering (OfflineAudioContext)');
const wav = await page.evaluate(`import('./music.js').then(m => m.renderWav())`) as { base64: string; peak: number; seconds: number };
log(`music: ${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;
// two-pass loudnorm to -14 LUFS / -1 dBTP, then a 2 s fade out
const m = Bun.spawnSync(['ffmpeg', '-hide_banner', '-i', raw, '-af', 'loudnorm=I=-14: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>;
ff('-i', raw, '-af', `loudnorm=I=-14:TP=-1.5:LRA=11:measured_I=${j.input_i}:measured_TP=${j.input_tp}:measured_LRA=${j.input_lra}:measured_thresh=${j.input_thresh}:offset=${j.target_offset}:linear=true,afade=t=out:st=${(dur - 2).toFixed(2)}:d=2,aresample=48000`,
'-c:a', 'pcm_s16le', '-ar', '48000', '-ac', '2', music);
for (const f of FORMATS) {
const silent = join(OUT, `teaser-${f.w}x${f.h}-silent.mp4`), out = join(OUT, `teaser-${f.w}x${f.h}.mp4`);
if (!existsSync(silent)) continue;
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)`);
}
} finally {
await browser.close();
server.stop(true);
}
}
await main();