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:
CRTkafa 2026-10-14 00:21:09 +08:00
parent 7ac028421a
commit c608b9f63e
6 changed files with 326 additions and 207 deletions

View file

@ -1,7 +1,8 @@
// The teaser video (media/teaser, v8, a story): every CLI line it shows is real output of the command it names, the game
// screens and screenshot tiles are the demo game's own, every desktop frame is a real capture of the app (desktop.ts),
// every cut lands on a beat of the music, each caption is short and fully on screen for at least 2.5 s, the whole cut is
// 56-62 s, and it is 16:9 only.
// The teaser video (media/teaser, v9, the story cut tight): every CLI line it shows is real output of the command it
// names, the game screens and screenshot tiles are the demo game's own, every desktop frame is a real capture of the app
// (desktop.ts), every cut lands on a beat of the music, each shot is 2-4 s, each caption is at most six words and fully
// on screen for at least 2 s, the whole cut is 40-48 s, frame 0 already moves, the last 4 s hold the end card with
// `npx storebound init`, and it is 16:9 only.
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { generateKeyPairSync } from 'node:crypto';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
@ -22,7 +23,7 @@ interface Teaser {
CAPTIONS: { id: string; pos: 'top' | 'edge'; text: string; at: number; until: number }[];
captionTimes(): { id: string; text: string; pos: string; at: number; from: number; to: number; gone: number; hold: number }[];
veil(t: number): number;
GAME_SCREENS: string[];
GAME_SCREENS: string[]; GAME_SPEED: number;
PRIVACY_ANSWERS: string[]; PRIVACY_STEPS: string[]; DATA_SAFETY_CSV: string[]; DECL_TOTAL: number; PRICE_ROWS: string[]; PRICE_CMD: string;
PILE: { at: number; kind: string }[]; PILE_TITLES: Record<string, string>;
REJECT: { head: string; at: string }; PROMPT: string;
@ -33,7 +34,8 @@ interface Teaser {
DESK: Record<string, { groups: { frame: string }[]; swap?: number[]; show?: number[]; rings: { at: number; until: number; target: string }[];
pointer?: { at: number; d: number; target: string; click?: number; until?: number } }>;
DESK_SCENES: string[]; DESK_FRAMES: string[]; CLICK_T: number;
END: { markAt: number; wordAt: number; lineAt: number; urlAt: number; d: number }; END_LINE: string; END_URL: string;
END: { markAt: number; wordAt: number; cmdAt: number; cps: number; urlAt: number; d: number; full: number }; END_CMD: string; END_URL: string;
SHOT_NOTES: Record<string, string>;
}
const T = (await import(join(ROOT, 'media/teaser/script.js'))) as Teaser;
@ -198,65 +200,69 @@ describe('teaser: timing and format', () => {
expect(render).not.toMatch(/1080\s*x\s*1920|w: 1080, h: 1920|vertical/i);
expect(render).toContain('scale=1280:720');
});
test('the story: nine beats in order, the montage in the middle, 56-62 s', () => {
test('the story: the shots in order, the montage in the middle, 40-48 s, hard cuts only', () => {
expect(T.SCENES.map(s => s.id)).toEqual(['game', 'pile', 'reject', 'ask', 'builds', 'shots', 'products', 'forms', 'consoles', 'scan', 'button', 'waiting', 'end']);
expect(T.MONTAGE).toEqual(['builds', 'shots', 'products', 'forms', 'consoles']);
expect(T.DURATION).toBe(T.SCENES.at(-1)!.end);
expect(T.DURATION).toBeGreaterThanOrEqual(56);
expect(T.DURATION).toBeLessThanOrEqual(62);
// the montage spans 19-37 s and speeds up (no beat longer than the one before)
const m = T.MONTAGE.map(id => T.SCENES.find(s => s.id === id)!);
expect(m[0]!.start).toBe(19);
expect(m.at(-1)!.end).toBe(37);
for (let i = 1; i < m.length; i++) expect(m[i]!.end - m[i]!.start).toBeLessThanOrEqual(m[i - 1]!.end - m[i - 1]!.start);
for (const s of m) expect(s.x, s.id).toBeLessThanOrEqual(0.15); // cuts, not dissolves
expect(T.DURATION).toBeGreaterThanOrEqual(40);
expect(T.DURATION).toBeLessThanOrEqual(48);
// the montage: 3 s per beat
for (const id of T.MONTAGE) { const s = T.SCENES.find(x => x.id === id)!; expect(s.end - s.start, id).toBe(3); }
for (const s of T.SCENES) expect(s.x, s.id).toBe(0);
// every shot says why it cuts (the animatic's shot list)
for (const s of T.SCENES) expect(T.SHOT_NOTES[s.id], s.id).toBeTruthy();
});
test('every cut lands on a beat of music.js (120 BPM: whole half seconds), scenes 3-6 s', () => {
test('every cut lands on a beat of music.js (120 BPM: whole half seconds), shots 2-4 s, the end card 5 s', () => {
const music = readFileSync(join(ROOT, 'media/teaser/music.js'), 'utf8');
expect(music).toContain('export const STEP = 0.5;');
expect(T.SCENES[0]!.start).toBe(0);
for (let i = 1; i < T.SCENES.length; i++) expect(T.SCENES[i]!.start).toBe(T.SCENES[i - 1]!.end);
for (const s of T.SCENES) {
expect(Number.isInteger(s.start / 0.5), s.id).toBe(true);
expect(s.end - s.start, s.id).toBeGreaterThanOrEqual(3);
expect(s.end - s.start, s.id).toBeLessThanOrEqual(6);
expect(s.end - s.start, s.id).toBeGreaterThanOrEqual(2);
expect(s.end - s.start, s.id).toBeLessThanOrEqual(s.id === 'end' ? 5 : 4);
}
// the fixes chime on beats too
// the fixes chime on beats too, and the pile lands one card per beat
const scan = T.SCENES.find(s => s.id === 'scan')!;
for (const f of T.FIX_AT) expect(Number.isInteger((scan.start + f) / 0.5), String(f)).toBe(true);
T.PILE.forEach((c, i) => expect(c.at, c.kind).toBe(i * 0.5));
});
test('captions: at most one short line per scene, fully on screen for at least 2.5 s, inside its scene', () => {
test('captions: at most six words, fully on screen for at least 2 s, never over the end card', () => {
const c = T.captionTimes();
expect(new Set(c.map(x => x.id)).size).toBe(c.length);
const end = T.SCENES.find(s => s.id === 'end')!;
for (const x of c) {
const s = T.SCENES.find(y => y.id === x.id)!;
expect(x.hold, x.text).toBeGreaterThanOrEqual(2.5);
expect(x.at, x.text).toBeGreaterThanOrEqual(s.start);
expect(x.gone, x.text).toBeLessThanOrEqual(s.end);
expect(x.text.split(' ').length, x.text).toBeLessThanOrEqual(8);
expect(x.hold, x.text).toBeGreaterThanOrEqual(2);
expect(x.at, x.text).toBeGreaterThanOrEqual(T.SCENES.find(y => y.id === x.id)!.start);
expect(x.gone, x.text).toBeLessThanOrEqual(end.start);
expect(x.text.split(' ').length, x.text).toBeLessThanOrEqual(6);
}
// the montage words sit at the edge, the story lines at the top; the click and the end card carry no caption
for (let i = 1; i < c.length; i++) expect(c[i]!.at, c[i]!.text).toBeGreaterThanOrEqual(c[i - 1]!.gone);
for (const x of c) expect(x.pos, x.id).toBe(T.MONTAGE.includes(x.id) ? 'edge' : 'top');
expect(c.map(x => x.id)).not.toContain('waiting');
expect(c.map(x => x.id)).not.toContain('end');
expect(c.map(x => x.text)).toEqual(['The game is done.', 'Then come the stores.', 'One missing line, and you start over.', 'So I handed it all to my AI.',
'Builds.', 'Screenshots, every size.', 'Products, 175 countries.', 'Forms.', 'Consoles.', 'Nothing left for a reviewer to catch.', 'One button is yours.']);
expect(c.map(x => x.text)).toEqual(['Your game is done.', 'Then come the stores.', 'One line. Rejected.', 'So my AI did it.',
'Builds.', 'Screenshots, every size.', 'Products, 175 countries.', 'Forms.', 'Consoles.', 'Nothing left to reject.', 'You press Submit.']);
});
test('the end card: the two lines on screen for at least 2.5 s', () => {
test('the cold open moves on frame 0: no fade from black, the screens already rushing past', () => {
expect(T.CAPTIONS[0]!.at).toBeLessThanOrEqual(0.2);
expect(T.GAME_SPEED).toBeGreaterThan(200);
const js = readFileSync(join(ROOT, 'media/teaser/teaser.js'), 'utf8');
expect(js).toContain("set(el, 'opacity', String(i === 0 ? 1 :");
});
test('the end card: the logo, `npx storebound init` and storebound.dev, complete for at least the last 4 s', () => {
const e = T.SCENES.find(s => s.id === 'end')!;
expect(T.END_LINE).toBe('From an empty folder to Submit.');
expect(T.END_URL).toBe('Open source · storebound.dev');
expect(e.end - e.start - T.END.urlAt - T.END.d).toBeGreaterThanOrEqual(2.5);
expect(T.SCENES.at(-1)!.id).toBe('end');
expect(T.END_CMD).toBe('npx storebound init');
expect(T.END_URL).toBe('storebound.dev');
expect(T.DURATION - (e.start + T.END.full)).toBeGreaterThanOrEqual(4);
const html = readFileSync(join(ROOT, 'media/teaser/index.html'), 'utf8');
expect(html).toMatch(/#brand \.cmdl \{[^}]*font: 800 7\dpx/);
});
test('the picture darkens over the rejection and lifts on the drop', () => {
test('the picture darkens with the rejection and lifts on the drop', () => {
const r = T.SCENES.find(s => s.id === 'reject')!, b = T.SCENES.find(s => s.id === 'builds')!;
expect(T.veil(r.start)).toBe(0);
expect(T.veil(r.end - 0.1)).toBeGreaterThan(0.5);
expect(T.veil(b.start)).toBe(0);
});
test('the pile speeds up: each card lands sooner after the one before', () => {
for (let i = 2; i < T.PILE.length; i++) expect(T.PILE[i]!.at - T.PILE[i - 1]!.at).toBeLessThanOrEqual(T.PILE[i - 1]!.at - T.PILE[i - 2]!.at + 1e-9);
});
});
// The desktop frames: the real app (fixture mode) in the consoles beat, the button and the waiting scene, captured by