#!/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 { 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 { 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 => { 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; 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();