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
start, so you hand it all to your AI, it does the work, and one button stays yours. Little text (one short line per
scene at most, big and calm), real product footage only, and the music carries the arc: calm, tension, near silence,
a driving build, a stop, a resolve. 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 the sound design are original and synthesized from scratch in
the same browser (Web Audio `OfflineAudioContext`): no samples, no downloaded audio, nothing to license.
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
you press Submit. Editing rules: frame 0 already moves and shows the product (no black, no calm intro); nothing holds
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
lands, a check turns green); captions are at most six words and on screen for at least 2 s; the camera pushes toward
the thing that changes; the last 4 s are the end card with `npx storebound init` typed out big and storebound.dev.
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
hard on the beat with one quick push each.
120 BPM: every cut is a hard cut on a beat (whole half seconds). Before any final render, make the animatic
(`--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 |
|---|---|---|---|
| 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 |
| 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 |
| 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 |
| 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 |
| 19-23 | `apple build --dry` and `play build --dry`: build 52, versionCode 52 | Builds. | the drop: kick, bass, arpeggio |
| 23-27 | the demo game's renders: 7 store sizes x 4 languages | Screenshots, every size. | + hats |
| 27-30.5 | `play products --dry` (174 regions), `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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 53-58 | the storebound mark (brand/logo-mark-256.png) | From an empty folder to Submit. · Open source · storebound.dev | the chord holds, fades |
| 0-3 | the demo game's own screens rush past, a push in | Your game is done. | calm pad, a pluck |
| 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 |
| 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 |
| 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 |
| 12-15 | `apple build --dry`, `play build --dry`: build 52, versionCode 52 | Builds. | the drop |
| 15-18 | the demo game's renders: 7 store sizes x 4 languages fill in | Screenshots, every size. | + hats |
| 18-21 | `play products --dry`, `catalog diff` (175 / 174 region prices, 0 mismatches) | Products, 175 countries. | + claps |
| 21-24 | the scan's declarations proposal, the Data safety rows ticking, 768 answers | Forms. | + sixteenths |
| 24-27 | `console plan apple-app-privacy`, then the desktop app's plan panel, the owner's Publish ringed | Consoles. | + lead |
| 27-31 | `storebound scan`: five findings turn green one per beat, the clean re-scan | Nothing left to reject. | a chime per fix |
| 31-33 | the desktop app's Send for review, the camera pushes onto Submit, the pointer arrives | You press Submit. | the music stops |
| 33-36 | the click, then the app's own "Waiting for review" | (same line) | the resolve |
| 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 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
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 --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 --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 ---------- */
#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);
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 .sev { color: #fff; background: var(--red-2); border-radius: 9px; padding: 3px 14px; 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); }
/* ---------- 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 .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 .line { margin-top: 40px; font: 750 64px/1.1 var(--display); letter-spacing: -0.025em; color: #fff; }
#brand .url { margin-top: 28px; font: 700 42px/1 var(--display); color: var(--accent); letter-spacing: -0.01em; }
#brand .word { margin-top: 20px; font: 900 120px/1 var(--display); letter-spacing: -0.05em; color: #fff; }
#brand .cmdl { display: inline-block; margin-top: 44px; padding: 26px 48px; min-width: 1040px; text-align: left; border-radius: 22px;
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>
</head>
<body>
@ -211,7 +213,7 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
<div id="brand" class="abs">
<img class="mark" src="brand/logo-mark-256.png" alt="">
<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>
</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 --sound the music and sound only (out/music.wav)
// bun media/teaser/render.ts --desktop capture the desktop app frames again first (desktop.ts)
// bun media/teaser/render.ts --animatic <dir> the animatic for the owner's OK before any final render: draft.mp4
// (960x540, 30 fps, quick encode, with sound), contact-sheet.png
// (one frame per second, timestamped), shots.md, checks.json
// bun media/teaser/render.ts --serve preview server: open /index.html?play=1 (or ?t=12.5)
// Needs ffmpeg and Playwright (devDependency; Playwright's Chromium or an installed Google Chrome), and the demo game's
// store renders (examples/demo-game: `shots capture`, `shots fonts`, `shots render`, see its README).
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { launch } from '../../cli/shots/browser';
import { captureDesktop } from './desktop';
@ -83,6 +86,8 @@ async function main(): Promise<void> {
const server = serve();
const base = `http://127.0.0.1:${server.port}/index.html`;
if (args.includes('--serve')) { log(`preview: ${base}?play=1`); return; }
const anim = opt('animatic');
if (anim) { try { await animatic(base, anim); } finally { server.stop(true); } return; }
const browser = await launch(ROOT, { headless: true });
try {
@ -178,4 +183,64 @@ async function main(): Promise<void> {
}
}
/** The animatic: a quick low-res draft with sound, a contact sheet (one frame per second, timestamped), the shot list and
* the layout of the end card's install line over the last 4 s. */
async function animatic(base: string, dir: string): Promise<void> {
mkdirSync(dir, { recursive: true });
const S = await import(join(DIR, 'script.js')) as any;
const browser = await launch(ROOT, { headless: true });
try {
const page = await browser.newPage({ viewport: { width: FORMAT.w, height: FORMAT.h }, deviceScaleFactor: 0.5 });
const errors: string[] = [];
page.on('pageerror', (e: Error) => errors.push(e.message));
await page.goto(base);
await page.waitForFunction('window.teaser', null, { timeout: 30_000 });
await page.evaluate('window.teaser.ready');
const { DURATION, FPS } = S;
const silent = join(dir, 'draft-silent.mp4');
const enc = Bun.spawn(['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', '-f', 'image2pipe', '-framerate', String(FPS), '-c:v', 'png', '-i', '-',
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '24', '-pix_fmt', 'yuv420p', '-g', String(FPS), silent], { stdin: 'pipe', stdout: 'inherit', stderr: 'inherit' });
const frames = Math.round(DURATION * FPS);
for (let i = 0; i < frames; i++) {
await page.evaluate(`teaser.seek(${i / FPS})`);
enc.stdin.write(await page.screenshot({ type: 'png' }));
if (i % 150 === 0) log(`draft frame ${i + 1}/${frames}`);
}
await enc.stdin.end();
if (await enc.exited !== 0) throw new Error('ffmpeg encode failed');
// the end card's install line over the last 4 s, every 0.25 s
const layout: unknown[] = [];
for (let t = DURATION - 4; t < DURATION - 1e-6; t += 0.25) layout.push(await page.evaluate(`teaser.layout(${t})`));
if (errors.length) throw new Error(`page errors:\n ${[...new Set(errors)].join('\n ')}`);
// sound
const wav = await page.evaluate(`import('./music.js').then(m => m.renderWav())`) as { base64: string };
const raw = join(dir, 'music-raw.wav');
await Bun.write(raw, Buffer.from(wav.base64, 'base64'));
ff('-i', silent, '-i', raw, '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af', 'loudnorm=I=-16:TP=-1.5', '-c:a', 'aac', '-b:a', '128k', '-ar', '48000', '-shortest', '-movflags', '+faststart', join(dir, 'draft.mp4'));
rmSync(silent); rmSync(raw);
// contact sheet: the draft's frame at every whole second, timestamped
const tmp = join(dir, 'sheet-frames');
mkdirSync(tmp, { recursive: true });
const secs = Array.from({ length: Math.ceil(DURATION) }, (_, k) => k);
for (const k of secs) ff('-ss', String(Math.min(k, DURATION - 1 / FPS)), '-i', join(dir, 'draft.mp4'), '-frames:v', '1', '-vf', 'scale=320:-2', '-q:v', '3', join(tmp, `${k}.jpg`));
const shotAt = (t: number) => S.SCENES.find((s: any) => t >= s.start && t < s.end)?.id ?? 'end';
const cols = 7;
const cells = secs.map(k => `<figure><img src="data:image/jpeg;base64,${Buffer.from(readFileSync(join(tmp, `${k}.jpg`))).toString('base64')}"><figcaption><b>${k}s</b> ${shotAt(k)}</figcaption></figure>`).join('');
const sheet = await browser.newPage({ viewport: { width: cols * 330 + 20, height: 400 }, deviceScaleFactor: 1 });
await sheet.setContent(`<style>body{margin:0;padding:10px;background:#111;font:600 15px system-ui;color:#ddd;display:grid;grid-template-columns:repeat(${cols},320px);gap:10px}figure{margin:0}img{display:block;width:320px;height:180px}figcaption{padding:4px 2px}b{color:#fff}</style>${cells}`);
await sheet.screenshot({ path: join(dir, 'contact-sheet.png'), fullPage: true });
rmSync(tmp, { recursive: true, force: true });
// the shot list
const caps = S.captionTimes();
const md = ['# storebound teaser v9: animatic shot list', '', `${DURATION} s, 16:9, 120 BPM (every cut on a half-second beat), hard cuts.`, '',
...S.SCENES.map((s: any, i: number) => {
const c = caps.filter((x: any) => x.at >= s.start - 0.01 && x.at < s.end);
return `${i + 1}. ${s.start.toFixed(1)}-${s.end.toFixed(1)} s (${(s.end - s.start).toFixed(1)} s) **${s.id}**: ${S.SHOT_NOTES[s.id]}${c.length ? ` · caption "${c.map((x: any) => x.text).join('", "')}" (${c.map((x: any) => `${x.from.toFixed(2)}-${x.to.toFixed(2)} s`).join(', ')})` : ''}`;
}), ''];
writeFileSync(join(dir, 'shots.md'), md.join('\n'));
writeFileSync(join(OUT, 'animatic-checks.json'), JSON.stringify({ duration: DURATION, endLayout: layout }, null, 2));
log(`animatic: ${dir} (draft.mp4, contact-sheet.png, shots.md); layout -> out/animatic-checks.json`);
} finally { await browser.close(); }
}
await main();

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).
// 58 s, 16:9. Little text: one short line per scene at most, big and calm; the music carries the arc
// (calm -> tension -> near silence -> a driving build -> stop -> resolve). Every cut lands on a half-second beat.
// Teaser timeline (v9, the story cut tight): the single source of truth for the picture (teaser.js) and the sound
// (music.js). 41 s, 16:9, 120 BPM: every cut lands on a beat (a whole half second), every shot does one thing in
// 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."
// pile 5-11 real store work piles up: App Privacy answers, Data safety CSV, "Then come the stores."
// screenshot sizes, price sheets
// 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 15-19 near silence: one request typed to the AI agent "So I handed it all to my AI."
// builds..consoles 19-37 the montage, one word at the edge: Builds. / Screenshots, every size. / Products, 175
// countries. / Forms. / Consoles.
// scan 37-42 `storebound scan`: each finding turns green, the re-scan is clean "Nothing left for a reviewer to catch."
// button 42-48 the desktop app's Send for review, the pointer resting on Submit; the music stops "One button is yours."
// waiting 48-53 the click, then "Waiting for review" (the app's own state); the music resolves
// end 53-58 the logo, "From an empty folder to Submit.", "Open source · storebound.dev"
// game 0-3 cold open: the demo game's real screens rush past, a push in "Your game is done."
// pile 3-7 a wall of real store work lands, one card per beat "Then come the stores."
// reject 7-9.5 the real scan finding 5.1.1(v) slams in, the picture darkens "One line. Rejected."
// ask 9.5-12 one request typed to the AI agent, sent on the beat "So my AI did it."
// builds..consoles 12-27 the montage, 3 s each, one word at the edge, each result lands: Builds. /
// Screenshots, every size. / Products, 175 countries. / Forms. / Consoles.
// scan 27-31 `storebound scan`: one finding turns green per beat, the clean re-scan "Nothing left to reject."
// button 31-33 the desktop app's Send for review, the camera pushes onto Submit "You press Submit."
// waiting 33-36 the click, then the app's own "Waiting for review" (same line)
// end 36-41 the logo, `npx storebound init` typed out, storebound.dev (4 s hold)
//
// 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`
@ -31,74 +31,91 @@ export const FPS = 30;
/** 16:9 only: the master; render.ts also makes the 1280x720 web version from it. */
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 = [
['game', 5, 0], ['pile', 6, 0.6], ['reject', 4, 0.5], ['ask', 4, 0.8],
['builds', 4, 0.1], ['shots', 4, 0.1], ['products', 3.5, 0.1], ['forms', 3.5, 0.1], ['consoles', 3, 0.1],
['scan', 5, 0.1], ['button', 6, 0.5], ['waiting', 5, 0.1], ['end', 5, 0.8],
['game', 3, 0], ['pile', 4, 0], ['reject', 2.5, 0], ['ask', 2.5, 0],
['builds', 3, 0], ['shots', 3, 0], ['products', 3, 0], ['forms', 3, 0], ['consoles', 3, 0],
['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 DURATION = SCENES.at(-1).end;
export const scene = id => SCENES.find(s => s.id === id);
/** The montage: one word or short phrase at the edge, cuts on the beat. */
export const MONTAGE = ['builds', 'shots', 'products', 'forms', 'consoles'];
/** 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 CAMERA = {
game: { from: { z: 1.0, x: 40, y: 10 }, to: { z: 1.07, x: -40, y: -6 } },
pile: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.12, 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.04, x: 0, y: 0 } },
builds: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.06, x: -20, y: -10 } },
shots: { from: { z: 1.04, x: 30, y: 0 }, to: { z: 1.0, x: -30, y: 0 } },
products: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.06, x: 20, y: -10 } },
forms: { from: { z: 1.04, x: -20, y: 0 }, to: { z: 1.0, x: 20, 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.05, x: 0, y: -10 } },
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.03, x: 0, y: 0 } },
/** Why each shot cuts where it does (the shot list, render.ts --animatic). */
export const SHOT_NOTES = {
game: 'the demo game\'s four real screens rush past, push in; cut as the push lands',
pile: 'App Privacy, Data safety CSV, screenshot sizes, prices: one real card per beat; cut when the wall is full',
reject: 'the real BLOCKER 5.1.1(v) finding slams in, push onto it, the picture darkens; cut when it fills the frame',
ask: 'one request typed to the AI agent; cut on the send',
builds: '`apple build --dry`, `play build --dry`: build 52 / versionCode 52; cut on "build planned"',
shots: 'the demo game\'s renders: 7 store sizes x 4 languages fill in column by column; cut when the grid is full',
products: '`play products --dry`, `catalog diff`: 175 region prices, 0 mismatches; cut on the 0',
forms: 'scan\'s declarations proposal ticks the Data safety rows, the count runs to 768; cut when it lands',
consoles: '`console plan apple-app-privacy`, then the app\'s plan panel, the owner\'s Publish ringed; cut on the ring',
scan: '`storebound scan`: 5 findings turn green one per beat, the clean re-scan; cut on 0 blockers',
button: 'the desktop app\'s Send for review, the camera pushes onto Submit, the pointer arrives; cut on arrival',
waiting: 'the click, then the app\'s own "Waiting for review", ringed; cut when it settles',
end: 'logo, `npx storebound init` typed out big, storebound.dev; holds 4 s (caret blink, slow push)',
};
/** Captions: at most one short line per scene. top = the story line, big and centred at the top; edge = the montage
* word, bottom left. at / until: seconds into the scene (fade in from `at`, gone at `until`). *word* = highlighted. */
/** One camera move per shot: from -> to (z = zoom, x / y = pan in px), a quick push that settles at 80 % of the shot. */
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 = [
{ id: 'game', pos: 'top', text: 'The game is *done.*', at: 0.6, until: 4.85 },
{ id: 'pile', pos: 'top', text: 'Then come *the stores.*', at: 0.5, until: 5.9 },
{ id: 'reject', pos: 'top', text: 'One missing line, and you *start over.*', at: 0.35, until: 3.95, hl: 'red' },
{ id: 'ask', pos: 'top', text: 'So I handed it all *to my AI.*', at: 0.35, until: 3.95 },
{ id: 'builds', pos: 'edge', text: 'Builds.', at: 0.05, until: 3.95 },
{ id: 'shots', pos: 'edge', text: 'Screenshots, every size.', at: 0.05, until: 3.95 },
{ id: 'products', pos: 'edge', text: 'Products, 175 countries.', at: 0.05, until: 3.45 },
{ id: 'forms', pos: 'edge', text: 'Forms.', at: 0.05, until: 3.45 },
{ id: 'consoles', pos: 'edge', text: 'Consoles.', at: 0.05, until: 2.95 },
{ id: 'scan', pos: 'top', text: 'Nothing left for a reviewer *to catch.*', at: 1.5, until: 4.95, hl: 'green' },
{ id: 'button', pos: 'top', text: 'One button is *yours.*', at: 0.9, until: 5.9 },
{ 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: 3.05, until: 6.95 },
{ id: 'reject', pos: 'top', text: 'One line. *Rejected.*', at: 7.05, until: 9.45, hl: 'red' },
{ id: 'ask', pos: 'top', text: 'So my AI *did it.*', at: 9.55, until: 11.95 },
{ id: 'builds', pos: 'edge', text: 'Builds.', at: 12.05, until: 14.95 },
{ id: 'shots', pos: 'edge', text: 'Screenshots, every size.', at: 15.05, until: 17.95 },
{ id: 'products', pos: 'edge', text: 'Products, 175 countries.', at: 18.05, until: 20.95 },
{ id: 'forms', pos: 'edge', text: 'Forms.', at: 21.05, until: 23.95 },
{ id: 'consoles', pos: 'edge', text: 'Consoles.', at: 24.05, until: 26.95 },
{ id: 'scan', pos: 'top', text: 'Nothing left to *reject.*', at: 28.6, until: 30.95, hl: 'green' },
{ 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. */
export const CAP_FADE = { top: [0.5, 0.4], edge: [0.15, 0.15] };
/** When each caption is fully on screen (tests: at least 2.5 s, inside its scene). */
/** Fades (s): quick, a cut is a cut. */
export const CAP_FADE = { top: [0.12, 0.08], edge: [0.1, 0.08] };
/** When each caption is fully on screen. */
export function captionTimes() {
return CAPTIONS.map(c => {
const s = scene(c.id), [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;
return { id: c.id, text: c.text.replace(/\*/g, ''), pos: c.pos, at, from, to, gone, hold: to - from };
const [fin, fout] = CAP_FADE[c.pos];
const from = c.at + fin, to = c.until - fout;
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 first beat of the montage. */
/** The dark veil over the picture (0-1) at time t: it falls with the rejection, holds through the request, gone on the
* first beat of the montage. */
export function veil(t) {
const r = scene('reject'), a = scene('ask');
if (t < r.start + 0.4) return 0;
if (t < r.end) return 0.72 * Math.min(1, (t - r.start - 0.4) / 3.2);
if (t < a.end - 0.05) return 0.72 - 0.12 * Math.min(1, (t - a.start) / 1.5);
if (t < r.start + 0.15) return 0;
if (t < r.end) return 0.7 * Math.min(1, (t - r.start - 0.15) / 1.6);
if (t < a.end) return 0.6;
return 0;
}
// ---------- 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. */
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 ----------
/** 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
* (px), w = width, rot = degrees. kind: what it shows. */
export const PILE = [
{ at: 0.25, kind: 'privacy', x: 700, y: 560, w: 1000, rot: -3 },
{ at: 1.25, kind: 'csv', x: 1220, y: 600, w: 980, rot: 2.5 },
{ at: 2.15, kind: 'shots', x: 860, y: 640, w: 900, rot: -1.5 },
{ at: 2.95, kind: 'prices', x: 1180, y: 560, w: 760, rot: 4 },
{ at: 3.6, kind: 'steps', x: 760, y: 690, w: 1040, rot: 2 },
{ at: 4.15, kind: 'csv', x: 1060, y: 610, w: 1000, rot: -4.5, from: 7 },
{ at: 4.6, 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: 5.35, kind: 'privacy', x: 1040, y: 600, w: 1060, rot: 1, from: 6 },
];
{ at: 0.0, kind: 'privacy', x: 640, y: 540, w: 1000, rot: -3 },
{ at: 0.5, kind: 'csv', x: 1260, y: 600, w: 980, rot: 2.5 },
{ at: 1.0, kind: 'shots', x: 820, y: 680, w: 900, rot: -1.5 },
{ at: 1.5, kind: 'prices', x: 1240, y: 520, w: 760, rot: 4 },
{ at: 2.0, kind: 'steps', x: 760, y: 700, w: 1040, rot: 2 },
{ at: 2.5, kind: 'csv', x: 1100, y: 620, w: 1000, rot: -4.5, from: 7 },
{ at: 3.0, kind: 'shots', x: 940, y: 560, w: 940, rot: 3.5, lang: 'ja' },
{ at: 3.5, kind: 'prices', x: 980, y: 640, w: 820, rot: -2.5, from: 11 },
]
/** 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' };
@ -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',
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 ----------
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. */
export const PROMPT_SENT = 3.05;
export const PROMPT_SENT = 2.0;
// ---------- 5 montage ----------
/** 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' },
];
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). */
export const PRODUCT_LINES = [
{ at: 0.05, 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.48, 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.05, 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.35, 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: 1.75, text: '[catalog] 2 catalog items: 0 mismatch(es), 0 warning(s), 0 price difference(s) between the stores' },
{ at: 0.07, text: '$ storebound play products --dry', cmd: true },
{ at: 0.41, text: '[play] 1/2 com.example.app.no_ads: 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: 1.08, text: '$ storebound catalog diff', cmd: true },
{ at: 1.42, text: 'ok [apple] com.example.app.gems_100: consumable, 175 region prices, READY_TO_SUBMIT' },
{ at: 1.62, text: 'ok [play] com.example.app.gems_100: consumable, 174 region prices, ACTIVE' },
{ at: 1.82, text: 'ok [apple] com.example.app.no_ads: non_consumable, 175 region prices, READY_TO_SUBMIT' },
{ at: 2.03, text: 'ok [play] com.example.app.no_ads: non_consumable, 174 region prices, ACTIVE' },
{ 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. */
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 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). */
export const FIX_AT = [1.0, 1.5, 2.0, 2.5, 3.0];
export const RESCAN_AT = 3.45;
export const FIX_AT = [0.5, 1.0, 1.5, 2.0, 2.5];
export const RESCAN_AT = 3.0;
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) ----------
@ -269,21 +286,21 @@ export const DESK = {
groups: [{ frame: 'console-plan' }],
show: CONSOLES_SWAP,
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
button: {
groups: [{ frame: 'submit-ready' }],
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' }],
pointer: { at: 1.9, d: 1.1, target: 'submitBtn' },
rings: [{ at: 1.3, until: 2.6, target: 'submitBtn' }],
pointer: { at: 0.4, d: 0.9, target: 'submitBtn' },
},
// the press, then the app's own "Waiting for review" state
waiting: {
groups: [{ frame: 'submit-ready' }, { frame: 'submit-waiting' }],
swap: [0.45, 0.9],
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 },
},
};
@ -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. */
export const CLICK_T = scene('waiting').start + DESK.waiting.pointer.click;
// ---------- 9 end card ----------
/** Seconds into the end scene at which each line comes in. */
export const END = { markAt: 0.25, wordAt: 0.5, lineAt: 0.9, urlAt: 1.25, d: 0.6 };
export const END_LINE = 'From an empty folder to Submit.';
export const END_URL = 'Open source · storebound.dev';
// ---------- 9 end card: the last thing on screen ----------
/** Seconds into the end shot: the logo and word slam in, the install line is typed, then the site. Everything is on
* screen by `full`; the rest (at least 4 s) holds with a blinking caret and a slow push. */
export const END_CMD = 'npx storebound init';
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) ----------
/** Cuts with a sound accent (every scene change but the seamless button -> waiting one). */
@ -321,5 +340,6 @@ export function ticks() {
export function keyTimes() {
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 < END_CMD.length; i++) out.push(scene('end').start + END.cmdAt + i / END.cps);
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.
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,
FINDINGS, FINDINGS_AT, FINDINGS_STEP, FIX_AT, FORM_AT, FORM_STEP, FORMAT, FORMS_COUNT_AT, FPS, GAME_AT, GAME_SCREENS, GAME_STEP, MONTAGE,
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_SCREENS, GAME_SPEED, MONTAGE,
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 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 lerp = (a, b, p) => a + (b - a) * p;
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 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>');
@ -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;
if (t < from || t >= until) { set(el, 'visibility', 'hidden'); set(el, 'opacity', '0'); return; }
set(el, 'visibility', 'visible');
// the first scene comes up from black; the others dissolve in over the previous one
set(el, 'opacity', String(i === 0 ? eio(prog(t, 0, 0.9)) : eio(prog(t, from, s.x))));
// hard cuts on the beat: the first frame is already the product, no fade from black
set(el, 'opacity', String(i === 0 ? 1 : eio(prog(t, from, s.x))));
const c = CAMERA[s.id];
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);
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) ----------
const capEl = $('#cap'), capBand = $('#capband'), edgeEl = $('#edge'), edgeBand = $('#edgeband');
const CT = Object.fromEntries(captionTimes().map(c => [c.id, c]));
let capId = null;
let capId = null, edgeId = null;
function captions(t) {
const s = active(t), c = CAPTIONS.find(x => x.id === s.id);
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 pin = eoc(prog(t, ct.at, fin)), pout = prog(t, ct.to, fout);
const op = pin * (1 - pout);
rise = (1 - pin) * 14 - pout * 8;
if (capId !== c.id) {
capId = c.id;
if (c.pos === 'top') { capEl.className = c.hl ?? ''; capEl.innerHTML = hl(c.text); } else edgeEl.innerHTML = hl(c.text);
if (c.pos === 'top') {
top = op; rise = (1 - pin) * 14;
if (capId !== c.id) { capId = c.id; capEl.className = c.hl ?? ''; capEl.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, 'transform', `translateY(${rise.toFixed(1)}px)`);
@ -90,22 +93,24 @@ function captions(t) {
// ---------- 1 game: the game's own screens ----------
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');
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);
return el;
});
function game(t) {
const s = scene('game'); if (t > s.end + 1) return;
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) => {
const p = prog(r, GAME_AT + i * GAME_STEP, 0.9);
set(el, 'opacity', String(eoc(p)));
// a slow float, each phone on its own phase
const fy = Math.sin(r * 0.9 + i * 1.3) * 6;
set(el, 'transform', `translateY(${((1 - eoc(p)) * 40 + fy).toFixed(1)}px) rotate(${((i - 1.5) * 1.2).toFixed(2)}deg)`);
const x = 160 + i * PHONE_GAP - GAME_SPEED * r;
const fy = Math.sin(r * 3 + i * 1.3) * 10;
set(el, 'opacity', '1');
set(el, 'left', `${x.toFixed(1)}px`);
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 r = t - s.start;
pileCards.forEach(({ c, el, rows, shade, maxScroll }, i) => {
const p = prog(r, c.at, 0.32);
set(el, 'opacity', String(clamp(p * 2.5)));
const p = prog(r, c.at - LEAD, 0.2);
set(el, 'opacity', String(clamp(p * 3)));
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)})`);
// 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)`);
// 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)));
});
}
@ -175,9 +180,12 @@ const findEl = $('#finding');
function reject(t) {
const s = scene('reject'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start;
set(findEl, 'opacity', String(eoc(prog(r, -0.1, 0.45))));
const z = lerp(REJECT_GROW[0], REJECT_GROW[1], esin(prog(r, -0.1, s.end - s.start + 0.3)));
set(findEl, 'transform', `translate(-50%, -50%) scale(${z.toFixed(4)})`);
// the slam: in at once, overshooting scale down to 1 with a short shake; then a push onto the rule
const sp = prog(r, -LEAD, REJECT_SLAM.d + LEAD);
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 ----------
@ -185,7 +193,7 @@ const promptEl = $('#prompt'), promptTxt = $('#prompt .txt'), sentEl = $('#promp
function ask(t) {
const s = scene('ask'); if (t < s.start - 1 || t > s.end + 1) return;
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 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>' : ''}`);
@ -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 r = t - s.start;
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);
@ -251,10 +259,10 @@ const shotTiles = [], shotLabels = [], langLabels = [];
function shots(t) {
const s = scene('shots'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start;
langLabels.forEach(el => appear(el, r, 0, 0.2, 0));
shotLabels.forEach(({ el, col }) => set(el, 'opacity', String(eoc(prog(r, SHOTS_AT + col * SHOTS_STEP, 0.25)))));
langLabels.forEach(el => appear(el, r, -0.05, 0.1, 0));
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) {
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, 'transform', `scale(${lerp(0.86, 1, eoc(p)).toFixed(4)})`);
}
@ -272,7 +280,7 @@ function forms(t) {
const r = t - s.start;
appear($('#prop'), r, -0.1, 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))));
formRows.forEach((el, i) => {
const p = prog(r, FORM_AT + i * FORM_STEP, 0.2);
@ -301,7 +309,7 @@ function consoles(t) {
const r = t - s.start;
const out = 1 - eio(prog(r, CONSOLES_SWAP[0], CONSOLES_SWAP[1]));
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))));
}
@ -320,10 +328,10 @@ function scan(t) {
const s = scene('scan'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start;
appear($('#scanterm'), r, -0.1, 0.2, 10);
appear(scanLns[0], r, 0.05, 0.15, 4);
appear(scanLns[1], r, 0.25, 0.2, 6);
appear(scanLns[0], r, -LEAD, 0.12, 4);
appear(scanLns[1], r, 0.05, 0.15, 6);
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);
set(el.querySelector('.tint.g'), 'opacity', String(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) {
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 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);
@ -394,15 +402,29 @@ function deskScene(dk, t) {
}
const desktop = t => desks.forEach(dk => deskScene(dk, t));
// ---------- 9 end card ----------
const brand = $('#brand');
brand.querySelector('.line').textContent = END_LINE;
// ---------- 9 end card: the logo, the install line typed out, the site; the last thing on screen ----------
const brand = $('#brand'), endCmd = $('#brand .cmdl .t'), endCaret = $('#brand .cmdl .caret');
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) {
const s = scene('end'); if (t < s.start - 1) return;
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) {
@ -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));
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('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
// 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