diff --git a/media/teaser/README.md b/media/teaser/README.md
index 26acb2e..cf02186 100644
--- a/media/teaser/README.md
+++ b/media/teaser/README.md
@@ -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)
```
diff --git a/media/teaser/index.html b/media/teaser/index.html
index 093791b..f34105c 100644
--- a/media/teaser/index.html
+++ b/media/teaser/index.html
@@ -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; }
@@ -211,7 +213,7 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
storebound
-
+
$
diff --git a/media/teaser/render.ts b/media/teaser/render.ts
index d827994..c8e7dea 100644
--- a/media/teaser/render.ts
+++ b/media/teaser/render.ts
@@ -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 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 {
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 {
}
}
+/** 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 {
+ 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 => `${k}s ${shotAt(k)} `).join('');
+ const sheet = await browser.newPage({ viewport: { width: cols * 330 + 20, height: 400 }, deviceScaleFactor: 1 });
+ await sheet.setContent(`${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();
diff --git a/media/teaser/script.js b/media/teaser/script.js
index e3494cd..5c6ecc9 100644
--- a/media/teaser/script.js
+++ b/media/teaser/script.js
@@ -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/.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;
}
diff --git a/media/teaser/teaser.js b/media/teaser/teaser.js
index e046364..66b3fa3 100644
--- a/media/teaser/teaser.js
+++ b/media/teaser/teaser.js
@@ -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, '&').replace(//g, '>');
+/** 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, '$1 ');
@@ -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 ? ' ' : ''}`);
@@ -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(() => {
diff --git a/tests/teaser.test.ts b/tests/teaser.test.ts
index b38e708..c03a4d5 100644
--- a/tests/teaser.test.ts
+++ b/tests/teaser.test.ts
@@ -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;
REJECT: { head: string; at: string }; PROMPT: string;
@@ -33,7 +34,8 @@ interface Teaser {
DESK: Record;
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;
}
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