teaser v8: a 58 s story instead of a feature list (the game is done; the stores pile up; one missing line; hand it to your AI; a montage of builds, screenshots, products, forms, consoles; the scan turns green; one button is yours; Waiting for review; the end card), one short line per scene, cuts on the 120 BPM beat; music rewritten for the arc (calm, tension, near silence, a driving build, a stop, a resolve), mastered with one gain instead of dynamic loudnorm; the pile shows real console plan, Data safety CSV and price sheet lines, the builds beat real apple / play build dry runs; desktop.ts captures submit-ready (Send for review before the press); tests check every line, the beat-aligned cuts and the caption times; two unused teaser icons dropped
This commit is contained in:
parent
d5ffc159c0
commit
0b6c8cd7a0
11 changed files with 1117 additions and 1041 deletions
|
|
@ -4,6 +4,7 @@
|
|||
// -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 --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
|
||||
|
|
@ -21,7 +22,7 @@ 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 (about 770 kbps video for 62 s, as v6 had for 55.5 s). */
|
||||
/** 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}`);
|
||||
|
|
@ -33,7 +34,8 @@ function sh(cmd: string[], input?: Uint8Array): string {
|
|||
}
|
||||
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. */
|
||||
/** 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 });
|
||||
|
|
@ -47,8 +49,18 @@ function prepAssets(): void {
|
|||
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++;
|
||||
}
|
||||
// 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: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 (examples/demo-game/store/renders)`);
|
||||
log(`assets: ${n} demo game renders and screens (examples/demo-game/store)`);
|
||||
}
|
||||
|
||||
function serve(): ReturnType<typeof Bun.serve> {
|
||||
|
|
@ -75,7 +87,8 @@ async function main(): Promise<void> {
|
|||
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: 1 });
|
||||
const errors: string[] = [];
|
||||
|
|
@ -131,17 +144,21 @@ async function main(): Promise<void> {
|
|||
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
|
||||
// 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<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`,
|
||||
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.5).toFixed(2)}:d=1.5,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`);
|
||||
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)`);
|
||||
}
|
||||
if (soundOnly) return;
|
||||
|
||||
// web version: 1280x720 H.264, about 6 MB (two-pass), plus its poster
|
||||
const h = join(OUT, 'teaser-1920x1080.mp4');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue