teaser: the reject shot redone (7-11 s, 4 s): the common rejections storebound catches as real scan finding cards landing one per beat (2.1(a) demo account, 5.1.1(v) account deletion, 5.1.2(i) ATT from tests/fixtures/scan/teaser; ITMS-91064 from the new teaser-manifest fixture), guideline + one plain line each, then our own REJECTED stamp hits the stack on the beat at 9.5 s, no shake (one 4 px 1-frame jolt), a 3% push, the picture darkens into the cut; caption 'Rejected. Again.'; later shots +1.5 s (42.5 s, every cut on a beat, music follows the scenes, a thud per card and a hit on the stamp); render.ts --animatic --range a,b and --sheet a,b,fps, and the reject cards' boxes per frame in animatic-checks.json; tests: real finding lines, beat timing, and every card inside 6% safe margins at every frame

This commit is contained in:
CRTkafa 2026-10-14 05:22:47 +08:00
parent 511716bfd5
commit 20a764b3ee
11 changed files with 205 additions and 86 deletions

View file

@ -7,7 +7,8 @@
// 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
// (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
@ -125,7 +126,8 @@ async function main(): Promise<void> {
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 ')}`);
// 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;
}
@ -149,7 +151,8 @@ 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 ')}`);
// 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();
@ -216,9 +219,11 @@ async function animatic(base: string, dir: string): Promise<void> {
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);
// --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(${i / FPS})`);
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}`);
}
@ -227,21 +232,25 @@ async function animatic(base: string, dir: string): Promise<void> {
// 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})`));
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'));
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 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 [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 => `<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 cells = secs.map(k => `<figure><img src="data:image/jpeg;base64,${Buffer.from(readFileSync(join(tmp, `${k}.jpg`))).toString('base64')}"><figcaption><b>${k.toFixed(2)}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 });
@ -254,7 +263,7 @@ async function animatic(base: string, dir: string): Promise<void> {
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));
writeFileSync(join(OUT, 'animatic-checks.json'), JSON.stringify({ duration: DURATION, fps: FPS, frame: FORMAT, endLayout: layout, rejectLayout }, null, 2));
log(`animatic: ${dir} (draft.mp4, contact-sheet.png, shots.md); layout -> out/animatic-checks.json`);
} finally { await browser.close(); }
}