teaser: render.ts --scale 2, the 3840x2160 master for 4K uploads (same page at device scale 2, desktop frames captured at @3x into out/desktop@3x, demo game renders at 2x into out/assets@2x, H.264 high about 40 Mbps, AAC 320 kbps, +faststart; no web version); desktop.ts captureDesktop takes a scale and an output folder

This commit is contained in:
CRTkafa 2026-10-14 04:27:49 +08:00
parent 8a0a05dca2
commit d1159313ac
3 changed files with 34 additions and 15 deletions

View file

@ -69,6 +69,7 @@ game's store captures and renders:
cd examples/demo-game && bun serve.ts & bun ../../cli/index.ts shots capture && bun ../../cli/index.ts shots fonts && bun ../../cli/index.ts shots render
# from the repo root
bun media/teaser/render.ts > media/teaser/out/render.log 2>&1 & # master, sound, poster, web version: about 20 min
bun media/teaser/render.ts --scale 2 > media/teaser/out/render-4k.log 2>&1 & # 3840x2160 master for X (about 40 Mbps, AAC 320k): about 45 min
bun media/teaser/render.ts --desktop # capture the desktop app frames again (after a UI change)
bun media/teaser/render.ts --animatic ../storebound-dist/teaser-v9-animatic # draft.mp4 (960x540), contact-sheet.png, shots.md: for the owner's OK
bun media/teaser/render.ts --stills 2.5,13.5,38 # PNG stills in out/stills for a quick layout check

View file

@ -39,7 +39,7 @@ import { launch } from '../../cli/shots/browser';
const DIR = import.meta.dir;
const ROOT = join(DIR, '../..');
const OUT = join(DIR, 'out/desktop');
const DEFAULT_OUT = join(DIR, 'out/desktop');
export const VIEW = { width: 1280, height: 800 };
const log = (m: string): void => console.log(`${new Date().toISOString().slice(11, 19)} [desktop] ${m}`);
@ -64,7 +64,9 @@ export function realConsolePlan(form: string): Record<string, unknown> {
} finally { rmSync(dir, { recursive: true, force: true }); }
}
export async function captureDesktop(): Promise<void> {
/** `scale` is the device scale of the captures (2: 2560x1600, the default; 3 for the 4K master), `dir` where they go. */
export async function captureDesktop(o: { scale?: number; dir?: string } = {}): Promise<void> {
const OUT = o.dir ?? DEFAULT_OUT, SCALE = o.scale ?? 2;
mkdirSync(OUT, { recursive: true });
const privacyPlan = realConsolePlan('apple-app-privacy');
const port = 14_300 + Math.floor(Math.random() * 500);
@ -84,7 +86,7 @@ export async function captureDesktop(): Promise<void> {
const errors: string[] = [];
const session = async (clean: boolean, waiting = false) => {
const ctx = await browser.newContext({ viewport: VIEW, deviceScaleFactor: 2, colorScheme: 'dark', reducedMotion: 'reduce' });
const ctx = await browser.newContext({ viewport: VIEW, deviceScaleFactor: SCALE, colorScheme: 'dark', reducedMotion: 'reduce' });
// the console plan panel shows the real plan of the CLI instead of the fixture's shortened one; after the submit
// (waiting), the fixture's apple status holds the new submission waiting for review
await ctx.route(/\/src\/dev\/submit-fixtures\.ts/, async (route: any) => {

View file

@ -9,6 +9,10 @@
// bun media/teaser/render.ts --animatic <dir> the animatic for the owner's OK before any final render: draft.mp4
// (960x540, 30 fps, quick encode, with sound), contact-sheet.png
// (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).
@ -20,11 +24,18 @@ 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 };
/** --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;
@ -49,7 +60,7 @@ function prepAssets(): void {
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`));
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
@ -59,7 +70,7 @@ function prepAssets(): void {
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:flags=lanczos', '-q:v', '2', join(game, `${id}.jpg`));
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)`);
@ -70,7 +81,10 @@ 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';
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));
@ -82,7 +96,7 @@ function serve(): ReturnType<typeof Bun.serve> {
async function main(): Promise<void> {
mkdirSync(OUT, { recursive: true });
prepAssets();
if (args.includes('--desktop') || !existsSync(join(OUT, 'desktop/boxes.json'))) await captureDesktop();
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; }
@ -95,7 +109,7 @@ async function main(): Promise<void> {
const soundOnly = args.includes('--sound');
if (!soundOnly) {
const f = FORMAT;
const page = await browser.newPage({ viewport: { width: f.w, height: f.h }, deviceScaleFactor: 1 });
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()}`); });
@ -117,10 +131,12 @@ async function main(): Promise<void> {
}
// 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 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', '-crf', '16', '-pix_fmt', 'yuv420p',
'-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++) {
@ -134,7 +150,7 @@ async function main(): Promise<void> {
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`));
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();
}
@ -158,12 +174,12 @@ async function main(): Promise<void> {
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-${FORMAT.w}x${FORMAT.h}-silent.mp4`), out = join(OUT, `teaser-${FORMAT.w}x${FORMAT.h}.mp4`);
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', '192k', '-ar', '48000', '-ac', '2', '-shortest', '-movflags', '+faststart', out);
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) return;
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');