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,30 +1,33 @@
# storebound teaser (v8, 58 s, 16:9) # storebound teaser (v9, 41 s, 16:9)
A short story, not a feature list: the game is done, the stores pile up work, one missing line sends you back to the The v8 story cut tight: the game is done, the stores pile up work, one line gets it rejected, so your AI does it, and
start, so you hand it all to your AI, it does the work, and one button stays yours. Little text (one short line per you press Submit. Editing rules: frame 0 already moves and shows the product (no black, no calm intro); nothing holds
scene at most, big and calm), real product footage only, and the music carries the arc: calm, tension, near silence, still longer than 0.8 s; every shot does one thing in 2-4 s and cuts when that thing completes (a line lands, a number
a driving build, a stop, a resolve. Every frame is a pure function of time, rendered from an HTML/CSS/JS timeline in lands, a check turns green); captions are at most six words and on screen for at least 2 s; the camera pushes toward
headless Chrome and encoded with ffmpeg. The music and the sound design are original and synthesized from scratch in the thing that changes; the last 4 s are the end card with `npx storebound init` typed out big and storebound.dev.
the same browser (Web Audio `OfflineAudioContext`): no samples, no downloaded audio, nothing to license. Real product footage only. Every frame is a pure function of time, rendered from an HTML/CSS/JS timeline in headless
Chrome and encoded with ffmpeg; the music and sound design are synthesized in the same browser (Web Audio
`OfflineAudioContext`): no samples, nothing to license.
120 BPM: every cut lands on a beat (whole half seconds). The story scenes dissolve and drift slowly; the montage cuts 120 BPM: every cut is a hard cut on a beat (whole half seconds). Before any final render, make the animatic
hard on the beat with one quick push each. (`--animatic`: a 960x540 draft with sound, a one-frame-per-second contact sheet and the shot list) and get the owner's OK.
| s | shows | text | music | | s | shows | text | music |
|---|---|---|---| |---|---|---|---|
| 0-5 | the demo game's own screens (its store captures), floating, a slow drift | The game is done. | calm: a warm D major pad, a slow pluck | | 0-3 | the demo game's own screens rush past, a push in | Your game is done. | calm pad, a pluck |
| 5-11 | store work lands card after card, faster and faster: the App Privacy answer sheet and console steps, the Data safety CSV (768 rows), the screenshot sizes, the price sheet | Then come the stores. | tension: B minor, a growing low pulse, a ticking clock, a riser; a thud per card | | 3-7 | real store work lands one card per beat: App Privacy answers and steps, the Data safety CSV (768 rows), screenshot sizes, price sheets | Then come the stores. | tension, a thud per card |
| 11-15 | one real scan finding (BLOCKER 5.1.1(v), at Planner/LoginView.swift:11) grows until it fills the frame; the picture goes dark | One missing line, and you start over. | a low boom, a dark drone sinking to nothing | | 7-9.5 | the real scan finding (BLOCKER 5.1.1(v), Planner/LoginView.swift:11) slams in, a push onto it, the picture darkens | One line. Rejected. | a low boom |
| 15-19 | in the dark, one request is typed to the AI agent: "Get this game ready for review on both stores." | So I handed it all to my AI. | near silence: room tone, key clicks, a swell into the drop | | 9.5-12 | one request typed to the AI agent, sent on the beat | So my AI did it. | near silence, key clicks, a swell |
| 19-23 | `apple build --dry` and `play build --dry`: build 52, versionCode 52 | Builds. | the drop: kick, bass, arpeggio | | 12-15 | `apple build --dry`, `play build --dry`: build 52, versionCode 52 | Builds. | the drop |
| 23-27 | the demo game's renders: 7 store sizes x 4 languages | Screenshots, every size. | + hats | | 15-18 | the demo game's renders: 7 store sizes x 4 languages fill in | Screenshots, every size. | + hats |
| 27-30.5 | `play products --dry` (174 regions), `catalog diff` (175 / 174 region prices, 0 mismatches) | Products, 175 countries. | + claps | | 18-21 | `play products --dry`, `catalog diff` (175 / 174 region prices, 0 mismatches) | Products, 175 countries. | + claps |
| 30.5-34 | the scan's declarations proposal (one "ask owner" line), the Data safety rows filling, 768 answers | Forms. | + sixteenths | | 21-24 | the scan's declarations proposal, the Data safety rows ticking, 768 answers | Forms. | + sixteenths |
| 34-37 | `console plan apple-app-privacy`, then the desktop app's plan panel with that real plan, at the owner's Publish | Consoles. | + a brighter lead | | 24-27 | `console plan apple-app-privacy`, then the desktop app's plan panel, the owner's Publish ringed | Consoles. | + lead |
| 37-42 | `storebound scan`: five findings turn green one per beat, the re-scan is clean (0 blocker, 0 high) | Nothing left for a reviewer to catch. | the drive peaks, a chime per fix | | 27-31 | `storebound scan`: five findings turn green one per beat, the clean re-scan | Nothing left to reject. | a chime per fix |
| 42-48 | the desktop app's Releases › Send for review (build 52 picked); the camera settles on "Submit for review", the pointer rests on it | One button is yours. | the music stops; a quiet low pulse | | 31-33 | the desktop app's Send for review, the camera pushes onto Submit, the pointer arrives | You press Submit. | the music stops |
| 48-53 | the click; the same card shows the new submission "Waiting for review" (the app's own state) | (none) | the resolve: a wide D major chord, bells | | 33-36 | the click, then the app's own "Waiting for review" | (same line) | the resolve |
| 53-58 | the storebound mark (brand/logo-mark-256.png) | From an empty folder to Submit. · Open source · storebound.dev | the chord holds, fades | | 36-41 | the logo, `npx storebound init` typed out, storebound.dev; holds 4 s | (none) | the chord holds |
The demo game has 4 languages (en, tr, ja, ar), so the screenshot beat says "every size", not a language count. The demo game has 4 languages (en, tr, ja, ar), so the screenshot beat says "every size", not a language count.
The video shows no drawn system dialog and no mail: between the click and "Waiting for review" the real app shows its The video shows no drawn system dialog and no mail: between the click and "Waiting for review" the real app shows its
@ -67,7 +70,8 @@ cd examples/demo-game && bun serve.ts & bun ../../cli/index.ts shots capture &&
# from the repo root # from the repo root
bun media/teaser/render.ts > media/teaser/out/render.log 2>&1 & # master, sound, poster, web version: about 20 min bun media/teaser/render.ts > media/teaser/out/render.log 2>&1 & # master, sound, poster, web version: about 20 min
bun media/teaser/render.ts --desktop # capture the desktop app frames again (after a UI change) bun media/teaser/render.ts --desktop # capture the desktop app frames again (after a UI change)
bun media/teaser/render.ts --stills 2.5,13.5,46 # PNG stills in out/stills for a quick layout check bun media/teaser/render.ts --animatic ../storebound-dist/teaser-v9-animatic # draft.mp4 (960x540), contact-sheet.png, shots.md: for the owner's OK
bun media/teaser/render.ts --stills 2.5,13.5,38 # PNG stills in out/stills for a quick layout check
bun media/teaser/render.ts --sound # the music only (out/music.wav) bun media/teaser/render.ts --sound # the music only (out/music.wav)
bun media/teaser/render.ts --serve --port 8750 # live preview: /index.html?play=1 (or ?t=12.5) bun media/teaser/render.ts --serve --port 8750 # live preview: /index.html?play=1 (or ?t=12.5)
``` ```

View file

@ -85,7 +85,7 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
/* ---------- 3 reject: the finding ---------- */ /* ---------- 3 reject: the finding ---------- */
#finding { left: 50%; top: 52%; width: max-content; padding: 34px 44px 38px; border-radius: 26px; background: #1a0d12; #finding { left: 50%; top: 52%; width: max-content; padding: 34px 44px 38px; border-radius: 26px; background: #1a0d12;
border: 3px solid rgba(255,90,95,.65); box-shadow: 0 0 0 10px rgba(229,72,77,.12), 0 0 160px rgba(229,72,77,.35), 0 50px 120px rgba(0,0,0,.7); border: 3px solid rgba(255,90,95,.65); box-shadow: 0 0 0 10px rgba(229,72,77,.12), 0 0 160px rgba(229,72,77,.35), 0 50px 120px rgba(0,0,0,.7);
font-family: var(--mono); opacity: 0; transform-origin: 50% 50%; } font-family: var(--mono); opacity: 0; transform-origin: 30% 50%; }
#finding .l1 { display: flex; align-items: center; gap: 18px; white-space: nowrap; font-size: 31px; font-weight: 700; color: #ffd6d7; } #finding .l1 { display: flex; align-items: center; gap: 18px; white-space: nowrap; font-size: 31px; font-weight: 700; color: #ffd6d7; }
#finding .sev { color: #fff; background: var(--red-2); border-radius: 9px; padding: 3px 14px; font-weight: 800; } #finding .sev { color: #fff; background: var(--red-2); border-radius: 9px; padding: 3px 14px; font-weight: 800; }
#finding .gl { color: #fff; font-weight: 800; } #finding .gl { color: #fff; font-weight: 800; }
@ -164,12 +164,14 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
.ring.green { border-color: var(--green); box-shadow: 0 0 0 6px rgba(61,220,132,.22), 0 0 40px rgba(61,220,132,.45); } .ring.green { border-color: var(--green); box-shadow: 0 0 0 6px rgba(61,220,132,.22), 0 0 40px rgba(61,220,132,.45); }
/* ---------- 9 end card ---------- */ /* ---------- 9 end card ---------- */
#brand { left: 0; right: 0; top: 250px; text-align: center; } #brand { left: 0; right: 0; top: 150px; text-align: center; }
#brand > * { opacity: 0; } #brand > * { opacity: 0; }
#brand .mark { width: 200px; height: 200px; display: block; margin: 0 auto; filter: drop-shadow(0 30px 70px rgba(91,92,240,.6)); } #brand .mark { width: 200px; height: 200px; display: block; margin: 0 auto; filter: drop-shadow(0 30px 70px rgba(91,92,240,.6)); }
#brand .word { margin-top: 26px; font: 900 120px/1 var(--display); letter-spacing: -0.05em; color: #fff; } #brand .word { margin-top: 20px; font: 900 120px/1 var(--display); letter-spacing: -0.05em; color: #fff; }
#brand .line { margin-top: 40px; font: 750 64px/1.1 var(--display); letter-spacing: -0.025em; color: #fff; } #brand .cmdl { display: inline-block; margin-top: 44px; padding: 26px 48px; min-width: 1040px; text-align: left; border-radius: 22px;
#brand .url { margin-top: 28px; font: 700 42px/1 var(--display); color: var(--accent); letter-spacing: -0.01em; } background: #0b1222; border: 2px solid var(--line-2); box-shadow: 0 30px 80px rgba(0,0,0,.5); font: 800 76px/1.1 var(--mono); color: #fff; white-space: pre; }
#brand .cmdl .caret { width: .55em; height: 1em; vertical-align: -0.12em; margin-left: 6px; }
#brand .url { margin-top: 40px; font: 750 56px/1 var(--display); color: var(--accent); letter-spacing: -0.01em; }
</style> </style>
</head> </head>
<body> <body>
@ -211,7 +213,7 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
<div id="brand" class="abs"> <div id="brand" class="abs">
<img class="mark" src="brand/logo-mark-256.png" alt=""> <img class="mark" src="brand/logo-mark-256.png" alt="">
<div class="word">storebound</div> <div class="word">storebound</div>
<div class="line"></div> <div class="cmdl"><span class="ps">$</span> <span class="t"></span><span class="caret"></span></div>
<div class="url"></div> <div class="url"></div>
</div> </div>
</div></div> </div></div>

