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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue