teaser v9 animatic: 41 s story cut tight (hard cuts on the beat, 2-4 s shots, frame 0 moving, every shot opens on its subject, captions <= 6 words held >= 2 s, end card with npx storebound init held 4 s); render.ts --animatic (960x540 draft with sound, contact sheet, shot list, end-card layout checks); tests for the v9 timing rules
This commit is contained in:
parent
7ac028421a
commit
c608b9f63e
6 changed files with 326 additions and 207 deletions
|
|
@ -6,10 +6,13 @@
|
|||
// 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 <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 --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 { 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';
|
||||
|
|
@ -83,6 +86,8 @@ async function main(): Promise<void> {
|
|||
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 {
|
||||
|
|
@ -178,4 +183,64 @@ async function main(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/** 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<void> {
|
||||
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' });
|
||||
const frames = Math.round(DURATION * FPS);
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await page.evaluate(`teaser.seek(${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})`));
|
||||
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, '-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 secs = Array.from({ length: Math.ceil(DURATION) }, (_, k) => k);
|
||||
for (const k of secs) ff('-ss', String(Math.min(k, DURATION - 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 => `<figure><img src="data:image/jpeg;base64,${Buffer.from(readFileSync(join(tmp, `${k}.jpg`))).toString('base64')}"><figcaption><b>${k}s</b> ${shotAt(k)}</figcaption></figure>`).join('');
|
||||
const sheet = await browser.newPage({ viewport: { width: cols * 330 + 20, height: 400 }, deviceScaleFactor: 1 });
|
||||
await sheet.setContent(`<style>body{margin:0;padding:10px;background:#111;font:600 15px system-ui;color:#ddd;display:grid;grid-template-columns:repeat(${cols},320px);gap:10px}figure{margin:0}img{display:block;width:320px;height:180px}figcaption{padding:4px 2px}b{color:#fff}</style>${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, endLayout: layout }, null, 2));
|
||||
log(`animatic: ${dir} (draft.mp4, contact-sheet.png, shots.md); layout -> out/animatic-checks.json`);
|
||||
} finally { await browser.close(); }
|
||||
}
|
||||
|
||||
await main();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue