Nine scenes of 6-7 s, one slow camera move each, soft dissolves, one plain-English caption per scene at the top (on screen at least 4 s): the stores' to-do list and one rejection mail, `init` in an empty folder, the agent prompt and `release plan` (25 steps), store text and renders in 7 sizes x 4 languages, `play products --dry` and `catalog diff`, the scan's declarations proposal with the ask-owner line, three findings with plain reasons turning green, the desktop release timeline and Store readiness "No blockers", Submit and the end card. Every CLI line is real output: tests/teaser.test.ts runs each command (store answers from tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml, never the network) and checks scene lengths, caption holds, the 55-65 s total and 16:9 only. The vertical output is gone; the web version targets 4.5 MB. Calmer music (D major pads, soft pluck, bells on scene changes). desktop.ts adds the Store readiness frame.
164 lines
10 KiB
TypeScript
164 lines
10 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 --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. */
|
|
const WEB_BYTES = 4.5e6;
|
|
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 grid: the demo game's real store renders, the first image of every size in every language. */
|
|
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++;
|
|
}
|
|
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 (examples/demo-game/store/renders)`);
|
|
}
|
|
|
|
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 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;
|
|
// two-pass loudnorm to -16 LUFS / -1.5 dBTP (calm music), then a short fade out
|
|
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>;
|
|
ff('-i', raw, '-af', `loudnorm=I=-16: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 - 1.5).toFixed(2)}:d=1.5,aresample=48000`,
|
|
'-c:a', 'pcm_s16le', '-ar', '48000', '-ac', '2', music);
|
|
{
|
|
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)`);
|
|
}
|
|
|
|
// web version: 1280x720 H.264, about 4.5 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();
|