View file

@ -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 --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 --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 --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) // 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 // 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). // 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 { join } from 'node:path';
import { launch } from '../../cli/shots/browser'; import { launch } from '../../cli/shots/browser';
import { captureDesktop } from './desktop'; import { captureDesktop } from './desktop';
@ -83,6 +86,8 @@ async function main(): Promise<void> {
const server = serve(); const server = serve();
const base = `http://127.0.0.1:${server.port}/index.html`; const base = `http://127.0.0.1:${server.port}/index.html`;
if (args.includes('--serve')) { log(`preview: ${base}?play=1`); return; } 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 }); const browser = await launch(ROOT, { headless: true });
try { 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(); await main();

View file

@ -1,18 +1,18 @@
// Teaser timeline (v8, a story): the single source of truth for the picture (teaser.js) and the sound (music.js). // Teaser timeline (v9, the story cut tight): the single source of truth for the picture (teaser.js) and the sound
// 58 s, 16:9. Little text: one short line per scene at most, big and calm; the music carries the arc // (music.js). 41 s, 16:9, 120 BPM: every cut lands on a beat (a whole half second), every shot does one thing in
// (calm -> tension -> near silence -> a driving build -> stop -> resolve). Every cut lands on a half-second beat. // 2-4 s and cuts when that thing completes. Frame 0 already moves; nothing holds still longer than 0.8 s; the last
// 4 s are the end card with `npx storebound init`.
// //
// game 0-5 the demo game's real screens, a gentle drift "The game is done." // game 0-3 cold open: the demo game's real screens rush past, a push in "Your game is done."
// pile 5-11 real store work piles up: App Privacy answers, Data safety CSV, "Then come the stores." // pile 3-7 a wall of real store work lands, one card per beat "Then come the stores."
// screenshot sizes, price sheets // reject 7-9.5 the real scan finding 5.1.1(v) slams in, the picture darkens "One line. Rejected."
// reject 11-15 one real scan finding (5.1.1(v)) grows to fill the frame, it darkens "One missing line, and you start over." // ask 9.5-12 one request typed to the AI agent, sent on the beat "So my AI did it."
// ask 15-19 near silence: one request typed to the AI agent "So I handed it all to my AI." // builds..consoles 12-27 the montage, 3 s each, one word at the edge, each result lands: Builds. /
// builds..consoles 19-37 the montage, one word at the edge: Builds. / Screenshots, every size. / Products, 175 // Screenshots, every size. / Products, 175 countries. / Forms. / Consoles.
// countries. / Forms. / Consoles. // scan 27-31 `storebound scan`: one finding turns green per beat, the clean re-scan "Nothing left to reject."
// scan 37-42 `storebound scan`: each finding turns green, the re-scan is clean "Nothing left for a reviewer to catch." // button 31-33 the desktop app's Send for review, the camera pushes onto Submit "You press Submit."
// button 42-48 the desktop app's Send for review, the pointer resting on Submit; the music stops "One button is yours." // waiting 33-36 the click, then the app's own "Waiting for review" (same line)
// waiting 48-53 the click, then "Waiting for review" (the app's own state); the music resolves // end 36-41 the logo, `npx storebound init` typed out, storebound.dev (4 s hold)
// end 53-58 the logo, "From an empty folder to Submit.", "Open source · storebound.dev"
// //
// Every CLI line shown is real output; tests/teaser.test.ts runs each command and checks every line: // Every CLI line shown is real output; tests/teaser.test.ts runs each command and checks every line:
// pile `storebound console plan apple-app-privacy` (answer sheet, steps), `storebound play data-safety --dry` // pile `storebound console plan apple-app-privacy` (answer sheet, steps), `storebound play data-safety --dry`
@ -31,74 +31,91 @@ export const FPS = 30;
/** 16:9 only: the master; render.ts also makes the 1280x720 web version from it. */ /** 16:9 only: the master; render.ts also makes the 1280x720 web version from it. */
export const FORMAT = { w: 1920, h: 1080 }; export const FORMAT = { w: 1920, h: 1080 };
/** Scenes: id, length (s), the transition into it (s, centred on the cut; short = a cut on the beat). */ /** Shots: id, length (s), the transition into it (s; 0 = a hard cut on the beat). */
const LENGTHS = [ const LENGTHS = [
['game', 5, 0], ['pile', 6, 0.6], ['reject', 4, 0.5], ['ask', 4, 0.8], ['game', 3, 0], ['pile', 4, 0], ['reject', 2.5, 0], ['ask', 2.5, 0],
['builds', 4, 0.1], ['shots', 4, 0.1], ['products', 3.5, 0.1], ['forms', 3.5, 0.1], ['consoles', 3, 0.1], ['builds', 3, 0], ['shots', 3, 0], ['products', 3, 0], ['forms', 3, 0], ['consoles', 3, 0],
['scan', 5, 0.1], ['button', 6, 0.5], ['waiting', 5, 0.1], ['end', 5, 0.8], ['scan', 4, 0], ['button', 2, 0], ['waiting', 3, 0], ['end', 5, 0],
]; ];
export const SCENES = LENGTHS.reduce((a, [id, len, x]) => { const start = a.at(-1)?.end ?? 0; return [...a, { id, start, end: start + len, x }]; }, []); export const SCENES = LENGTHS.reduce((a, [id, len, x]) => { const start = a.at(-1)?.end ?? 0; return [...a, { id, start, end: start + len, x }]; }, []);
export const DURATION = SCENES.at(-1).end; export const DURATION = SCENES.at(-1).end;
export const scene = id => SCENES.find(s => s.id === id); export const scene = id => SCENES.find(s => s.id === id);
/** The montage: one word or short phrase at the edge, cuts on the beat. */ /** The montage: one word or short phrase at the edge, cuts on the beat. */
export const MONTAGE = ['builds', 'shots', 'products', 'forms', 'consoles']; export const MONTAGE = ['builds', 'shots', 'products', 'forms', 'consoles'];
/** Why each shot cuts where it does (the shot list, render.ts --animatic). */
/** One camera move per scene: from -> to (z = zoom, x / y = pan in px), a slow ease (story) or a quick push (montage). */ export const SHOT_NOTES = {
export const CAMERA = { game: 'the demo game\'s four real screens rush past, push in; cut as the push lands',
game: { from: { z: 1.0, x: 40, y: 10 }, to: { z: 1.07, x: -40, y: -6 } }, pile: 'App Privacy, Data safety CSV, screenshot sizes, prices: one real card per beat; cut when the wall is full',
pile: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.12, x: 0, y: 10 } }, reject: 'the real BLOCKER 5.1.1(v) finding slams in, push onto it, the picture darkens; cut when it fills the frame',
reject: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, ask: 'one request typed to the AI agent; cut on the send',
ask: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.04, x: 0, y: 0 } }, builds: '`apple build --dry`, `play build --dry`: build 52 / versionCode 52; cut on "build planned"',
builds: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.06, x: -20, y: -10 } }, shots: 'the demo game\'s renders: 7 store sizes x 4 languages fill in column by column; cut when the grid is full',
shots: { from: { z: 1.04, x: 30, y: 0 }, to: { z: 1.0, x: -30, y: 0 } }, products: '`play products --dry`, `catalog diff`: 175 region prices, 0 mismatches; cut on the 0',
products: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.06, x: 20, y: -10 } }, forms: 'scan\'s declarations proposal ticks the Data safety rows, the count runs to 768; cut when it lands',
forms: { from: { z: 1.04, x: -20, y: 0 }, to: { z: 1.0, x: 20, y: 0 } }, consoles: '`console plan apple-app-privacy`, then the app\'s plan panel, the owner\'s Publish ringed; cut on the ring',
consoles: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, scan: '`storebound scan`: 5 findings turn green one per beat, the clean re-scan; cut on 0 blockers',
scan: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: -10 } }, button: 'the desktop app\'s Send for review, the camera pushes onto Submit, the pointer arrives; cut on arrival',
button: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, waiting: 'the click, then the app\'s own "Waiting for review", ringed; cut when it settles',
waiting: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, end: 'logo, `npx storebound init` typed out big, storebound.dev; holds 4 s (caret blink, slow push)',
end: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.03, x: 0, y: 0 } },
}; };
/** Captions: at most one short line per scene. top = the story line, big and centred at the top; edge = the montage /** One camera move per shot: from -> to (z = zoom, x / y = pan in px), a quick push that settles at 80 % of the shot. */
* word, bottom left. at / until: seconds into the scene (fade in from `at`, gone at `until`). *word* = highlighted. */ export const CAMERA = {
game: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.14, x: 0, y: 30 } },
pile: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 0, y: 10 } },
reject: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
ask: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.12, x: -60, y: 0 } },
builds: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 40, y: -40 } },
shots: { from: { z: 0.98, x: 0, y: 0 }, to: { z: 1.08, x: 0, y: 0 } },
products: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 60, y: -50 } },
forms: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.07, x: 30, y: 0 } },
consoles: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
scan: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.08, x: 0, y: -20 } },
button: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
waiting: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
end: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: 0 } },
};
/** Captions: at most six words, each fully on screen for at least 2 s. top = the story line, big and centred at the
* top; edge = the montage word, bottom left. at / until: absolute seconds. *word* = highlighted. */
export const CAPTIONS = [ export const CAPTIONS = [
{ id: 'game', pos: 'top', text: 'The game is *done.*', at: 0.6, until: 4.85 }, { id: 'game', pos: 'top', text: 'Your game is *done.*', at: 0.1, until: 2.95 },
{ id: 'pile', pos: 'top', text: 'Then come *the stores.*', at: 0.5, until: 5.9 }, { id: 'pile', pos: 'top', text: 'Then come *the stores.*', at: 3.05, until: 6.95 },
{ id: 'reject', pos: 'top', text: 'One missing line, and you *start over.*', at: 0.35, until: 3.95, hl: 'red' }, { id: 'reject', pos: 'top', text: 'One line. *Rejected.*', at: 7.05, until: 9.45, hl: 'red' },
{ id: 'ask', pos: 'top', text: 'So I handed it all *to my AI.*', at: 0.35, until: 3.95 }, { id: 'ask', pos: 'top', text: 'So my AI *did it.*', at: 9.55, until: 11.95 },
{ id: 'builds', pos: 'edge', text: 'Builds.', at: 0.05, until: 3.95 }, { id: 'builds', pos: 'edge', text: 'Builds.', at: 12.05, until: 14.95 },
{ id: 'shots', pos: 'edge', text: 'Screenshots, every size.', at: 0.05, until: 3.95 }, { id: 'shots', pos: 'edge', text: 'Screenshots, every size.', at: 15.05, until: 17.95 },
{ id: 'products', pos: 'edge', text: 'Products, 175 countries.', at: 0.05, until: 3.45 }, { id: 'products', pos: 'edge', text: 'Products, 175 countries.', at: 18.05, until: 20.95 },
{ id: 'forms', pos: 'edge', text: 'Forms.', at: 0.05, until: 3.45 }, { id: 'forms', pos: 'edge', text: 'Forms.', at: 21.05, until: 23.95 },
{ id: 'consoles', pos: 'edge', text: 'Consoles.', at: 0.05, until: 2.95 }, { id: 'consoles', pos: 'edge', text: 'Consoles.', at: 24.05, until: 26.95 },
{ id: 'scan', pos: 'top', text: 'Nothing left for a reviewer *to catch.*', at: 1.5, until: 4.95, hl: 'green' }, { id: 'scan', pos: 'top', text: 'Nothing left to *reject.*', at: 28.6, until: 30.95, hl: 'green' },
{ id: 'button', pos: 'top', text: 'One button is *yours.*', at: 0.9, until: 5.9 }, { id: 'button', pos: 'top', text: 'You press *Submit.*', at: 31.1, until: 35.95 },
]; ];
/** Fades (s): the story lines slow and soft, the montage words quick. */ /** Fades (s): quick, a cut is a cut. */
export const CAP_FADE = { top: [0.5, 0.4], edge: [0.15, 0.15] }; export const CAP_FADE = { top: [0.12, 0.08], edge: [0.1, 0.08] };
/** When each caption is fully on screen (tests: at least 2.5 s, inside its scene). */ /** When each caption is fully on screen. */
export function captionTimes() { export function captionTimes() {
return CAPTIONS.map(c => { return CAPTIONS.map(c => {
const s = scene(c.id), [fin, fout] = CAP_FADE[c.pos]; const [fin, fout] = CAP_FADE[c.pos];
const at = s.start + c.at, from = at + fin, to = s.start + c.until - fout, gone = s.start + c.until; const from = c.at + fin, to = c.until - fout;
return { id: c.id, text: c.text.replace(/\*/g, ''), pos: c.pos, at, from, to, gone, hold: to - from }; return { id: c.id, text: c.text.replace(/\*/g, ''), pos: c.pos, at: c.at, from, to, gone: c.until, hold: to - from };
}); });
} }
/** The dark veil over the picture (0-1) at time t: it falls over the rejection, holds through the request, lifts on /** The dark veil over the picture (0-1) at time t: it falls with the rejection, holds through the request, gone on the
* the first beat of the montage. */ * first beat of the montage. */
export function veil(t) { export function veil(t) {
const r = scene('reject'), a = scene('ask'); const r = scene('reject'), a = scene('ask');
if (t < r.start + 0.4) return 0; if (t < r.start + 0.15) return 0;
if (t < r.end) return 0.72 * Math.min(1, (t - r.start - 0.4) / 3.2); if (t < r.end) return 0.7 * Math.min(1, (t - r.start - 0.15) / 1.6);
if (t < a.end - 0.05) return 0.72 - 0.12 * Math.min(1, (t - a.start) / 1.5); if (t < a.end) return 0.6;
return 0; return 0;
} }
// ---------- 1 game: the demo game's real screens ---------- // ---------- 1 game: the demo game's real screens ----------
/** examples/demo-game/store/shots/frames/en/phone/<id>.png (the game's own captures), left to right. */ /** examples/demo-game/store/shots/frames/en/phone/<id>.png (the game's own captures), left to right. */
export const GAME_SCREENS = ['play', 'combo', 'map', 'stars']; export const GAME_SCREENS = ['play', 'combo', 'map', 'stars'];
export const GAME_AT = 0.15, GAME_STEP = 0.22; /** The cold open: the screens rush past (px/s) while the camera pushes in; already moving on frame 0. */
export const GAME_AT = 0, GAME_STEP = 0, GAME_SPEED = 520;
// ---------- 2 pile: the store work piles up ---------- // ---------- 2 pile: the store work piles up ----------
/** Real lines (see the header). */ /** Real lines (see the header). */
@ -157,16 +174,15 @@ export const PRICE_CMD = 'prices ppp --offline --dry --store apple --only com.ex
/** The cards that drop onto the pile, faster and faster. at = seconds into the scene; x / y = the card's centre /** The cards that drop onto the pile, faster and faster. at = seconds into the scene; x / y = the card's centre
* (px), w = width, rot = degrees. kind: what it shows. */ * (px), w = width, rot = degrees. kind: what it shows. */
export const PILE = [ export const PILE = [
{ at: 0.25, kind: 'privacy', x: 700, y: 560, w: 1000, rot: -3 }, { at: 0.0, kind: 'privacy', x: 640, y: 540, w: 1000, rot: -3 },
{ at: 1.25, kind: 'csv', x: 1220, y: 600, w: 980, rot: 2.5 }, { at: 0.5, kind: 'csv', x: 1260, y: 600, w: 980, rot: 2.5 },
{ at: 2.15, kind: 'shots', x: 860, y: 640, w: 900, rot: -1.5 }, { at: 1.0, kind: 'shots', x: 820, y: 680, w: 900, rot: -1.5 },
{ at: 2.95, kind: 'prices', x: 1180, y: 560, w: 760, rot: 4 }, { at: 1.5, kind: 'prices', x: 1240, y: 520, w: 760, rot: 4 },
{ at: 3.6, kind: 'steps', x: 760, y: 690, w: 1040, rot: 2 }, { at: 2.0, kind: 'steps', x: 760, y: 700, w: 1040, rot: 2 },
{ at: 4.15, kind: 'csv', x: 1060, y: 610, w: 1000, rot: -4.5, from: 7 }, { at: 2.5, kind: 'csv', x: 1100, y: 620, w: 1000, rot: -4.5, from: 7 },
{ at: 4.6, kind: 'shots', x: 940, y: 560, w: 940, rot: 3.5, lang: 'ja' }, { at: 3.0, kind: 'shots', x: 940, y: 560, w: 940, rot: 3.5, lang: 'ja' },
{ at: 5.0, kind: 'prices', x: 820, y: 640, w: 800, rot: -2.5, from: 11 }, { at: 3.5, kind: 'prices', x: 980, y: 640, w: 820, rot: -2.5, from: 11 },
{ at: 5.35, kind: 'privacy', x: 1040, y: 600, w: 1060, rot: 1, from: 6 }, ]
];
/** The card headers (what each card is; no claims). */ /** The card headers (what each card is; no claims). */
export const PILE_TITLES = { privacy: 'App Privacy · answer sheet', steps: 'App Privacy · console steps', csv: `Data safety · ${DECL_TOTAL} rows`, shots: 'Screenshots', prices: 'Prices · every region' }; export const PILE_TITLES = { privacy: 'App Privacy · answer sheet', steps: 'App Privacy · console steps', csv: `Data safety · ${DECL_TOTAL} rows`, shots: 'Screenshots', prices: 'Prices · every region' };
@ -176,13 +192,14 @@ export const REJECT = {
head: 'BLOCKER 5.1.1(v) · App Store · Accounts can be created but no in-app account deletion was found', head: 'BLOCKER 5.1.1(v) · App Store · Accounts can be created but no in-app account deletion was found',
at: 'at Planner/LoginView.swift:11 Button("Create account") { Task { try await AccountApi.signUp() } }', at: 'at Planner/LoginView.swift:11 Button("Create account") { Task { try await AccountApi.signUp() } }',
}; };
export const REJECT_GROW = [0.6, 1.12]; /** The slam (scale from -> 1 in `slam` s), then a push onto the rule number. */
export const REJECT_GROW = [1.0, 1.28], REJECT_SLAM = { from: 1.7, d: 0.16 };
// ---------- 4 ask: the request to the agent ---------- // ---------- 4 ask: the request to the agent ----------
export const PROMPT = 'Get this game ready for review on both stores.'; export const PROMPT = 'Get this game ready for review on both stores.';
export const PROMPT_AT = 0.7, PROMPT_CPS = 24; export const PROMPT_AT = 0.1, PROMPT_CPS = 31;
/** Enter: the request is sent (a soft accent), seconds into the scene. */ /** Enter: the request is sent (a soft accent), seconds into the scene. */
export const PROMPT_SENT = 3.05; export const PROMPT_SENT = 2.0;
// ---------- 5 montage ---------- // ---------- 5 montage ----------
/** builds: real lines of `apple build --dry` and `play build --dry` (clock prefix and "(0s)" left out). */ /** builds: real lines of `apple build --dry` and `play build --dry` (clock prefix and "(0s)" left out). */
@ -202,18 +219,18 @@ export const SHOT_SIZES = [
{ id: 'play', label: 'Play phone' }, { id: 'tab7', label: 'Play 7″' }, { id: 'tab10', label: 'Play 10″' }, { id: 'feature', label: 'Feature graphic' }, { id: 'play', label: 'Play phone' }, { id: 'tab7', label: 'Play 7″' }, { id: 'tab10', label: 'Play 10″' }, { id: 'feature', label: 'Feature graphic' },
]; ];
export const SHOT_LANGS = ['en', 'tr', 'ja', 'ar']; export const SHOT_LANGS = ['en', 'tr', 'ja', 'ar'];
export const SHOTS_AT = 0.1, SHOTS_STEP = 0.2; export const SHOTS_AT = 0.05, SHOTS_STEP = 0.3, SHOTS_ROW = 0.1;
/** products: real output (clock prefix and the "(0s)" timer left out). */ /** products: real output (clock prefix and the "(0s)" timer left out). */
export const PRODUCT_LINES = [ export const PRODUCT_LINES = [
{ at: 0.05, text: '$ storebound play products --dry', cmd: true }, { at: 0.07, text: '$ storebound play products --dry', cmd: true },
{ at: 0.3, text: '[play] 1/2 com.example.app.no_ads: would create · 174 regions · 2 listings (dry)' }, { at: 0.41, text: '[play] 1/2 com.example.app.no_ads: would create · 174 regions · 2 listings (dry)' },
{ at: 0.48, text: '[play] 2/2 com.example.app.gems_100: would create · 174 regions · 2 listings (dry)' }, { at: 0.65, text: '[play] 2/2 com.example.app.gems_100: would create · 174 regions · 2 listings (dry)' },
{ at: 0.8, text: '$ storebound catalog diff', cmd: true }, { at: 1.08, text: '$ storebound catalog diff', cmd: true },
{ at: 1.05, text: 'ok [apple] com.example.app.gems_100: consumable, 175 region prices, READY_TO_SUBMIT' }, { at: 1.42, text: 'ok [apple] com.example.app.gems_100: consumable, 175 region prices, READY_TO_SUBMIT' },
{ at: 1.2, text: 'ok [play] com.example.app.gems_100: consumable, 174 region prices, ACTIVE' }, { at: 1.62, text: 'ok [play] com.example.app.gems_100: consumable, 174 region prices, ACTIVE' },
{ at: 1.35, text: 'ok [apple] com.example.app.no_ads: non_consumable, 175 region prices, READY_TO_SUBMIT' }, { at: 1.82, text: 'ok [apple] com.example.app.no_ads: non_consumable, 175 region prices, READY_TO_SUBMIT' },
{ at: 1.5, text: 'ok [play] com.example.app.no_ads: non_consumable, 174 region prices, ACTIVE' }, { at: 2.03, text: 'ok [play] com.example.app.no_ads: non_consumable, 174 region prices, ACTIVE' },
{ at: 1.75, text: '[catalog] 2 catalog items: 0 mismatch(es), 0 warning(s), 0 price difference(s) between the stores' }, { at: 2.36, text: '[catalog] 2 catalog items: 0 mismatch(es), 0 warning(s), 0 price difference(s) between the stores' },
]; ];
/** forms: the declarations proposal (scan of the teaser fixture), then the Data safety rows it fills. */ /** forms: the declarations proposal (scan of the teaser fixture), then the Data safety rows it fills. */
export const DECL_LINES = [ export const DECL_LINES = [
@ -253,10 +270,10 @@ export const FINDINGS = [
'HIGH 3.1.1 · App Store · Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found', 'HIGH 3.1.1 · App Store · Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found',
'HIGH 5.1.2(i) · App Store · Tracking-capable code without App Tracking Transparency (Google AdMob, IDFA)', 'HIGH 5.1.2(i) · App Store · Tracking-capable code without App Tracking Transparency (Google AdMob, IDFA)',
]; ];
export const FINDINGS_AT = 0.4, FINDINGS_STEP = 0.12; export const FINDINGS_AT = 0.2, FINDINGS_STEP = 0.06;
/** Each finding turns green (fixed) on a beat, then the re-scan of the fixed copy (tests/fixtures/scan/teaser-fixed). */ /** Each finding turns green (fixed) on a beat, then the re-scan of the fixed copy (tests/fixtures/scan/teaser-fixed). */
export const FIX_AT = [1.0, 1.5, 2.0, 2.5, 3.0]; export const FIX_AT = [0.5, 1.0, 1.5, 2.0, 2.5];
export const RESCAN_AT = 3.45; export const RESCAN_AT = 3.0;
export const RESCAN_LINE = 'Rejection risks: 0 blocker, 0 high, 0 medium, 0 low'; export const RESCAN_LINE = 'Rejection risks: 0 blocker, 0 high, 0 medium, 0 low';
// ---------- desktop: real frames of the app in fixture mode (desktop.ts) ---------- // ---------- desktop: real frames of the app in fixture mode (desktop.ts) ----------
@ -269,21 +286,21 @@ export const DESK = {
groups: [{ frame: 'console-plan' }], groups: [{ frame: 'console-plan' }],
show: CONSOLES_SWAP, show: CONSOLES_SWAP,
cam: { from: { f: [0.5, 0.56], z: 1.12 }, to: { f: [0.5, 0.58], z: 1.22 } }, cam: { from: { f: [0.5, 0.56], z: 1.12 }, to: { f: [0.5, 0.58], z: 1.22 } },
rings: [{ at: 1.6, until: 3.2, target: 'cpFinal', green: true }], rings: [{ at: 1.5, until: 3.4, target: 'cpFinal', green: true }],
}, },
// Releases > Send for review: build 52 picked, the one button left; the camera settles on it, the pointer rests // Releases > Send for review: build 52 picked, the one button left; the camera settles on it, the pointer rests
button: { button: {
groups: [{ frame: 'submit-ready' }], groups: [{ frame: 'submit-ready' }],
cam: { from: { f: [0.45, 0.5], z: 1.0 }, to: { f: [0.3, 0.68], z: 1.55 } }, cam: { from: { f: [0.45, 0.5], z: 1.0 }, to: { f: [0.3, 0.68], z: 1.55 } },
rings: [{ at: 3.2, until: 6.3, target: 'submitBtn' }], rings: [{ at: 1.3, until: 2.6, target: 'submitBtn' }],
pointer: { at: 1.9, d: 1.1, target: 'submitBtn' }, pointer: { at: 0.4, d: 0.9, target: 'submitBtn' },
}, },
// the press, then the app's own "Waiting for review" state // the press, then the app's own "Waiting for review" state
waiting: { waiting: {
groups: [{ frame: 'submit-ready' }, { frame: 'submit-waiting' }], groups: [{ frame: 'submit-ready' }, { frame: 'submit-waiting' }],
swap: [0.45, 0.9], swap: [0.45, 0.9],
cam: { from: { f: [0.3, 0.68], z: 1.55 }, to: { f: [0.4, 0.4], z: 1.3 } }, cam: { from: { f: [0.3, 0.68], z: 1.55 }, to: { f: [0.4, 0.4], z: 1.3 } },
rings: [{ at: 1.2, until: 5.3, target: 'waitingItem', green: true }], rings: [{ at: 1.0, until: 3.4, target: 'waitingItem', green: true }],
pointer: { at: -1, d: 0.01, target: 'submitBtn', click: 0.2, until: 0.6 }, pointer: { at: -1, d: 0.01, target: 'submitBtn', click: 0.2, until: 0.6 },
}, },
}; };
@ -293,11 +310,13 @@ export const DESK_FRAMES = [...new Set(Object.values(DESK).flatMap(d => d.groups
/** The click on Submit (absolute seconds): the music's resolve starts here. */ /** The click on Submit (absolute seconds): the music's resolve starts here. */
export const CLICK_T = scene('waiting').start + DESK.waiting.pointer.click; export const CLICK_T = scene('waiting').start + DESK.waiting.pointer.click;
// ---------- 9 end card ---------- // ---------- 9 end card: the last thing on screen ----------
/** Seconds into the end scene at which each line comes in. */ /** Seconds into the end shot: the logo and word slam in, the install line is typed, then the site. Everything is on
export const END = { markAt: 0.25, wordAt: 0.5, lineAt: 0.9, urlAt: 1.25, d: 0.6 }; * screen by `full`; the rest (at least 4 s) holds with a blinking caret and a slow push. */
export const END_LINE = 'From an empty folder to Submit.'; export const END_CMD = 'npx storebound init';
export const END_URL = 'Open source · storebound.dev'; export const END_URL = 'storebound.dev';
export const END = { markAt: 0, wordAt: 0.06, cmdAt: 0.15, cps: 40, urlAt: 0.7, d: 0.2 };
END.full = Math.max(END.cmdAt + END_CMD.length / END.cps, END.urlAt + END.d);
// ---------- sound cues (music.js) ---------- // ---------- sound cues (music.js) ----------
/** Cuts with a sound accent (every scene change but the seamless button -> waiting one). */ /** Cuts with a sound accent (every scene change but the seamless button -> waiting one). */
@ -321,5 +340,6 @@ export function ticks() {
export function keyTimes() { export function keyTimes() {
const out = []; const out = [];
for (let i = 0; i < PROMPT.length; i++) if (PROMPT[i] !== ' ' || i % 2) out.push(scene('ask').start + PROMPT_AT + i / PROMPT_CPS); for (let i = 0; i < PROMPT.length; i++) if (PROMPT[i] !== ' ' || i % 2) out.push(scene('ask').start + PROMPT_AT + i / PROMPT_CPS);
for (let i = 0; i < END_CMD.length; i++) out.push(scene('end').start + END.cmdAt + i / END.cps);
return out; return out;
} }

View file

@ -1,10 +1,10 @@
// Teaser picture (v8, a story): every element is set from the time t alone (window.teaser.seek(t)), so frames render // Teaser picture (v9, the story cut tight): every element is set from the time t alone (window.teaser.seek(t)), so frames render
// deterministically, in any order. 16:9 only (1920x1080). URL: ?t=12.5 holds a frame, ?play=1 previews in real time. // deterministically, in any order. 16:9 only (1920x1080). URL: ?t=12.5 holds a frame, ?play=1 previews in real time.
import { ASK_AT, BUILD_LINES, CAMERA, CAP_FADE, CAPTIONS, captionTimes, CONSOLE_CMD, CONSOLE_LINES, CONSOLES_SWAP, import { ASK_AT, BUILD_LINES, CAMERA, CAP_FADE, CAPTIONS, captionTimes, CONSOLE_CMD, CONSOLE_LINES, CONSOLES_SWAP,
DATA_SAFETY_CSV, DATA_SAFETY_ROWS, DECL_ASK, DECL_AT, DECL_LINES, DECL_STEP, DECL_TOTAL, DESK, DESK_SCENES, DURATION, END, END_LINE, END_URL, DATA_SAFETY_CSV, DATA_SAFETY_ROWS, DECL_ASK, DECL_AT, DECL_LINES, DECL_STEP, DECL_TOTAL, DESK, DESK_SCENES, DURATION, END, END_CMD, END_URL,
FINDINGS, FINDINGS_AT, FINDINGS_STEP, FIX_AT, FORM_AT, FORM_STEP, FORMAT, FORMS_COUNT_AT, FPS, GAME_AT, GAME_SCREENS, GAME_STEP, MONTAGE, FINDINGS, FINDINGS_AT, FINDINGS_STEP, FIX_AT, FORM_AT, FORM_STEP, FORMAT, FORMS_COUNT_AT, FPS, GAME_SCREENS, GAME_SPEED, MONTAGE,
PILE, PILE_TITLES, PRICE_ROWS, PRIVACY_ANSWERS, PRIVACY_STEPS, PRODUCT_LINES, PROMPT, PROMPT_AT, PROMPT_CPS, PROMPT_SENT, PILE, PILE_TITLES, PRICE_ROWS, PRIVACY_ANSWERS, PRIVACY_STEPS, PRODUCT_LINES, PROMPT, PROMPT_AT, PROMPT_CPS, PROMPT_SENT,
REJECT, REJECT_GROW, RESCAN_AT, RESCAN_LINE, RISKS_LINE, SCAN_CMD, SCENES, SHOT_LANGS, SHOT_SIZES, SHOTS_AT, SHOTS_STEP, scene, veil } from './script.js'; REJECT, REJECT_GROW, REJECT_SLAM, RESCAN_AT, RESCAN_LINE, RISKS_LINE, SCAN_CMD, SCENES, SHOT_LANGS, SHOT_SIZES, SHOTS_AT, SHOTS_ROW, SHOTS_STEP, scene, veil } from './script.js';
const q = new URLSearchParams(location.search); const q = new URLSearchParams(location.search);
const W = FORMAT.w, H = FORMAT.h; const W = FORMAT.w, H = FORMAT.h;
@ -23,6 +23,8 @@ const ecam = p => 1 - Math.pow(1 - clamp(p / 0.8), 4);
const esin = p => 0.5 - Math.cos(Math.PI * clamp(p)) / 2; const esin = p => 0.5 - Math.cos(Math.PI * clamp(p)) / 2;
const lerp = (a, b, p) => a + (b - a) * p; const lerp = (a, b, p) => a + (b - a) * p;
const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
/** Every shot's first frame already shows its subject: things that open a shot start this much before the cut. */
const LEAD = 0.15;
const set = (el, k, v) => { if (el.__s?.[k] !== v) { (el.__s ??= {})[k] = v; el.style[k] = v; } }; const set = (el, k, v) => { if (el.__s?.[k] !== v) { (el.__s ??= {})[k] = v; el.style[k] = v; } };
const html = (el, v) => { if (el.__h !== v) { el.__h = v; el.innerHTML = v; } }; const html = (el, v) => { if (el.__h !== v) { el.__h = v; el.innerHTML = v; } };
const hl = s => esc(s).replace(/\*([^*]+)\*/g, '<span class="hl">$1</span>'); const hl = s => esc(s).replace(/\*([^*]+)\*/g, '<span class="hl">$1</span>');
@ -44,11 +46,11 @@ function scenes(t) {
const from = i === 0 ? -1 : s.start - s.x / 2, until = next ? next.start + next.x / 2 : Infinity; const from = i === 0 ? -1 : s.start - s.x / 2, until = next ? next.start + next.x / 2 : Infinity;
if (t < from || t >= until) { set(el, 'visibility', 'hidden'); set(el, 'opacity', '0'); return; } if (t < from || t >= until) { set(el, 'visibility', 'hidden'); set(el, 'opacity', '0'); return; }
set(el, 'visibility', 'visible'); set(el, 'visibility', 'visible');
// the first scene comes up from black; the others dissolve in over the previous one // hard cuts on the beat: the first frame is already the product, no fade from black
set(el, 'opacity', String(i === 0 ? eio(prog(t, 0, 0.9)) : eio(prog(t, from, s.x)))); set(el, 'opacity', String(i === 0 ? 1 : eio(prog(t, from, s.x))));
const c = CAMERA[s.id]; const c = CAMERA[s.id];
const raw = (t - s.start) / (s.end - s.start); const raw = (t - s.start) / (s.end - s.start);
const p = MONTAGE.includes(s.id) ? ecam(raw) : esin(raw); const p = ecam(raw);
const z = lerp(c.from.z, c.to.z, p), x = lerp(c.from.x, c.to.x, p), y = lerp(c.from.y, c.to.y, p); const z = lerp(c.from.z, c.to.z, p), x = lerp(c.from.x, c.to.x, p), y = lerp(c.from.y, c.to.y, p);
set(cam, 'transform', `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) scale(${z.toFixed(5)})`); set(cam, 'transform', `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) scale(${z.toFixed(5)})`);
}); });
@ -67,20 +69,21 @@ function background(t) {
// ---------- captions: the story line (top) and the montage word (edge) ---------- // ---------- captions: the story line (top) and the montage word (edge) ----------
const capEl = $('#cap'), capBand = $('#capband'), edgeEl = $('#edge'), edgeBand = $('#edgeband'); const capEl = $('#cap'), capBand = $('#capband'), edgeEl = $('#edge'), edgeBand = $('#edgeband');
const CT = Object.fromEntries(captionTimes().map(c => [c.id, c])); const CT = Object.fromEntries(captionTimes().map(c => [c.id, c]));
let capId = null; let capId = null, edgeId = null;
function captions(t) { function captions(t) {
const s = active(t), c = CAPTIONS.find(x => x.id === s.id);
let top = 0, edge = 0, rise = 0; let top = 0, edge = 0, rise = 0;
if (c) { for (const c of CAPTIONS) {
if (t < c.at || t >= c.until) continue;
const ct = CT[c.id], [fin, fout] = CAP_FADE[c.pos]; const ct = CT[c.id], [fin, fout] = CAP_FADE[c.pos];
const pin = eoc(prog(t, ct.at, fin)), pout = prog(t, ct.to, fout); const pin = eoc(prog(t, ct.at, fin)), pout = prog(t, ct.to, fout);
const op = pin * (1 - pout); const op = pin * (1 - pout);
rise = (1 - pin) * 14 - pout * 8; if (c.pos === 'top') {
if (capId !== c.id) { top = op; rise = (1 - pin) * 14;
capId = c.id; if (capId !== c.id) { capId = c.id; capEl.className = c.hl ?? ''; capEl.innerHTML = hl(c.text); }
if (c.pos === 'top') { capEl.className = c.hl ?? ''; capEl.innerHTML = hl(c.text); } else edgeEl.innerHTML = hl(c.text); } else {
edge = op;
if (edgeId !== c.id) { edgeId = c.id; edgeEl.innerHTML = hl(c.text); }
} }
if (c.pos === 'top') top = op; else edge = op;
} }
set(capEl, 'opacity', String(top)); set(capBand, 'opacity', String(top)); set(capEl, 'opacity', String(top)); set(capBand, 'opacity', String(top));
set(capEl, 'transform', `translateY(${rise.toFixed(1)}px)`); set(capEl, 'transform', `translateY(${rise.toFixed(1)}px)`);
@ -90,22 +93,24 @@ function captions(t) {
// ---------- 1 game: the game's own screens ---------- // ---------- 1 game: the game's own screens ----------
const gameEl = $('#game'); const gameEl = $('#game');
const phones = GAME_SCREENS.map((id, i) => { const PHONE_GAP = 400, PHONES = [...GAME_SCREENS, ...GAME_SCREENS, ...GAME_SCREENS];
const phones = PHONES.map(id => {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'phone'; el.className = 'phone';
Object.assign(el.style, { left: `${195 + i * 400}px`, backgroundImage: `url(out/assets/game/${id}.jpg)` }); el.style.backgroundImage = `url(out/assets/game/${id}.jpg)`;
gameEl.appendChild(el); gameEl.appendChild(el);
return el; return el;
}); });
function game(t) { function game(t) {
const s = scene('game'); if (t > s.end + 1) return; const s = scene('game'); if (t > s.end + 1) return;
const r = t - s.start; const r = t - s.start;
// the screens rush past from frame 0 (no fade-in): a conveyor, each on its own bob
phones.forEach((el, i) => { phones.forEach((el, i) => {
const p = prog(r, GAME_AT + i * GAME_STEP, 0.9); const x = 160 + i * PHONE_GAP - GAME_SPEED * r;
set(el, 'opacity', String(eoc(p))); const fy = Math.sin(r * 3 + i * 1.3) * 10;
// a slow float, each phone on its own phase set(el, 'opacity', '1');
const fy = Math.sin(r * 0.9 + i * 1.3) * 6; set(el, 'left', `${x.toFixed(1)}px`);
set(el, 'transform', `translateY(${((1 - eoc(p)) * 40 + fy).toFixed(1)}px) rotate(${((i - 1.5) * 1.2).toFixed(2)}deg)`); set(el, 'transform', `translateY(${fy.toFixed(1)}px) rotate(${(((i % 4) - 1.5) * 1.6).toFixed(2)}deg)`);
}); });
} }
@ -153,14 +158,14 @@ function pile(t) {
const s = scene('pile'); if (t < s.start - 1 || t > s.end + 1) return; const s = scene('pile'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start; const r = t - s.start;
pileCards.forEach(({ c, el, rows, shade, maxScroll }, i) => { pileCards.forEach(({ c, el, rows, shade, maxScroll }, i) => {
const p = prog(r, c.at, 0.32); const p = prog(r, c.at - LEAD, 0.2);
set(el, 'opacity', String(clamp(p * 2.5))); set(el, 'opacity', String(clamp(p * 3)));
const e = eoc(p); const e = eoc(p);
set(el, 'transform', `translateY(${lerp(-70, 0, e).toFixed(1)}px) rotate(${lerp(c.rot * 2.2, c.rot, e).toFixed(2)}deg) scale(${lerp(1.22, 1, e).toFixed(4)})`); set(el, 'transform', `translateY(${lerp(-70, 0, e).toFixed(1)}px) rotate(${lerp(c.rot * 2.2, c.rot, e).toFixed(2)}deg) scale(${lerp(1.22, 1, e).toFixed(4)})`);
// the rows keep streaming: there is always more // the rows keep streaming: there is always more
if (rows) set(rows, 'transform', `translateY(${(-Math.min(maxScroll, Math.max(0, r - c.at) * 34)).toFixed(1)}px)`); if (rows) set(rows, 'transform', `translateY(${(-Math.min(maxScroll, Math.max(0, r - c.at) * 34)).toFixed(1)}px)`);
// older cards sink into the shadow of the new ones // older cards sink into the shadow of the new ones
const above = PILE.slice(i + 1).reduce((k, x) => k + eoc(prog(r, x.at, 0.32)), 0); const above = PILE.slice(i + 1).reduce((k, x) => k + eoc(prog(r, x.at - LEAD, 0.2)), 0);
set(shade, 'opacity', String(Math.min(0.6, above * 0.16).toFixed(3))); set(shade, 'opacity', String(Math.min(0.6, above * 0.16).toFixed(3)));
}); });
} }
@ -175,9 +180,12 @@ const findEl = $('#finding');
function reject(t) { function reject(t) {
const s = scene('reject'); if (t < s.start - 1 || t > s.end + 1) return; const s = scene('reject'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start; const r = t - s.start;
set(findEl, 'opacity', String(eoc(prog(r, -0.1, 0.45)))); // the slam: in at once, overshooting scale down to 1 with a short shake; then a push onto the rule
const z = lerp(REJECT_GROW[0], REJECT_GROW[1], esin(prog(r, -0.1, s.end - s.start + 0.3))); const sp = prog(r, -LEAD, REJECT_SLAM.d + LEAD);
set(findEl, 'transform', `translate(-50%, -50%) scale(${z.toFixed(4)})`); set(findEl, 'opacity', String(clamp(sp * 4)));
const z = lerp(REJECT_SLAM.from, 1, eoc(sp)) * lerp(REJECT_GROW[0], REJECT_GROW[1], ecam(prog(r, REJECT_SLAM.d, s.end - s.start - REJECT_SLAM.d)));
const shake = r < 0.45 && r > REJECT_SLAM.d ? Math.sin(r * 90) * 14 * (1 - (r - REJECT_SLAM.d) / (0.45 - REJECT_SLAM.d)) : 0;
set(findEl, 'transform', `translate(calc(-50% + ${shake.toFixed(1)}px), -50%) scale(${z.toFixed(4)})`);
} }
// ---------- 4 ask: the request to the agent ---------- // ---------- 4 ask: the request to the agent ----------
@ -185,7 +193,7 @@ const promptEl = $('#prompt'), promptTxt = $('#prompt .txt'), sentEl = $('#promp
function ask(t) { function ask(t) {
const s = scene('ask'); if (t < s.start - 1 || t > s.end + 1) return; const s = scene('ask'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start; const r = t - s.start;
appear(promptEl, r, 0.15, 0.6, 16); appear(promptEl, r, -0.05, 0.12, 0);
const n = clamp(Math.floor((r - PROMPT_AT) * PROMPT_CPS), 0, PROMPT.length); const n = clamp(Math.floor((r - PROMPT_AT) * PROMPT_CPS), 0, PROMPT.length);
const caret = r < PROMPT_SENT && (n < PROMPT.length || Math.floor(r * 2.2) % 2 === 0); const caret = r < PROMPT_SENT && (n < PROMPT.length || Math.floor(r * 2.2) % 2 === 0);
html(promptTxt, `${esc(PROMPT.slice(0, n))}${caret ? '<span class="caret"></span>' : ''}`); html(promptTxt, `${esc(PROMPT.slice(0, n))}${caret ? '<span class="caret"></span>' : ''}`);
@ -209,7 +217,7 @@ function montageTerm(id, sel, lns, lines) {
const s = scene(id); if (t < s.start - 1 || t > s.end + 1) return; const s = scene(id); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start; const r = t - s.start;
appear($(sel), r, -0.1, 0.2, 10); appear($(sel), r, -0.1, 0.2, 10);
lns.forEach((el, i) => appear(el, r, lines[i].at, 0.18, 6)); lns.forEach((el, i) => appear(el, r, lines[i].at - LEAD, 0.18, 6));
}; };
} }
const builds = montageTerm('builds', '#buildterm', buildLns, BUILD_LINES); const builds = montageTerm('builds', '#buildterm', buildLns, BUILD_LINES);
@ -251,10 +259,10 @@ const shotTiles = [], shotLabels = [], langLabels = [];
function shots(t) { function shots(t) {
const s = scene('shots'); if (t < s.start - 1 || t > s.end + 1) return; const s = scene('shots'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start; const r = t - s.start;
langLabels.forEach(el => appear(el, r, 0, 0.2, 0)); langLabels.forEach(el => appear(el, r, -0.05, 0.1, 0));
shotLabels.forEach(({ el, col }) => set(el, 'opacity', String(eoc(prog(r, SHOTS_AT + col * SHOTS_STEP, 0.25))))); shotLabels.forEach(({ el, col }) => set(el, 'opacity', String(eoc(prog(r, SHOTS_AT - LEAD + col * SHOTS_STEP, 0.2)))));
for (const { el, col, row } of shotTiles) { for (const { el, col, row } of shotTiles) {
const p = prog(r, SHOTS_AT + col * SHOTS_STEP + row * 0.05, 0.28); const p = prog(r, SHOTS_AT - LEAD + col * SHOTS_STEP + row * SHOTS_ROW, 0.22);
set(el, 'opacity', String(eoc(p))); set(el, 'opacity', String(eoc(p)));
set(el, 'transform', `scale(${lerp(0.86, 1, eoc(p)).toFixed(4)})`); set(el, 'transform', `scale(${lerp(0.86, 1, eoc(p)).toFixed(4)})`);
} }
@ -272,7 +280,7 @@ function forms(t) {
const r = t - s.start; const r = t - s.start;
appear($('#prop'), r, -0.1, 0.2, 10); appear($('#prop'), r, -0.1, 0.2, 10);
appear($('#form'), r, 0.0, 0.2, 10); appear($('#form'), r, 0.0, 0.2, 10);
propLns.forEach((el, i) => appear(el, r, i < DECL_LINES.length ? DECL_AT + i * DECL_STEP : ASK_AT, 0.2, 6)); propLns.forEach((el, i) => appear(el, r, i < DECL_LINES.length ? DECL_AT - LEAD + i * DECL_STEP : ASK_AT, 0.2, 6));
set(askGlow, 'opacity', String(eoc(prog(r, ASK_AT + 0.1, 0.3)))); set(askGlow, 'opacity', String(eoc(prog(r, ASK_AT + 0.1, 0.3))));
formRows.forEach((el, i) => { formRows.forEach((el, i) => {
const p = prog(r, FORM_AT + i * FORM_STEP, 0.2); const p = prog(r, FORM_AT + i * FORM_STEP, 0.2);
@ -301,7 +309,7 @@ function consoles(t) {
const r = t - s.start; const r = t - s.start;
const out = 1 - eio(prog(r, CONSOLES_SWAP[0], CONSOLES_SWAP[1])); const out = 1 - eio(prog(r, CONSOLES_SWAP[0], CONSOLES_SWAP[1]));
set(cpTerm, 'opacity', String(eoc(prog(r, -0.1, 0.2)) * out)); set(cpTerm, 'opacity', String(eoc(prog(r, -0.1, 0.2)) * out));
cpLns.forEach((el, i) => appear(el, r, CONSOLE_LINES[i].at, 0.15, 5)); cpLns.forEach((el, i) => appear(el, r, CONSOLE_LINES[i].at - LEAD, 0.15, 5));
set(cpGlow, 'opacity', String(eoc(prog(r, CONSOLE_LINES.at(-1).at + 0.08, 0.2)))); set(cpGlow, 'opacity', String(eoc(prog(r, CONSOLE_LINES.at(-1).at + 0.08, 0.2))));
} }
@ -320,10 +328,10 @@ function scan(t) {
const s = scene('scan'); if (t < s.start - 1 || t > s.end + 1) return; const s = scene('scan'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start; const r = t - s.start;
appear($('#scanterm'), r, -0.1, 0.2, 10); appear($('#scanterm'), r, -0.1, 0.2, 10);
appear(scanLns[0], r, 0.05, 0.15, 4); appear(scanLns[0], r, -LEAD, 0.12, 4);
appear(scanLns[1], r, 0.25, 0.2, 6); appear(scanLns[1], r, 0.05, 0.15, 6);
fdEls.forEach((el, i) => { fdEls.forEach((el, i) => {
appear(el, r, FINDINGS_AT + i * FINDINGS_STEP, 0.25, 8); appear(el, r, FINDINGS_AT - LEAD + i * FINDINGS_STEP, 0.2, 8);
const f = prog(r, FIX_AT[i], 0.3); const f = prog(r, FIX_AT[i], 0.3);
set(el.querySelector('.tint.g'), 'opacity', String(eoc(f))); set(el.querySelector('.tint.g'), 'opacity', String(eoc(f)));
set(el.querySelector('.tint.b'), 'opacity', String(1 - eoc(f))); set(el.querySelector('.tint.b'), 'opacity', String(1 - eoc(f)));
@ -354,7 +362,7 @@ const desks = DESK_SCENES.map(id => {
}); });
function deskView(dk, r) { function deskView(dk, r) {
const s = scene(dk.id), len = s.end - s.start, { WIN } = dk; const s = scene(dk.id), len = s.end - s.start, { WIN } = dk;
const p = dk.id === 'consoles' ? ecam(clamp(r / len)) : esin(clamp(r / len)); const p = ecam(clamp(r / len));
const { from, to } = dk.d.cam; const { from, to } = dk.d.cam;
const S = WIN.s0 * lerp(from.z, to.z, p), fx = lerp(from.f[0], to.f[0], p), fy = lerp(from.f[1], to.f[1], p); const S = WIN.s0 * lerp(from.z, to.z, p), fx = lerp(from.f[0], to.f[0], p), fy = lerp(from.f[1], to.f[1], p);
const ox = clamp(WIN.w / 2 - fx * 1280 * S, WIN.w - 1280 * S, 0), oy = clamp(WIN.vh / 2 - fy * 800 * S, WIN.vh - 800 * S, 0); const ox = clamp(WIN.w / 2 - fx * 1280 * S, WIN.w - 1280 * S, 0), oy = clamp(WIN.vh / 2 - fy * 800 * S, WIN.vh - 800 * S, 0);
@ -394,15 +402,29 @@ function deskScene(dk, t) {
} }
const desktop = t => desks.forEach(dk => deskScene(dk, t)); const desktop = t => desks.forEach(dk => deskScene(dk, t));
// ---------- 9 end card ---------- // ---------- 9 end card: the logo, the install line typed out, the site; the last thing on screen ----------
const brand = $('#brand'); const brand = $('#brand'), endCmd = $('#brand .cmdl .t'), endCaret = $('#brand .cmdl .caret');
brand.querySelector('.line').textContent = END_LINE;
brand.querySelector('.url').textContent = END_URL; brand.querySelector('.url').textContent = END_URL;
const brandEls = [['.mark', END.markAt], ['.word', END.wordAt], ['.line', END.lineAt], ['.url', END.urlAt]].map(([s, at]) => [brand.querySelector(s), at]); const brandEls = [['.mark', END.markAt], ['.word', END.wordAt], ['.cmdl', END.cmdAt - 0.05], ['.url', END.urlAt]].map(([s, at]) => [brand.querySelector(s), at]);
function end(t) { function end(t) {
const s = scene('end'); if (t < s.start - 1) return; const s = scene('end'); if (t < s.start - 1) return;
const r = t - s.start; const r = t - s.start;
for (const [el, at] of brandEls) appear(el, r, at, END.d, 18); for (const [el, at] of brandEls) {
const p = prog(r, at - LEAD, END.d);
set(el, 'opacity', String(clamp(p * 2)));
set(el, 'transform', `scale(${lerp(1.25, 1, eoc(p)).toFixed(4)})`);
}
const n = clamp(Math.floor((r - END.cmdAt) * END.cps), 0, END_CMD.length);
html(endCmd, esc(END_CMD.slice(0, n)));
// the caret: solid while typing, then a steady blink (two per second, on the beat) through the hold
set(endCaret, 'opacity', r < END.full || Math.floor((r - END.full) * 2) % 2 === 0 ? '1' : '0');
}
/** Layout data for the animatic checks: the end card's install line, as drawn at time t. */
function layout(t) {
seek(t);
const sc = document.querySelector('#s-end'), el = $('#brand .cmdl'), b = el.getBoundingClientRect();
return { t, endVisible: getComputedStyle(sc).visibility === 'visible' && Number(sc.style.opacity) > 0.99, text: endCmd.textContent,
opacity: Number(el.style.opacity), box: { x: b.x, y: b.y, w: b.width, h: b.height }, fontPx: parseFloat(getComputedStyle(el).fontSize) };
} }
function seek(t) { function seek(t) {
@ -440,7 +462,7 @@ const ready = (async () => {
for (let i = 0; i < all.length; i += 8) await Promise.all(all.slice(i, i + 8).map(decode)); for (let i = 0; i < all.length; i += 8) await Promise.all(all.slice(i, i + 8).map(decode));
seek(0); seek(0);
})(); })();
window.teaser = { ready, seek, DURATION, FPS, W, H, captionTimes }; window.teaser = { ready, seek, layout, DURATION, FPS, W, H, captionTimes };
if (q.has('t')) ready.then(() => seek(Number(q.get('t')))); if (q.has('t')) ready.then(() => seek(Number(q.get('t'))));
if (q.has('play')) ready.then(() => { if (q.has('play')) ready.then(() => {

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 // The teaser video (media/teaser, v9, the story cut tight): every CLI line it shows is real output of the command it
// screens and screenshot tiles are the demo game's own, every desktop frame is a real capture of the app (desktop.ts), // names, the game screens and screenshot tiles are the demo game's own, every desktop frame is a real capture of the app
// 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 // (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
// 56-62 s, and it is 16:9 only. // 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 { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { generateKeyPairSync } from 'node:crypto'; import { generateKeyPairSync } from 'node:crypto';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; 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 }[]; 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 }[]; captionTimes(): { id: string; text: string; pos: string; at: number; from: number; to: number; gone: number; hold: number }[];
veil(t: number): 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; 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>; PILE: { at: number; kind: string }[]; PILE_TITLES: Record<string, string>;
REJECT: { head: string; at: string }; PROMPT: 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 }[]; 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 } }>; pointer?: { at: number; d: number; target: string; click?: number; until?: number } }>;
DESK_SCENES: string[]; DESK_FRAMES: string[]; CLICK_T: 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; 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).not.toMatch(/1080\s*x\s*1920|w: 1080, h: 1920|vertical/i);
expect(render).toContain('scale=1280:720'); 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.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.MONTAGE).toEqual(['builds', 'shots', 'products', 'forms', 'consoles']);
expect(T.DURATION).toBe(T.SCENES.at(-1)!.end); expect(T.DURATION).toBe(T.SCENES.at(-1)!.end);
expect(T.DURATION).toBeGreaterThanOrEqual(56); expect(T.DURATION).toBeGreaterThanOrEqual(40);
expect(T.DURATION).toBeLessThanOrEqual(62); expect(T.DURATION).toBeLessThanOrEqual(48);
// the montage spans 19-37 s and speeds up (no beat longer than the one before) // the montage: 3 s per beat
const m = T.MONTAGE.map(id => T.SCENES.find(s => s.id === id)!); for (const id of T.MONTAGE) { const s = T.SCENES.find(x => x.id === id)!; expect(s.end - s.start, id).toBe(3); }
expect(m[0]!.start).toBe(19); for (const s of T.SCENES) expect(s.x, s.id).toBe(0);
expect(m.at(-1)!.end).toBe(37); // every shot says why it cuts (the animatic's shot list)
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 T.SCENES) expect(T.SHOT_NOTES[s.id], s.id).toBeTruthy();
for (const s of m) expect(s.x, s.id).toBeLessThanOrEqual(0.15); // cuts, not dissolves
}); });
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'); const music = readFileSync(join(ROOT, 'media/teaser/music.js'), 'utf8');
expect(music).toContain('export const STEP = 0.5;'); expect(music).toContain('export const STEP = 0.5;');
expect(T.SCENES[0]!.start).toBe(0); 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 (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) { for (const s of T.SCENES) {
expect(Number.isInteger(s.start / 0.5), s.id).toBe(true); 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).toBeGreaterThanOrEqual(2);
expect(s.end - s.start, s.id).toBeLessThanOrEqual(6); 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')!; 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); 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(); const c = T.captionTimes();
expect(new Set(c.map(x => x.id)).size).toBe(c.length); 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) { for (const x of c) {
const s = T.SCENES.find(y => y.id === x.id)!; expect(x.hold, x.text).toBeGreaterThanOrEqual(2);
expect(x.hold, x.text).toBeGreaterThanOrEqual(2.5); expect(x.at, x.text).toBeGreaterThanOrEqual(T.SCENES.find(y => y.id === x.id)!.start);
expect(x.at, x.text).toBeGreaterThanOrEqual(s.start); expect(x.gone, x.text).toBeLessThanOrEqual(end.start);
expect(x.gone, x.text).toBeLessThanOrEqual(s.end); expect(x.text.split(' ').length, x.text).toBeLessThanOrEqual(6);
expect(x.text.split(' ').length, x.text).toBeLessThanOrEqual(8);
} }
// 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'); 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.text)).toEqual(['Your game is done.', 'Then come the stores.', 'One line. Rejected.', 'So my AI did it.',
expect(c.map(x => x.id)).not.toContain('end'); 'Builds.', 'Screenshots, every size.', 'Products, 175 countries.', 'Forms.', 'Consoles.', 'Nothing left to reject.', 'You press Submit.']);
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.']);
}); });
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')!; const e = T.SCENES.find(s => s.id === 'end')!;
expect(T.END_LINE).toBe('From an empty folder to Submit.'); expect(T.SCENES.at(-1)!.id).toBe('end');
expect(T.END_URL).toBe('Open source · storebound.dev'); expect(T.END_CMD).toBe('npx storebound init');
expect(e.end - e.start - T.END.urlAt - T.END.d).toBeGreaterThanOrEqual(2.5); 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')!; 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.start)).toBe(0);
expect(T.veil(r.end - 0.1)).toBeGreaterThan(0.5); expect(T.veil(r.end - 0.1)).toBeGreaterThan(0.5);
expect(T.veil(b.start)).toBe(0); 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 // The desktop frames: the real app (fixture mode) in the consoles beat, the button and the waiting scene, captured by