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.
133 lines
7.5 KiB
TypeScript
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();
|