#!/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 --animatic the animatic for the owner's OK before any final render: draft.mp4 // (960x540, 30 fps, quick encode, with sound), contact-sheet.png; // --range 5,14 renders only that region, --sheet 6,12,4 sets the sheet's span and rate // (one frame per second, timestamped), shots.md, checks.json // bun media/teaser/render.ts --scale 2 the 3840x2160 master for 4K uploads: the same page at device scale 2 // (text and terminals rendered natively), the desktop frames captured // at @3x (out/desktop@3x), the demo game renders at 2x (out/assets@2x), // H.264 high at about 40 Mbps, AAC 320 kbps; no web version // 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, readFileSync, 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 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 }; /** --scale 2: the page keeps its 1920x1080 layout and renders at this device scale (3840x2160). */ const SCALE = Number(opt('scale') ?? 1); if (SCALE !== 1 && SCALE !== 2) throw new Error('--scale takes 1 or 2'); const OUT_W = FORMAT.w * SCALE, OUT_H = FORMAT.h * SCALE; /** The desktop frames' device scale: the deepest push shows a 1280 px frame at about 1.43 CSS px per px, so 4K needs @3x. */ const DESK_DIR = SCALE > 1 ? 'desktop@3x' : 'desktop'; const ASSET_DIR = SCALE > 1 ? `assets@${SCALE}x` : 'assets'; const ASSETS = join(OUT, ASSET_DIR); /** 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=${Math.min(1024, 600 * SCALE)}:-2:flags=lanczos` : `scale=-2:${360 * SCALE}: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 * SCALE}: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 { return Bun.serve({ port: Number(opt('port') ?? 0), fetch(req) { let p = decodeURIComponent(new URL(req.url).pathname).replace(/^\/+/, '') || 'index.html'; // --scale 2: the page asks for out/desktop and out/assets; serve the higher-resolution captures in their place if (p.startsWith('out/desktop/')) p = `out/${DESK_DIR}/${p.slice('out/desktop/'.length)}`; else if (p.startsWith('out/assets/')) p = `out/${ASSET_DIR}/${p.slice('out/assets/'.length)}`; 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 { mkdirSync(OUT, { recursive: true }); prepAssets(); if (args.includes('--desktop') || !existsSync(join(OUT, DESK_DIR, 'boxes.json'))) await captureDesktop(SCALE > 1 ? { scale: 3, dir: join(OUT, DESK_DIR) } : {}); 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 anim = opt('animatic'); if (anim) { try { await animatic(base, anim); } finally { server.stop(true); } 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: SCALE }); 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 => { 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`); // the reject card at every frame of its shot (safe margins, tests/teaser.test.ts) 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-${OUT_W}x${OUT_H}-silent.mp4`); // 1080p: CRF 16; 4K for X: about 40 Mbps (41 s, about 210 MB, under X's 512 MB) const rate = SCALE > 1 ? ['-b:v', '40M', '-maxrate', '50M', '-bufsize', '80M'] : ['-crf', '16']; 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', ...rate, '-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'); // the reject card at every frame of its shot (safe margins, tests/teaser.test.ts) 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-${OUT_W}x${OUT_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; 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).toFixed(2)}:d=1,aresample=48000`, '-c:a', 'pcm_s16le', '-ar', '48000', '-ac', '2', music); if (!soundOnly) { const silent = join(OUT, `teaser-${OUT_W}x${OUT_H}-silent.mp4`), out = join(OUT, `teaser-${OUT_W}x${OUT_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', SCALE > 1 ? '320k' : '192k', '-ar', '48000', '-ac', '2', '-shortest', '-movflags', '+faststart', out); log(`${out} (${(statSync(out).size / 1e6).toFixed(1)} MB)`); } if (soundOnly || SCALE > 1) 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); } } /** The animatic: a quick low-res draft with sound, a contact sheet (one frame per second, timestamped), the shot list and * the layout of the end card's install line over the last 4 s. */ async function animatic(base: string, dir: string): Promise { mkdirSync(dir, { recursive: true }); const S = await import(join(DIR, 'script.js')) as any; const browser = await launch(ROOT, { headless: true }); try { const page = await browser.newPage({ viewport: { width: FORMAT.w, height: FORMAT.h }, deviceScaleFactor: 0.5 }); const errors: string[] = []; page.on('pageerror', (e: Error) => errors.push(e.message)); await page.goto(base); await page.waitForFunction('window.teaser', null, { timeout: 30_000 }); await page.evaluate('window.teaser.ready'); const { DURATION, FPS } = S; const silent = join(dir, 'draft-silent.mp4'); const enc = Bun.spawn(['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', '-f', 'image2pipe', '-framerate', String(FPS), '-c:v', 'png', '-i', '-', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '24', '-pix_fmt', 'yuv420p', '-g', String(FPS), silent], { stdin: 'pipe', stdout: 'inherit', stderr: 'inherit' }); // --range a,b: only that region (a re-cut of one shot); --sheet a,b,fps: the contact sheet's span and rate const [r0, r1] = (opt('range') ?? `0,${DURATION}`).split(',').map(Number) as [number, number]; const frames = Math.round((r1 - r0) * FPS); for (let i = 0; i < frames; i++) { await page.evaluate(`teaser.seek(${r0 + i / FPS})`); enc.stdin.write(await page.screenshot({ type: 'png' })); if (i % 150 === 0) log(`draft frame ${i + 1}/${frames}`); } await enc.stdin.end(); if (await enc.exited !== 0) throw new Error('ffmpeg encode failed'); // the end card's install line over the last 4 s, every 0.25 s const layout: unknown[] = []; for (let t = DURATION - 4; t < DURATION - 1e-6; t += 0.25) layout.push(await page.evaluate(`teaser.layout(${t})`)); // the reject card at every frame of its shot (safe margins, tests/teaser.test.ts) const rj = S.scene('reject'), rejectLayout: unknown[] = []; for (let i = 0; i < Math.round((rj.end - rj.start) * FPS); i++) rejectLayout.push(await page.evaluate(`teaser.rejectLayout(${rj.start + i / FPS})`)); // text overflow at every frame of the whole teaser: each text node fits its element and its card, every card in frame const textFrames = Math.round(DURATION * FPS); const textProblems = await page.evaluate(`(() => { const out = []; for (let i = 0; i < ${textFrames}; i++) out.push(...teaser.textCheck(i / ${FPS})); return out; })()`) as unknown[]; if (errors.length) throw new Error(`page errors:\n ${[...new Set(errors)].join('\n ')}`); // sound const wav = await page.evaluate(`import('./music.js').then(m => m.renderWav())`) as { base64: string }; const raw = join(dir, 'music-raw.wav'); await Bun.write(raw, Buffer.from(wav.base64, 'base64')); ff('-i', silent, '-ss', String(r0), '-t', String(r1 - r0), '-i', raw, '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af', 'loudnorm=I=-16:TP=-1.5', '-c:a', 'aac', '-b:a', '128k', '-ar', '48000', '-shortest', '-movflags', '+faststart', join(dir, 'draft.mp4')); rmSync(silent); rmSync(raw); // contact sheet: the draft's frame at every whole second, timestamped const tmp = join(dir, 'sheet-frames'); mkdirSync(tmp, { recursive: true }); const [s0, s1, sfps] = (opt('sheet') ?? `${r0},${r1},1`).split(',').map(Number) as [number, number, number]; const secs = Array.from({ length: Math.round((s1 - s0) * sfps) }, (_, k) => s0 + k / sfps).filter(k => k >= r0 && k < r1); for (const k of secs) ff('-ss', String(Math.min(k - r0, r1 - r0 - 1 / FPS)), '-i', join(dir, 'draft.mp4'), '-frames:v', '1', '-vf', 'scale=320:-2', '-q:v', '3', join(tmp, `${k}.jpg`)); const shotAt = (t: number) => S.SCENES.find((s: any) => t >= s.start && t < s.end)?.id ?? 'end'; const cols = 7; const cells = secs.map(k => `
${k.toFixed(2)}s ${shotAt(k)}
`).join(''); const sheet = await browser.newPage({ viewport: { width: cols * 330 + 20, height: 400 }, deviceScaleFactor: 1 }); await sheet.setContent(`${cells}`); await sheet.screenshot({ path: join(dir, 'contact-sheet.png'), fullPage: true }); rmSync(tmp, { recursive: true, force: true }); // the shot list const caps = S.captionTimes(); const md = ['# storebound teaser v9: animatic shot list', '', `${DURATION} s, 16:9, 120 BPM (every cut on a half-second beat), hard cuts.`, '', ...S.SCENES.map((s: any, i: number) => { const c = caps.filter((x: any) => x.at >= s.start - 0.01 && x.at < s.end); return `${i + 1}. ${s.start.toFixed(1)}-${s.end.toFixed(1)} s (${(s.end - s.start).toFixed(1)} s) **${s.id}**: ${S.SHOT_NOTES[s.id]}${c.length ? ` · caption "${c.map((x: any) => x.text).join('", "')}" (${c.map((x: any) => `${x.from.toFixed(2)}-${x.to.toFixed(2)} s`).join(', ')})` : ''}`; }), '']; writeFileSync(join(dir, 'shots.md'), md.join('\n')); writeFileSync(join(OUT, 'animatic-checks.json'), JSON.stringify({ duration: DURATION, fps: FPS, frame: FORMAT, endLayout: layout, rejectLayout, textFrames, textProblems }, null, 2)); log(`animatic: ${dir} (draft.mp4, contact-sheet.png, shots.md); layout -> out/animatic-checks.json`); } finally { await browser.close(); } } await main();