From c608b9f63eaaafe72bc5becf0968ae057f6b8d6f Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 00:21:09 +0800 Subject: [PATCH] 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 --- media/teaser/README.md | 50 +++++----- media/teaser/index.html | 14 +-- media/teaser/render.ts | 67 ++++++++++++- media/teaser/script.js | 208 ++++++++++++++++++++++------------------ media/teaser/teaser.js | 112 +++++++++++++--------- tests/teaser.test.ts | 82 ++++++++-------- 6 files changed, 326 insertions(+), 207 deletions(-) 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