teaser v9 animatic: 41 s story cut tight (hard cuts on the beat, 2-4 s shots, frame 0 moving, every shot opens on its subject, captions <= 6 words held >= 2 s, end card with npx storebound init held 4 s); render.ts --animatic (960x540 draft with sound, contact sheet, shot list, end-card layout checks); tests for the v9 timing rules
This commit is contained in:
parent
7ac028421a
commit
c608b9f63e
6 changed files with 326 additions and 207 deletions
|
|
@ -1,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)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
|
|||
/* ---------- 3 reject: the finding ---------- */
|
||||
#finding { left: 50%; top: 52%; width: max-content; padding: 34px 44px 38px; border-radius: 26px; background: #1a0d12;
|
||||
border: 3px solid rgba(255,90,95,.65); box-shadow: 0 0 0 10px rgba(229,72,77,.12), 0 0 160px rgba(229,72,77,.35), 0 50px 120px rgba(0,0,0,.7);
|
||||
font-family: var(--mono); opacity: 0; transform-origin: 50% 50%; }
|
||||
font-family: var(--mono); opacity: 0; transform-origin: 30% 50%; }
|
||||
#finding .l1 { display: flex; align-items: center; gap: 18px; white-space: nowrap; font-size: 31px; font-weight: 700; color: #ffd6d7; }
|
||||
#finding .sev { color: #fff; background: var(--red-2); border-radius: 9px; padding: 3px 14px; font-weight: 800; }
|
||||
#finding .gl { color: #fff; font-weight: 800; }
|
||||
|
|
@ -164,12 +164,14 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
|
|||
.ring.green { border-color: var(--green); box-shadow: 0 0 0 6px rgba(61,220,132,.22), 0 0 40px rgba(61,220,132,.45); }
|
||||
|
||||
/* ---------- 9 end card ---------- */
|
||||
#brand { left: 0; right: 0; top: 250px; text-align: center; }
|
||||
#brand { left: 0; right: 0; top: 150px; text-align: center; }
|
||||
#brand > * { opacity: 0; }
|
||||
#brand .mark { width: 200px; height: 200px; display: block; margin: 0 auto; filter: drop-shadow(0 30px 70px rgba(91,92,240,.6)); }
|
||||
#brand .word { margin-top: 26px; font: 900 120px/1 var(--display); letter-spacing: -0.05em; color: #fff; }
|
||||
#brand .line { margin-top: 40px; font: 750 64px/1.1 var(--display); letter-spacing: -0.025em; color: #fff; }
|
||||
#brand .url { margin-top: 28px; font: 700 42px/1 var(--display); color: var(--accent); letter-spacing: -0.01em; }
|
||||
#brand .word { margin-top: 20px; font: 900 120px/1 var(--display); letter-spacing: -0.05em; color: #fff; }
|
||||
#brand .cmdl { display: inline-block; margin-top: 44px; padding: 26px 48px; min-width: 1040px; text-align: left; border-radius: 22px;
|
||||
background: #0b1222; border: 2px solid var(--line-2); box-shadow: 0 30px 80px rgba(0,0,0,.5); font: 800 76px/1.1 var(--mono); color: #fff; white-space: pre; }
|
||||
#brand .cmdl .caret { width: .55em; height: 1em; vertical-align: -0.12em; margin-left: 6px; }
|
||||
#brand .url { margin-top: 40px; font: 750 56px/1 var(--display); color: var(--accent); letter-spacing: -0.01em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -211,7 +213,7 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
|
|||
<div id="brand" class="abs">
|
||||
<img class="mark" src="brand/logo-mark-256.png" alt="">
|
||||
<div class="word">storebound</div>
|
||||
<div class="line"></div>
|
||||
<div class="cmdl"><span class="ps">$</span> <span class="t"></span><span class="caret"></span></div>
|
||||
<div class="url"></div>
|
||||
</div>
|
||||
</div></div>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@
|
|||
// bun media/teaser/render.ts --stills 1,9.5,30 PNG stills only (out/stills/), quick layout check
|
||||
// bun media/teaser/render.ts --sound the music and sound only (out/music.wav)
|
||||
// bun media/teaser/render.ts --desktop capture the desktop app frames again first (desktop.ts)
|
||||
// bun media/teaser/render.ts --animatic <dir> the animatic for the owner's OK before any final render: draft.mp4
|
||||
// (960x540, 30 fps, quick encode, with sound), contact-sheet.png
|
||||
// (one frame per second, timestamped), shots.md, checks.json
|
||||
// bun media/teaser/render.ts --serve preview server: open /index.html?play=1 (or ?t=12.5)
|
||||
// Needs ffmpeg and Playwright (devDependency; Playwright's Chromium or an installed Google Chrome), and the demo game's
|
||||
// store renders (examples/demo-game: `shots capture`, `shots fonts`, `shots render`, see its README).
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { launch } from '../../cli/shots/browser';
|
||||
import { captureDesktop } from './desktop';
|
||||
|
|
@ -83,6 +86,8 @@ async function main(): Promise<void> {
|
|||
const server = serve();
|
||||
const base = `http://127.0.0.1:${server.port}/index.html`;
|
||||
if (args.includes('--serve')) { log(`preview: ${base}?play=1`); return; }
|
||||
const anim = opt('animatic');
|
||||
if (anim) { try { await animatic(base, anim); } finally { server.stop(true); } return; }
|
||||
|
||||
const browser = await launch(ROOT, { headless: true });
|
||||
try {
|
||||
|
|
@ -178,4 +183,64 @@ async function main(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/** The animatic: a quick low-res draft with sound, a contact sheet (one frame per second, timestamped), the shot list and
|
||||
* the layout of the end card's install line over the last 4 s. */
|
||||
async function animatic(base: string, dir: string): Promise<void> {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const S = await import(join(DIR, 'script.js')) as any;
|
||||
const browser = await launch(ROOT, { headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: FORMAT.w, height: FORMAT.h }, deviceScaleFactor: 0.5 });
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e: Error) => errors.push(e.message));
|
||||
await page.goto(base);
|
||||
await page.waitForFunction('window.teaser', null, { timeout: 30_000 });
|
||||
await page.evaluate('window.teaser.ready');
|
||||
const { DURATION, FPS } = S;
|
||||
const silent = join(dir, 'draft-silent.mp4');
|
||||
const enc = Bun.spawn(['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', '-f', 'image2pipe', '-framerate', String(FPS), '-c:v', 'png', '-i', '-',
|
||||
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '24', '-pix_fmt', 'yuv420p', '-g', String(FPS), silent], { stdin: 'pipe', stdout: 'inherit', stderr: 'inherit' });
|
||||
const frames = Math.round(DURATION * FPS);
|
||||
for (let i = 0; i < frames; i++) {
|
||||
await page.evaluate(`teaser.seek(${i / FPS})`);
|
||||
enc.stdin.write(await page.screenshot({ type: 'png' }));
|
||||
if (i % 150 === 0) log(`draft frame ${i + 1}/${frames}`);
|
||||
}
|
||||
await enc.stdin.end();
|
||||
if (await enc.exited !== 0) throw new Error('ffmpeg encode failed');
|
||||
// the end card's install line over the last 4 s, every 0.25 s
|
||||
const layout: unknown[] = [];
|
||||
for (let t = DURATION - 4; t < DURATION - 1e-6; t += 0.25) layout.push(await page.evaluate(`teaser.layout(${t})`));
|
||||
if (errors.length) throw new Error(`page errors:\n ${[...new Set(errors)].join('\n ')}`);
|
||||
// sound
|
||||
const wav = await page.evaluate(`import('./music.js').then(m => m.renderWav())`) as { base64: string };
|
||||
const raw = join(dir, 'music-raw.wav');
|
||||
await Bun.write(raw, Buffer.from(wav.base64, 'base64'));
|
||||
ff('-i', silent, '-i', raw, '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af', 'loudnorm=I=-16:TP=-1.5', '-c:a', 'aac', '-b:a', '128k', '-ar', '48000', '-shortest', '-movflags', '+faststart', join(dir, 'draft.mp4'));
|
||||
rmSync(silent); rmSync(raw);
|
||||
// contact sheet: the draft's frame at every whole second, timestamped
|
||||
const tmp = join(dir, 'sheet-frames');
|
||||
mkdirSync(tmp, { recursive: true });
|
||||
const secs = Array.from({ length: Math.ceil(DURATION) }, (_, k) => k);
|
||||
for (const k of secs) ff('-ss', String(Math.min(k, DURATION - 1 / FPS)), '-i', join(dir, 'draft.mp4'), '-frames:v', '1', '-vf', 'scale=320:-2', '-q:v', '3', join(tmp, `${k}.jpg`));
|
||||
const shotAt = (t: number) => S.SCENES.find((s: any) => t >= s.start && t < s.end)?.id ?? 'end';
|
||||
const cols = 7;
|
||||
const cells = secs.map(k => `<figure><img src="data:image/jpeg;base64,${Buffer.from(readFileSync(join(tmp, `${k}.jpg`))).toString('base64')}"><figcaption><b>${k}s</b> ${shotAt(k)}</figcaption></figure>`).join('');
|
||||
const sheet = await browser.newPage({ viewport: { width: cols * 330 + 20, height: 400 }, deviceScaleFactor: 1 });
|
||||
await sheet.setContent(`<style>body{margin:0;padding:10px;background:#111;font:600 15px system-ui;color:#ddd;display:grid;grid-template-columns:repeat(${cols},320px);gap:10px}figure{margin:0}img{display:block;width:320px;height:180px}figcaption{padding:4px 2px}b{color:#fff}</style>${cells}`);
|
||||
await sheet.screenshot({ path: join(dir, 'contact-sheet.png'), fullPage: true });
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
// the shot list
|
||||
const caps = S.captionTimes();
|
||||
const md = ['# storebound teaser v9: animatic shot list', '', `${DURATION} s, 16:9, 120 BPM (every cut on a half-second beat), hard cuts.`, '',
|
||||
...S.SCENES.map((s: any, i: number) => {
|
||||
const c = caps.filter((x: any) => x.at >= s.start - 0.01 && x.at < s.end);
|
||||
return `${i + 1}. ${s.start.toFixed(1)}-${s.end.toFixed(1)} s (${(s.end - s.start).toFixed(1)} s) **${s.id}**: ${S.SHOT_NOTES[s.id]}${c.length ? ` · caption "${c.map((x: any) => x.text).join('", "')}" (${c.map((x: any) => `${x.from.toFixed(2)}-${x.to.toFixed(2)} s`).join(', ')})` : ''}`;
|
||||
}), ''];
|
||||
writeFileSync(join(dir, 'shots.md'), md.join('\n'));
|
||||
writeFileSync(join(OUT, 'animatic-checks.json'), JSON.stringify({ duration: DURATION, endLayout: layout }, null, 2));
|
||||
log(`animatic: ${dir} (draft.mp4, contact-sheet.png, shots.md); layout -> out/animatic-checks.json`);
|
||||
} finally { await browser.close(); }
|
||||
}
|
||||
|
||||
await main();
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
// Teaser timeline (v8, a story): the single source of truth for the picture (teaser.js) and the sound (music.js).
|
||||
// 58 s, 16:9. Little text: one short line per scene at most, big and calm; the music carries the arc
|
||||
// (calm -> tension -> near silence -> a driving build -> stop -> resolve). Every cut lands on a half-second beat.
|
||||
// Teaser timeline (v9, the story cut tight): the single source of truth for the picture (teaser.js) and the sound
|
||||
// (music.js). 41 s, 16:9, 120 BPM: every cut lands on a beat (a whole half second), every shot does one thing in
|
||||
// 2-4 s and cuts when that thing completes. Frame 0 already moves; nothing holds still longer than 0.8 s; the last
|
||||
// 4 s are the end card with `npx storebound init`.
|
||||
//
|
||||
// game 0-5 the demo game's real screens, a gentle drift "The game is done."
|
||||
// pile 5-11 real store work piles up: App Privacy answers, Data safety CSV, "Then come the stores."
|
||||
// screenshot sizes, price sheets
|
||||
// reject 11-15 one real scan finding (5.1.1(v)) grows to fill the frame, it darkens "One missing line, and you start over."
|
||||
// ask 15-19 near silence: one request typed to the AI agent "So I handed it all to my AI."
|
||||
// builds..consoles 19-37 the montage, one word at the edge: Builds. / Screenshots, every size. / Products, 175
|
||||
// countries. / Forms. / Consoles.
|
||||
// scan 37-42 `storebound scan`: each finding turns green, the re-scan is clean "Nothing left for a reviewer to catch."
|
||||
// button 42-48 the desktop app's Send for review, the pointer resting on Submit; the music stops "One button is yours."
|
||||
// waiting 48-53 the click, then "Waiting for review" (the app's own state); the music resolves
|
||||
// end 53-58 the logo, "From an empty folder to Submit.", "Open source · storebound.dev"
|
||||
// game 0-3 cold open: the demo game's real screens rush past, a push in "Your game is done."
|
||||
// pile 3-7 a wall of real store work lands, one card per beat "Then come the stores."
|
||||
// reject 7-9.5 the real scan finding 5.1.1(v) slams in, the picture darkens "One line. Rejected."
|
||||
// ask 9.5-12 one request typed to the AI agent, sent on the beat "So my AI did it."
|
||||
// builds..consoles 12-27 the montage, 3 s each, one word at the edge, each result lands: Builds. /
|
||||
// Screenshots, every size. / Products, 175 countries. / Forms. / Consoles.
|
||||
// scan 27-31 `storebound scan`: one finding turns green per beat, the clean re-scan "Nothing left to reject."
|
||||
// button 31-33 the desktop app's Send for review, the camera pushes onto Submit "You press Submit."
|
||||
// waiting 33-36 the click, then the app's own "Waiting for review" (same line)
|
||||
// end 36-41 the logo, `npx storebound init` typed out, storebound.dev (4 s hold)
|
||||
//
|
||||
// Every CLI line shown is real output; tests/teaser.test.ts runs each command and checks every line:
|
||||
// pile `storebound console plan apple-app-privacy` (answer sheet, steps), `storebound play data-safety --dry`
|
||||
|
|
@ -31,74 +31,91 @@ export const FPS = 30;
|
|||
/** 16:9 only: the master; render.ts also makes the 1280x720 web version from it. */
|
||||
export const FORMAT = { w: 1920, h: 1080 };
|
||||
|
||||
/** Scenes: id, length (s), the transition into it (s, centred on the cut; short = a cut on the beat). */
|
||||
/** Shots: id, length (s), the transition into it (s; 0 = a hard cut on the beat). */
|
||||
const LENGTHS = [
|
||||
['game', 5, 0], ['pile', 6, 0.6], ['reject', 4, 0.5], ['ask', 4, 0.8],
|
||||
['builds', 4, 0.1], ['shots', 4, 0.1], ['products', 3.5, 0.1], ['forms', 3.5, 0.1], ['consoles', 3, 0.1],
|
||||
['scan', 5, 0.1], ['button', 6, 0.5], ['waiting', 5, 0.1], ['end', 5, 0.8],
|
||||
['game', 3, 0], ['pile', 4, 0], ['reject', 2.5, 0], ['ask', 2.5, 0],
|
||||
['builds', 3, 0], ['shots', 3, 0], ['products', 3, 0], ['forms', 3, 0], ['consoles', 3, 0],
|
||||
['scan', 4, 0], ['button', 2, 0], ['waiting', 3, 0], ['end', 5, 0],
|
||||
];
|
||||
export const SCENES = LENGTHS.reduce((a, [id, len, x]) => { const start = a.at(-1)?.end ?? 0; return [...a, { id, start, end: start + len, x }]; }, []);
|
||||
export const DURATION = SCENES.at(-1).end;
|
||||
export const scene = id => SCENES.find(s => s.id === id);
|
||||
/** The montage: one word or short phrase at the edge, cuts on the beat. */
|
||||
export const MONTAGE = ['builds', 'shots', 'products', 'forms', 'consoles'];
|
||||
|
||||
/** One camera move per scene: from -> to (z = zoom, x / y = pan in px), a slow ease (story) or a quick push (montage). */
|
||||
export const CAMERA = {
|
||||
game: { from: { z: 1.0, x: 40, y: 10 }, to: { z: 1.07, x: -40, y: -6 } },
|
||||
pile: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.12, x: 0, y: 10 } },
|
||||
reject: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
|
||||
ask: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.04, x: 0, y: 0 } },
|
||||
builds: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.06, x: -20, y: -10 } },
|
||||
shots: { from: { z: 1.04, x: 30, y: 0 }, to: { z: 1.0, x: -30, y: 0 } },
|
||||
products: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.06, x: 20, y: -10 } },
|
||||
forms: { from: { z: 1.04, x: -20, y: 0 }, to: { z: 1.0, x: 20, y: 0 } },
|
||||
consoles: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
|
||||
scan: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: -10 } },
|
||||
button: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
|
||||
waiting: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
|
||||
end: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.03, x: 0, y: 0 } },
|
||||
/** Why each shot cuts where it does (the shot list, render.ts --animatic). */
|
||||
export const SHOT_NOTES = {
|
||||
game: 'the demo game\'s four real screens rush past, push in; cut as the push lands',
|
||||
pile: 'App Privacy, Data safety CSV, screenshot sizes, prices: one real card per beat; cut when the wall is full',
|
||||
reject: 'the real BLOCKER 5.1.1(v) finding slams in, push onto it, the picture darkens; cut when it fills the frame',
|
||||
ask: 'one request typed to the AI agent; cut on the send',
|
||||
builds: '`apple build --dry`, `play build --dry`: build 52 / versionCode 52; cut on "build planned"',
|
||||
shots: 'the demo game\'s renders: 7 store sizes x 4 languages fill in column by column; cut when the grid is full',
|
||||
products: '`play products --dry`, `catalog diff`: 175 region prices, 0 mismatches; cut on the 0',
|
||||
forms: 'scan\'s declarations proposal ticks the Data safety rows, the count runs to 768; cut when it lands',
|
||||
consoles: '`console plan apple-app-privacy`, then the app\'s plan panel, the owner\'s Publish ringed; cut on the ring',
|
||||
scan: '`storebound scan`: 5 findings turn green one per beat, the clean re-scan; cut on 0 blockers',
|
||||
button: 'the desktop app\'s Send for review, the camera pushes onto Submit, the pointer arrives; cut on arrival',
|
||||
waiting: 'the click, then the app\'s own "Waiting for review", ringed; cut when it settles',
|
||||
end: 'logo, `npx storebound init` typed out big, storebound.dev; holds 4 s (caret blink, slow push)',
|
||||
};
|
||||
|
||||
/** Captions: at most one short line per scene. top = the story line, big and centred at the top; edge = the montage
|
||||
* word, bottom left. at / until: seconds into the scene (fade in from `at`, gone at `until`). *word* = highlighted. */
|
||||
/** One camera move per shot: from -> to (z = zoom, x / y = pan in px), a quick push that settles at 80 % of the shot. */
|
||||
export const CAMERA = {
|
||||
game: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.14, x: 0, y: 30 } },
|
||||
pile: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 0, y: 10 } },
|
||||
reject: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
|
||||
ask: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.12, x: -60, y: 0 } },
|
||||
builds: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 40, y: -40 } },
|
||||
shots: { from: { z: 0.98, x: 0, y: 0 }, to: { z: 1.08, x: 0, y: 0 } },
|
||||
products: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 60, y: -50 } },
|
||||
forms: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.07, x: 30, y: 0 } },
|
||||
consoles: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
|
||||
scan: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.08, x: 0, y: -20 } },
|
||||
button: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
|
||||
waiting: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
|
||||
end: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: 0 } },
|
||||
};
|
||||
|
||||
/** Captions: at most six words, each fully on screen for at least 2 s. top = the story line, big and centred at the
|
||||
* top; edge = the montage word, bottom left. at / until: absolute seconds. *word* = highlighted. */
|
||||
export const CAPTIONS = [
|
||||
{ id: 'game', pos: 'top', text: 'The game is *done.*', at: 0.6, until: 4.85 },
|
||||
{ id: 'pile', pos: 'top', text: 'Then come *the stores.*', at: 0.5, until: 5.9 },
|
||||
{ id: 'reject', pos: 'top', text: 'One missing line, and you *start over.*', at: 0.35, until: 3.95, hl: 'red' },
|
||||
{ id: 'ask', pos: 'top', text: 'So I handed it all *to my AI.*', at: 0.35, until: 3.95 },
|
||||
{ id: 'builds', pos: 'edge', text: 'Builds.', at: 0.05, until: 3.95 },
|
||||
{ id: 'shots', pos: 'edge', text: 'Screenshots, every size.', at: 0.05, until: 3.95 },
|
||||
{ id: 'products', pos: 'edge', text: 'Products, 175 countries.', at: 0.05, until: 3.45 },
|
||||
{ id: 'forms', pos: 'edge', text: 'Forms.', at: 0.05, until: 3.45 },
|
||||
{ id: 'consoles', pos: 'edge', text: 'Consoles.', at: 0.05, until: 2.95 },
|
||||
{ id: 'scan', pos: 'top', text: 'Nothing left for a reviewer *to catch.*', at: 1.5, until: 4.95, hl: 'green' },
|
||||
{ id: 'button', pos: 'top', text: 'One button is *yours.*', at: 0.9, until: 5.9 },
|
||||
{ id: 'game', pos: 'top', text: 'Your game is *done.*', at: 0.1, until: 2.95 },
|
||||
{ id: 'pile', pos: 'top', text: 'Then come *the stores.*', at: 3.05, until: 6.95 },
|
||||
{ id: 'reject', pos: 'top', text: 'One line. *Rejected.*', at: 7.05, until: 9.45, hl: 'red' },
|
||||
{ id: 'ask', pos: 'top', text: 'So my AI *did it.*', at: 9.55, until: 11.95 },
|
||||
{ id: 'builds', pos: 'edge', text: 'Builds.', at: 12.05, until: 14.95 },
|
||||
{ id: 'shots', pos: 'edge', text: 'Screenshots, every size.', at: 15.05, until: 17.95 },
|
||||
{ id: 'products', pos: 'edge', text: 'Products, 175 countries.', at: 18.05, until: 20.95 },
|
||||
{ id: 'forms', pos: 'edge', text: 'Forms.', at: 21.05, until: 23.95 },
|
||||
{ id: 'consoles', pos: 'edge', text: 'Consoles.', at: 24.05, until: 26.95 },
|
||||
{ id: 'scan', pos: 'top', text: 'Nothing left to *reject.*', at: 28.6, until: 30.95, hl: 'green' },
|
||||
{ id: 'button', pos: 'top', text: 'You press *Submit.*', at: 31.1, until: 35.95 },
|
||||
];
|
||||
/** Fades (s): the story lines slow and soft, the montage words quick. */
|
||||
export const CAP_FADE = { top: [0.5, 0.4], edge: [0.15, 0.15] };
|
||||
/** When each caption is fully on screen (tests: at least 2.5 s, inside its scene). */
|
||||
/** Fades (s): quick, a cut is a cut. */
|
||||
export const CAP_FADE = { top: [0.12, 0.08], edge: [0.1, 0.08] };
|
||||
/** When each caption is fully on screen. */
|
||||
export function captionTimes() {
|
||||
return CAPTIONS.map(c => {
|
||||
const s = scene(c.id), [fin, fout] = CAP_FADE[c.pos];
|
||||
const at = s.start + c.at, from = at + fin, to = s.start + c.until - fout, gone = s.start + c.until;
|
||||
return { id: c.id, text: c.text.replace(/\*/g, ''), pos: c.pos, at, from, to, gone, hold: to - from };
|
||||
const [fin, fout] = CAP_FADE[c.pos];
|
||||
const from = c.at + fin, to = c.until - fout;
|
||||
return { id: c.id, text: c.text.replace(/\*/g, ''), pos: c.pos, at: c.at, from, to, gone: c.until, hold: to - from };
|
||||
});
|
||||
}
|
||||
/** The dark veil over the picture (0-1) at time t: it falls over the rejection, holds through the request, lifts on
|
||||
* the first beat of the montage. */
|
||||
/** The dark veil over the picture (0-1) at time t: it falls with the rejection, holds through the request, gone on the
|
||||
* first beat of the montage. */
|
||||
export function veil(t) {
|
||||
const r = scene('reject'), a = scene('ask');
|
||||
if (t < r.start + 0.4) return 0;
|
||||
if (t < r.end) return 0.72 * Math.min(1, (t - r.start - 0.4) / 3.2);
|
||||
if (t < a.end - 0.05) return 0.72 - 0.12 * Math.min(1, (t - a.start) / 1.5);
|
||||
if (t < r.start + 0.15) return 0;
|
||||
if (t < r.end) return 0.7 * Math.min(1, (t - r.start - 0.15) / 1.6);
|
||||
if (t < a.end) return 0.6;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------- 1 game: the demo game's real screens ----------
|
||||
/** examples/demo-game/store/shots/frames/en/phone/<id>.png (the game's own captures), left to right. */
|
||||
export const GAME_SCREENS = ['play', 'combo', 'map', 'stars'];
|
||||
export const GAME_AT = 0.15, GAME_STEP = 0.22;
|
||||
/** The cold open: the screens rush past (px/s) while the camera pushes in; already moving on frame 0. */
|
||||
export const GAME_AT = 0, GAME_STEP = 0, GAME_SPEED = 520;
|
||||
|
||||
// ---------- 2 pile: the store work piles up ----------
|
||||
/** Real lines (see the header). */
|
||||
|
|
@ -157,16 +174,15 @@ export const PRICE_CMD = 'prices ppp --offline --dry --store apple --only com.ex
|
|||
/** The cards that drop onto the pile, faster and faster. at = seconds into the scene; x / y = the card's centre
|
||||
* (px), w = width, rot = degrees. kind: what it shows. */
|
||||
export const PILE = [
|
||||
{ at: 0.25, kind: 'privacy', x: 700, y: 560, w: 1000, rot: -3 },
|
||||
{ at: 1.25, kind: 'csv', x: 1220, y: 600, w: 980, rot: 2.5 },
|
||||
{ at: 2.15, kind: 'shots', x: 860, y: 640, w: 900, rot: -1.5 },
|
||||
{ at: 2.95, kind: 'prices', x: 1180, y: 560, w: 760, rot: 4 },
|
||||
{ at: 3.6, kind: 'steps', x: 760, y: 690, w: 1040, rot: 2 },
|
||||
{ at: 4.15, kind: 'csv', x: 1060, y: 610, w: 1000, rot: -4.5, from: 7 },
|
||||
{ at: 4.6, kind: 'shots', x: 940, y: 560, w: 940, rot: 3.5, lang: 'ja' },
|
||||
{ at: 5.0, kind: 'prices', x: 820, y: 640, w: 800, rot: -2.5, from: 11 },
|
||||
{ at: 5.35, kind: 'privacy', x: 1040, y: 600, w: 1060, rot: 1, from: 6 },
|
||||
];
|
||||
{ at: 0.0, kind: 'privacy', x: 640, y: 540, w: 1000, rot: -3 },
|
||||
{ at: 0.5, kind: 'csv', x: 1260, y: 600, w: 980, rot: 2.5 },
|
||||
{ at: 1.0, kind: 'shots', x: 820, y: 680, w: 900, rot: -1.5 },
|
||||
{ at: 1.5, kind: 'prices', x: 1240, y: 520, w: 760, rot: 4 },
|
||||
{ at: 2.0, kind: 'steps', x: 760, y: 700, w: 1040, rot: 2 },
|
||||
{ at: 2.5, kind: 'csv', x: 1100, y: 620, w: 1000, rot: -4.5, from: 7 },
|
||||
{ at: 3.0, kind: 'shots', x: 940, y: 560, w: 940, rot: 3.5, lang: 'ja' },
|
||||
{ at: 3.5, kind: 'prices', x: 980, y: 640, w: 820, rot: -2.5, from: 11 },
|
||||
]
|
||||
/** The card headers (what each card is; no claims). */
|
||||
export const PILE_TITLES = { privacy: 'App Privacy · answer sheet', steps: 'App Privacy · console steps', csv: `Data safety · ${DECL_TOTAL} rows`, shots: 'Screenshots', prices: 'Prices · every region' };
|
||||
|
||||
|
|
@ -176,13 +192,14 @@ export const REJECT = {
|
|||
head: 'BLOCKER 5.1.1(v) · App Store · Accounts can be created but no in-app account deletion was found',
|
||||
at: 'at Planner/LoginView.swift:11 Button("Create account") { Task { try await AccountApi.signUp() } }',
|
||||
};
|
||||
export const REJECT_GROW = [0.6, 1.12];
|
||||
/** The slam (scale from -> 1 in `slam` s), then a push onto the rule number. */
|
||||
export const REJECT_GROW = [1.0, 1.28], REJECT_SLAM = { from: 1.7, d: 0.16 };
|
||||
|
||||
// ---------- 4 ask: the request to the agent ----------
|
||||
export const PROMPT = 'Get this game ready for review on both stores.';
|
||||
export const PROMPT_AT = 0.7, PROMPT_CPS = 24;
|
||||
export const PROMPT_AT = 0.1, PROMPT_CPS = 31;
|
||||
/** Enter: the request is sent (a soft accent), seconds into the scene. */
|
||||
export const PROMPT_SENT = 3.05;
|
||||
export const PROMPT_SENT = 2.0;
|
||||
|
||||
// ---------- 5 montage ----------
|
||||
/** builds: real lines of `apple build --dry` and `play build --dry` (clock prefix and "(0s)" left out). */
|
||||
|
|
@ -202,18 +219,18 @@ export const SHOT_SIZES = [
|
|||
{ id: 'play', label: 'Play phone' }, { id: 'tab7', label: 'Play 7″' }, { id: 'tab10', label: 'Play 10″' }, { id: 'feature', label: 'Feature graphic' },
|
||||
];
|
||||
export const SHOT_LANGS = ['en', 'tr', 'ja', 'ar'];
|
||||
export const SHOTS_AT = 0.1, SHOTS_STEP = 0.2;
|
||||
export const SHOTS_AT = 0.05, SHOTS_STEP = 0.3, SHOTS_ROW = 0.1;
|
||||
/** products: real output (clock prefix and the "(0s)" timer left out). */
|
||||
export const PRODUCT_LINES = [
|
||||
{ at: 0.05, text: '$ storebound play products --dry', cmd: true },
|
||||
{ at: 0.3, text: '[play] 1/2 com.example.app.no_ads: would create · 174 regions · 2 listings (dry)' },
|
||||
{ at: 0.48, text: '[play] 2/2 com.example.app.gems_100: would create · 174 regions · 2 listings (dry)' },
|
||||
{ at: 0.8, text: '$ storebound catalog diff', cmd: true },
|
||||
{ at: 1.05, text: 'ok [apple] com.example.app.gems_100: consumable, 175 region prices, READY_TO_SUBMIT' },
|
||||
{ at: 1.2, text: 'ok [play] com.example.app.gems_100: consumable, 174 region prices, ACTIVE' },
|
||||
{ at: 1.35, text: 'ok [apple] com.example.app.no_ads: non_consumable, 175 region prices, READY_TO_SUBMIT' },
|
||||
{ at: 1.5, text: 'ok [play] com.example.app.no_ads: non_consumable, 174 region prices, ACTIVE' },
|
||||
{ at: 1.75, text: '[catalog] 2 catalog items: 0 mismatch(es), 0 warning(s), 0 price difference(s) between the stores' },
|
||||
{ at: 0.07, text: '$ storebound play products --dry', cmd: true },
|
||||
{ at: 0.41, text: '[play] 1/2 com.example.app.no_ads: would create · 174 regions · 2 listings (dry)' },
|
||||
{ at: 0.65, text: '[play] 2/2 com.example.app.gems_100: would create · 174 regions · 2 listings (dry)' },
|
||||
{ at: 1.08, text: '$ storebound catalog diff', cmd: true },
|
||||
{ at: 1.42, text: 'ok [apple] com.example.app.gems_100: consumable, 175 region prices, READY_TO_SUBMIT' },
|
||||
{ at: 1.62, text: 'ok [play] com.example.app.gems_100: consumable, 174 region prices, ACTIVE' },
|
||||
{ at: 1.82, text: 'ok [apple] com.example.app.no_ads: non_consumable, 175 region prices, READY_TO_SUBMIT' },
|
||||
{ at: 2.03, text: 'ok [play] com.example.app.no_ads: non_consumable, 174 region prices, ACTIVE' },
|
||||
{ at: 2.36, text: '[catalog] 2 catalog items: 0 mismatch(es), 0 warning(s), 0 price difference(s) between the stores' },
|
||||
];
|
||||
/** forms: the declarations proposal (scan of the teaser fixture), then the Data safety rows it fills. */
|
||||
export const DECL_LINES = [
|
||||
|
|
@ -253,10 +270,10 @@ export const FINDINGS = [
|
|||
'HIGH 3.1.1 · App Store · Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found',
|
||||
'HIGH 5.1.2(i) · App Store · Tracking-capable code without App Tracking Transparency (Google AdMob, IDFA)',
|
||||
];
|
||||
export const FINDINGS_AT = 0.4, FINDINGS_STEP = 0.12;
|
||||
export const FINDINGS_AT = 0.2, FINDINGS_STEP = 0.06;
|
||||
/** Each finding turns green (fixed) on a beat, then the re-scan of the fixed copy (tests/fixtures/scan/teaser-fixed). */
|
||||
export const FIX_AT = [1.0, 1.5, 2.0, 2.5, 3.0];
|
||||
export const RESCAN_AT = 3.45;
|
||||
export const FIX_AT = [0.5, 1.0, 1.5, 2.0, 2.5];
|
||||
export const RESCAN_AT = 3.0;
|
||||
export const RESCAN_LINE = 'Rejection risks: 0 blocker, 0 high, 0 medium, 0 low';
|
||||
|
||||
// ---------- desktop: real frames of the app in fixture mode (desktop.ts) ----------
|
||||
|
|
@ -269,21 +286,21 @@ export const DESK = {
|
|||
groups: [{ frame: 'console-plan' }],
|
||||
show: CONSOLES_SWAP,
|
||||
cam: { from: { f: [0.5, 0.56], z: 1.12 }, to: { f: [0.5, 0.58], z: 1.22 } },
|
||||
rings: [{ at: 1.6, until: 3.2, target: 'cpFinal', green: true }],
|
||||
rings: [{ at: 1.5, until: 3.4, target: 'cpFinal', green: true }],
|
||||
},
|
||||
// Releases > Send for review: build 52 picked, the one button left; the camera settles on it, the pointer rests
|
||||
button: {
|
||||
groups: [{ frame: 'submit-ready' }],
|
||||
cam: { from: { f: [0.45, 0.5], z: 1.0 }, to: { f: [0.3, 0.68], z: 1.55 } },
|
||||
rings: [{ at: 3.2, until: 6.3, target: 'submitBtn' }],
|
||||
pointer: { at: 1.9, d: 1.1, target: 'submitBtn' },
|
||||
rings: [{ at: 1.3, until: 2.6, target: 'submitBtn' }],
|
||||
pointer: { at: 0.4, d: 0.9, target: 'submitBtn' },
|
||||
},
|
||||
// the press, then the app's own "Waiting for review" state
|
||||
waiting: {
|
||||
groups: [{ frame: 'submit-ready' }, { frame: 'submit-waiting' }],
|
||||
swap: [0.45, 0.9],
|
||||
cam: { from: { f: [0.3, 0.68], z: 1.55 }, to: { f: [0.4, 0.4], z: 1.3 } },
|
||||
rings: [{ at: 1.2, until: 5.3, target: 'waitingItem', green: true }],
|
||||
rings: [{ at: 1.0, until: 3.4, target: 'waitingItem', green: true }],
|
||||
pointer: { at: -1, d: 0.01, target: 'submitBtn', click: 0.2, until: 0.6 },
|
||||
},
|
||||
};
|
||||
|
|
@ -293,11 +310,13 @@ export const DESK_FRAMES = [...new Set(Object.values(DESK).flatMap(d => d.groups
|
|||
/** The click on Submit (absolute seconds): the music's resolve starts here. */
|
||||
export const CLICK_T = scene('waiting').start + DESK.waiting.pointer.click;
|
||||
|
||||
// ---------- 9 end card ----------
|
||||
/** Seconds into the end scene at which each line comes in. */
|
||||
export const END = { markAt: 0.25, wordAt: 0.5, lineAt: 0.9, urlAt: 1.25, d: 0.6 };
|
||||
export const END_LINE = 'From an empty folder to Submit.';
|
||||
export const END_URL = 'Open source · storebound.dev';
|
||||
// ---------- 9 end card: the last thing on screen ----------
|
||||
/** Seconds into the end shot: the logo and word slam in, the install line is typed, then the site. Everything is on
|
||||
* screen by `full`; the rest (at least 4 s) holds with a blinking caret and a slow push. */
|
||||
export const END_CMD = 'npx storebound init';
|
||||
export const END_URL = 'storebound.dev';
|
||||
export const END = { markAt: 0, wordAt: 0.06, cmdAt: 0.15, cps: 40, urlAt: 0.7, d: 0.2 };
|
||||
END.full = Math.max(END.cmdAt + END_CMD.length / END.cps, END.urlAt + END.d);
|
||||
|
||||
// ---------- sound cues (music.js) ----------
|
||||
/** Cuts with a sound accent (every scene change but the seamless button -> waiting one). */
|
||||
|
|
@ -321,5 +340,6 @@ export function ticks() {
|
|||
export function keyTimes() {
|
||||
const out = [];
|
||||
for (let i = 0; i < PROMPT.length; i++) if (PROMPT[i] !== ' ' || i % 2) out.push(scene('ask').start + PROMPT_AT + i / PROMPT_CPS);
|
||||
for (let i = 0; i < END_CMD.length; i++) out.push(scene('end').start + END.cmdAt + i / END.cps);
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, '<').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, '<span class="hl">$1</span>');
|
||||
|
|
@ -44,11 +46,11 @@ function scenes(t) {
|
|||
const from = i === 0 ? -1 : s.start - s.x / 2, until = next ? next.start + next.x / 2 : Infinity;
|
||||
if (t < from || t >= until) { set(el, 'visibility', 'hidden'); set(el, 'opacity', '0'); return; }
|
||||
set(el, 'visibility', 'visible');
|
||||
// the first scene comes up from black; the others dissolve in over the previous one
|
||||
set(el, 'opacity', String(i === 0 ? eio(prog(t, 0, 0.9)) : eio(prog(t, from, s.x))));
|
||||
// hard cuts on the beat: the first frame is already the product, no fade from black
|
||||
set(el, 'opacity', String(i === 0 ? 1 : eio(prog(t, from, s.x))));
|
||||
const c = CAMERA[s.id];
|
||||
const raw = (t - s.start) / (s.end - s.start);
|
||||
const p = MONTAGE.includes(s.id) ? ecam(raw) : esin(raw);
|
||||
const p = ecam(raw);
|
||||
const z = lerp(c.from.z, c.to.z, p), x = lerp(c.from.x, c.to.x, p), y = lerp(c.from.y, c.to.y, p);
|
||||
set(cam, 'transform', `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) scale(${z.toFixed(5)})`);
|
||||
});
|
||||
|
|
@ -67,20 +69,21 @@ function background(t) {
|
|||
// ---------- captions: the story line (top) and the montage word (edge) ----------
|
||||
const capEl = $('#cap'), capBand = $('#capband'), edgeEl = $('#edge'), edgeBand = $('#edgeband');
|
||||
const CT = Object.fromEntries(captionTimes().map(c => [c.id, c]));
|
||||
let capId = null;
|
||||
let capId = null, edgeId = null;
|
||||
function captions(t) {
|
||||
const s = active(t), c = CAPTIONS.find(x => x.id === s.id);
|
||||
let top = 0, edge = 0, rise = 0;
|
||||
if (c) {
|
||||
for (const c of CAPTIONS) {
|
||||
if (t < c.at || t >= c.until) continue;
|
||||
const ct = CT[c.id], [fin, fout] = CAP_FADE[c.pos];
|
||||
const pin = eoc(prog(t, ct.at, fin)), pout = prog(t, ct.to, fout);
|
||||
const op = pin * (1 - pout);
|
||||
rise = (1 - pin) * 14 - pout * 8;
|
||||
if (capId !== c.id) {
|
||||
capId = c.id;
|
||||
if (c.pos === 'top') { capEl.className = c.hl ?? ''; capEl.innerHTML = hl(c.text); } else edgeEl.innerHTML = hl(c.text);
|
||||
if (c.pos === 'top') {
|
||||
top = op; rise = (1 - pin) * 14;
|
||||
if (capId !== c.id) { capId = c.id; capEl.className = c.hl ?? ''; capEl.innerHTML = hl(c.text); }
|
||||
} else {
|
||||
edge = op;
|
||||
if (edgeId !== c.id) { edgeId = c.id; edgeEl.innerHTML = hl(c.text); }
|
||||
}
|
||||
if (c.pos === 'top') top = op; else edge = op;
|
||||
}
|
||||
set(capEl, 'opacity', String(top)); set(capBand, 'opacity', String(top));
|
||||
set(capEl, 'transform', `translateY(${rise.toFixed(1)}px)`);
|
||||
|
|
@ -90,22 +93,24 @@ function captions(t) {
|
|||
|
||||
// ---------- 1 game: the game's own screens ----------
|
||||
const gameEl = $('#game');
|
||||
const phones = GAME_SCREENS.map((id, i) => {
|
||||
const PHONE_GAP = 400, PHONES = [...GAME_SCREENS, ...GAME_SCREENS, ...GAME_SCREENS];
|
||||
const phones = PHONES.map(id => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'phone';
|
||||
Object.assign(el.style, { left: `${195 + i * 400}px`, backgroundImage: `url(out/assets/game/${id}.jpg)` });
|
||||
el.style.backgroundImage = `url(out/assets/game/${id}.jpg)`;
|
||||
gameEl.appendChild(el);
|
||||
return el;
|
||||
});
|
||||
function game(t) {
|
||||
const s = scene('game'); if (t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
// the screens rush past from frame 0 (no fade-in): a conveyor, each on its own bob
|
||||
phones.forEach((el, i) => {
|
||||
const p = prog(r, GAME_AT + i * GAME_STEP, 0.9);
|
||||
set(el, 'opacity', String(eoc(p)));
|
||||
// a slow float, each phone on its own phase
|
||||
const fy = Math.sin(r * 0.9 + i * 1.3) * 6;
|
||||
set(el, 'transform', `translateY(${((1 - eoc(p)) * 40 + fy).toFixed(1)}px) rotate(${((i - 1.5) * 1.2).toFixed(2)}deg)`);
|
||||
const x = 160 + i * PHONE_GAP - GAME_SPEED * r;
|
||||
const fy = Math.sin(r * 3 + i * 1.3) * 10;
|
||||
set(el, 'opacity', '1');
|
||||
set(el, 'left', `${x.toFixed(1)}px`);
|
||||
set(el, 'transform', `translateY(${fy.toFixed(1)}px) rotate(${(((i % 4) - 1.5) * 1.6).toFixed(2)}deg)`);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -153,14 +158,14 @@ function pile(t) {
|
|||
const s = scene('pile'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
pileCards.forEach(({ c, el, rows, shade, maxScroll }, i) => {
|
||||
const p = prog(r, c.at, 0.32);
|
||||
set(el, 'opacity', String(clamp(p * 2.5)));
|
||||
const p = prog(r, c.at - LEAD, 0.2);
|
||||
set(el, 'opacity', String(clamp(p * 3)));
|
||||
const e = eoc(p);
|
||||
set(el, 'transform', `translateY(${lerp(-70, 0, e).toFixed(1)}px) rotate(${lerp(c.rot * 2.2, c.rot, e).toFixed(2)}deg) scale(${lerp(1.22, 1, e).toFixed(4)})`);
|
||||
// the rows keep streaming: there is always more
|
||||
if (rows) set(rows, 'transform', `translateY(${(-Math.min(maxScroll, Math.max(0, r - c.at) * 34)).toFixed(1)}px)`);
|
||||
// older cards sink into the shadow of the new ones
|
||||
const above = PILE.slice(i + 1).reduce((k, x) => k + eoc(prog(r, x.at, 0.32)), 0);
|
||||
const above = PILE.slice(i + 1).reduce((k, x) => k + eoc(prog(r, x.at - LEAD, 0.2)), 0);
|
||||
set(shade, 'opacity', String(Math.min(0.6, above * 0.16).toFixed(3)));
|
||||
});
|
||||
}
|
||||
|
|
@ -175,9 +180,12 @@ const findEl = $('#finding');
|
|||
function reject(t) {
|
||||
const s = scene('reject'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
set(findEl, 'opacity', String(eoc(prog(r, -0.1, 0.45))));
|
||||
const z = lerp(REJECT_GROW[0], REJECT_GROW[1], esin(prog(r, -0.1, s.end - s.start + 0.3)));
|
||||
set(findEl, 'transform', `translate(-50%, -50%) scale(${z.toFixed(4)})`);
|
||||
// the slam: in at once, overshooting scale down to 1 with a short shake; then a push onto the rule
|
||||
const sp = prog(r, -LEAD, REJECT_SLAM.d + LEAD);
|
||||
set(findEl, 'opacity', String(clamp(sp * 4)));
|
||||
const z = lerp(REJECT_SLAM.from, 1, eoc(sp)) * lerp(REJECT_GROW[0], REJECT_GROW[1], ecam(prog(r, REJECT_SLAM.d, s.end - s.start - REJECT_SLAM.d)));
|
||||
const shake = r < 0.45 && r > REJECT_SLAM.d ? Math.sin(r * 90) * 14 * (1 - (r - REJECT_SLAM.d) / (0.45 - REJECT_SLAM.d)) : 0;
|
||||
set(findEl, 'transform', `translate(calc(-50% + ${shake.toFixed(1)}px), -50%) scale(${z.toFixed(4)})`);
|
||||
}
|
||||
|
||||
// ---------- 4 ask: the request to the agent ----------
|
||||
|
|
@ -185,7 +193,7 @@ const promptEl = $('#prompt'), promptTxt = $('#prompt .txt'), sentEl = $('#promp
|
|||
function ask(t) {
|
||||
const s = scene('ask'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
appear(promptEl, r, 0.15, 0.6, 16);
|
||||
appear(promptEl, r, -0.05, 0.12, 0);
|
||||
const n = clamp(Math.floor((r - PROMPT_AT) * PROMPT_CPS), 0, PROMPT.length);
|
||||
const caret = r < PROMPT_SENT && (n < PROMPT.length || Math.floor(r * 2.2) % 2 === 0);
|
||||
html(promptTxt, `${esc(PROMPT.slice(0, n))}${caret ? '<span class="caret"></span>' : ''}`);
|
||||
|
|
@ -209,7 +217,7 @@ function montageTerm(id, sel, lns, lines) {
|
|||
const s = scene(id); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
appear($(sel), r, -0.1, 0.2, 10);
|
||||
lns.forEach((el, i) => appear(el, r, lines[i].at, 0.18, 6));
|
||||
lns.forEach((el, i) => appear(el, r, lines[i].at - LEAD, 0.18, 6));
|
||||
};
|
||||
}
|
||||
const builds = montageTerm('builds', '#buildterm', buildLns, BUILD_LINES);
|
||||
|
|
@ -251,10 +259,10 @@ const shotTiles = [], shotLabels = [], langLabels = [];
|
|||
function shots(t) {
|
||||
const s = scene('shots'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
langLabels.forEach(el => appear(el, r, 0, 0.2, 0));
|
||||
shotLabels.forEach(({ el, col }) => set(el, 'opacity', String(eoc(prog(r, SHOTS_AT + col * SHOTS_STEP, 0.25)))));
|
||||
langLabels.forEach(el => appear(el, r, -0.05, 0.1, 0));
|
||||
shotLabels.forEach(({ el, col }) => set(el, 'opacity', String(eoc(prog(r, SHOTS_AT - LEAD + col * SHOTS_STEP, 0.2)))));
|
||||
for (const { el, col, row } of shotTiles) {
|
||||
const p = prog(r, SHOTS_AT + col * SHOTS_STEP + row * 0.05, 0.28);
|
||||
const p = prog(r, SHOTS_AT - LEAD + col * SHOTS_STEP + row * SHOTS_ROW, 0.22);
|
||||
set(el, 'opacity', String(eoc(p)));
|
||||
set(el, 'transform', `scale(${lerp(0.86, 1, eoc(p)).toFixed(4)})`);
|
||||
}
|
||||
|
|
@ -272,7 +280,7 @@ function forms(t) {
|
|||
const r = t - s.start;
|
||||
appear($('#prop'), r, -0.1, 0.2, 10);
|
||||
appear($('#form'), r, 0.0, 0.2, 10);
|
||||
propLns.forEach((el, i) => appear(el, r, i < DECL_LINES.length ? DECL_AT + i * DECL_STEP : ASK_AT, 0.2, 6));
|
||||
propLns.forEach((el, i) => appear(el, r, i < DECL_LINES.length ? DECL_AT - LEAD + i * DECL_STEP : ASK_AT, 0.2, 6));
|
||||
set(askGlow, 'opacity', String(eoc(prog(r, ASK_AT + 0.1, 0.3))));
|
||||
formRows.forEach((el, i) => {
|
||||
const p = prog(r, FORM_AT + i * FORM_STEP, 0.2);
|
||||
|
|
@ -301,7 +309,7 @@ function consoles(t) {
|
|||
const r = t - s.start;
|
||||
const out = 1 - eio(prog(r, CONSOLES_SWAP[0], CONSOLES_SWAP[1]));
|
||||
set(cpTerm, 'opacity', String(eoc(prog(r, -0.1, 0.2)) * out));
|
||||
cpLns.forEach((el, i) => appear(el, r, CONSOLE_LINES[i].at, 0.15, 5));
|
||||
cpLns.forEach((el, i) => appear(el, r, CONSOLE_LINES[i].at - LEAD, 0.15, 5));
|
||||
set(cpGlow, 'opacity', String(eoc(prog(r, CONSOLE_LINES.at(-1).at + 0.08, 0.2))));
|
||||
}
|
||||
|
||||
|
|
@ -320,10 +328,10 @@ function scan(t) {
|
|||
const s = scene('scan'); if (t < s.start - 1 || t > s.end + 1) return;
|
||||
const r = t - s.start;
|
||||
appear($('#scanterm'), r, -0.1, 0.2, 10);
|
||||
appear(scanLns[0], r, 0.05, 0.15, 4);
|
||||
appear(scanLns[1], r, 0.25, 0.2, 6);
|
||||
appear(scanLns[0], r, -LEAD, 0.12, 4);
|
||||
appear(scanLns[1], r, 0.05, 0.15, 6);
|
||||
fdEls.forEach((el, i) => {
|
||||
appear(el, r, FINDINGS_AT + i * FINDINGS_STEP, 0.25, 8);
|
||||
appear(el, r, FINDINGS_AT - LEAD + i * FINDINGS_STEP, 0.2, 8);
|
||||
const f = prog(r, FIX_AT[i], 0.3);
|
||||
set(el.querySelector('.tint.g'), 'opacity', String(eoc(f)));
|
||||
set(el.querySelector('.tint.b'), 'opacity', String(1 - eoc(f)));
|
||||
|
|
@ -354,7 +362,7 @@ const desks = DESK_SCENES.map(id => {
|
|||
});
|
||||
function deskView(dk, r) {
|
||||
const s = scene(dk.id), len = s.end - s.start, { WIN } = dk;
|
||||
const p = dk.id === 'consoles' ? ecam(clamp(r / len)) : esin(clamp(r / len));
|
||||
const p = ecam(clamp(r / len));
|
||||
const { from, to } = dk.d.cam;
|
||||
const S = WIN.s0 * lerp(from.z, to.z, p), fx = lerp(from.f[0], to.f[0], p), fy = lerp(from.f[1], to.f[1], p);
|
||||
const ox = clamp(WIN.w / 2 - fx * 1280 * S, WIN.w - 1280 * S, 0), oy = clamp(WIN.vh / 2 - fy * 800 * S, WIN.vh - 800 * S, 0);
|
||||
|
|
@ -394,15 +402,29 @@ function deskScene(dk, t) {
|
|||
}
|
||||
const desktop = t => desks.forEach(dk => deskScene(dk, t));
|
||||
|
||||
// ---------- 9 end card ----------
|
||||
const brand = $('#brand');
|
||||
brand.querySelector('.line').textContent = END_LINE;
|
||||
// ---------- 9 end card: the logo, the install line typed out, the site; the last thing on screen ----------
|
||||
const brand = $('#brand'), endCmd = $('#brand .cmdl .t'), endCaret = $('#brand .cmdl .caret');
|
||||
brand.querySelector('.url').textContent = END_URL;
|
||||
const brandEls = [['.mark', END.markAt], ['.word', END.wordAt], ['.line', END.lineAt], ['.url', END.urlAt]].map(([s, at]) => [brand.querySelector(s), at]);
|
||||
const brandEls = [['.mark', END.markAt], ['.word', END.wordAt], ['.cmdl', END.cmdAt - 0.05], ['.url', END.urlAt]].map(([s, at]) => [brand.querySelector(s), at]);
|
||||
function end(t) {
|
||||
const s = scene('end'); if (t < s.start - 1) return;
|
||||
const r = t - s.start;
|
||||
for (const [el, at] of brandEls) appear(el, r, at, END.d, 18);
|
||||
for (const [el, at] of brandEls) {
|
||||
const p = prog(r, at - LEAD, END.d);
|
||||
set(el, 'opacity', String(clamp(p * 2)));
|
||||
set(el, 'transform', `scale(${lerp(1.25, 1, eoc(p)).toFixed(4)})`);
|
||||
}
|
||||
const n = clamp(Math.floor((r - END.cmdAt) * END.cps), 0, END_CMD.length);
|
||||
html(endCmd, esc(END_CMD.slice(0, n)));
|
||||
// the caret: solid while typing, then a steady blink (two per second, on the beat) through the hold
|
||||
set(endCaret, 'opacity', r < END.full || Math.floor((r - END.full) * 2) % 2 === 0 ? '1' : '0');
|
||||
}
|
||||
/** Layout data for the animatic checks: the end card's install line, as drawn at time t. */
|
||||
function layout(t) {
|
||||
seek(t);
|
||||
const sc = document.querySelector('#s-end'), el = $('#brand .cmdl'), b = el.getBoundingClientRect();
|
||||
return { t, endVisible: getComputedStyle(sc).visibility === 'visible' && Number(sc.style.opacity) > 0.99, text: endCmd.textContent,
|
||||
opacity: Number(el.style.opacity), box: { x: b.x, y: b.y, w: b.width, h: b.height }, fontPx: parseFloat(getComputedStyle(el).fontSize) };
|
||||
}
|
||||
|
||||
function seek(t) {
|
||||
|
|
@ -440,7 +462,7 @@ const ready = (async () => {
|
|||
for (let i = 0; i < all.length; i += 8) await Promise.all(all.slice(i, i + 8).map(decode));
|
||||
seek(0);
|
||||
})();
|
||||
window.teaser = { ready, seek, DURATION, FPS, W, H, captionTimes };
|
||||
window.teaser = { ready, seek, layout, DURATION, FPS, W, H, captionTimes };
|
||||
|
||||
if (q.has('t')) ready.then(() => seek(Number(q.get('t'))));
|
||||
if (q.has('play')) ready.then(() => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// The teaser video (media/teaser, v8, a story): every CLI line it shows is real output of the command it names, the game
|
||||
// screens and screenshot tiles are the demo game's own, every desktop frame is a real capture of the app (desktop.ts),
|
||||
// every cut lands on a beat of the music, each caption is short and fully on screen for at least 2.5 s, the whole cut is
|
||||
// 56-62 s, and it is 16:9 only.
|
||||
// The teaser video (media/teaser, v9, the story cut tight): every CLI line it shows is real output of the command it
|
||||
// names, the game screens and screenshot tiles are the demo game's own, every desktop frame is a real capture of the app
|
||||
// (desktop.ts), every cut lands on a beat of the music, each shot is 2-4 s, each caption is at most six words and fully
|
||||
// on screen for at least 2 s, the whole cut is 40-48 s, frame 0 already moves, the last 4 s hold the end card with
|
||||
// `npx storebound init`, and it is 16:9 only.
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
|
|
@ -22,7 +23,7 @@ interface Teaser {
|
|||
CAPTIONS: { id: string; pos: 'top' | 'edge'; text: string; at: number; until: number }[];
|
||||
captionTimes(): { id: string; text: string; pos: string; at: number; from: number; to: number; gone: number; hold: number }[];
|
||||
veil(t: number): number;
|
||||
GAME_SCREENS: string[];
|
||||
GAME_SCREENS: string[]; GAME_SPEED: number;
|
||||
PRIVACY_ANSWERS: string[]; PRIVACY_STEPS: string[]; DATA_SAFETY_CSV: string[]; DECL_TOTAL: number; PRICE_ROWS: string[]; PRICE_CMD: string;
|
||||
PILE: { at: number; kind: string }[]; PILE_TITLES: Record<string, string>;
|
||||
REJECT: { head: string; at: string }; PROMPT: string;
|
||||
|
|
@ -33,7 +34,8 @@ interface Teaser {
|
|||
DESK: Record<string, { groups: { frame: string }[]; swap?: number[]; show?: number[]; rings: { at: number; until: number; target: string }[];
|
||||
pointer?: { at: number; d: number; target: string; click?: number; until?: number } }>;
|
||||
DESK_SCENES: string[]; DESK_FRAMES: string[]; CLICK_T: number;
|
||||
END: { markAt: number; wordAt: number; lineAt: number; urlAt: number; d: number }; END_LINE: string; END_URL: string;
|
||||
END: { markAt: number; wordAt: number; cmdAt: number; cps: number; urlAt: number; d: number; full: number }; END_CMD: string; END_URL: string;
|
||||
SHOT_NOTES: Record<string, string>;
|
||||
}
|
||||
const T = (await import(join(ROOT, 'media/teaser/script.js'))) as Teaser;
|
||||
|
||||
|
|
@ -198,65 +200,69 @@ describe('teaser: timing and format', () => {
|
|||
expect(render).not.toMatch(/1080\s*x\s*1920|w: 1080, h: 1920|vertical/i);
|
||||
expect(render).toContain('scale=1280:720');
|
||||
});
|
||||
test('the story: nine beats in order, the montage in the middle, 56-62 s', () => {
|
||||
test('the story: the shots in order, the montage in the middle, 40-48 s, hard cuts only', () => {
|
||||
expect(T.SCENES.map(s => s.id)).toEqual(['game', 'pile', 'reject', 'ask', 'builds', 'shots', 'products', 'forms', 'consoles', 'scan', 'button', 'waiting', 'end']);
|
||||
expect(T.MONTAGE).toEqual(['builds', 'shots', 'products', 'forms', 'consoles']);
|
||||
expect(T.DURATION).toBe(T.SCENES.at(-1)!.end);
|
||||
expect(T.DURATION).toBeGreaterThanOrEqual(56);
|
||||
expect(T.DURATION).toBeLessThanOrEqual(62);
|
||||
// the montage spans 19-37 s and speeds up (no beat longer than the one before)
|
||||
const m = T.MONTAGE.map(id => T.SCENES.find(s => s.id === id)!);
|
||||
expect(m[0]!.start).toBe(19);
|
||||
expect(m.at(-1)!.end).toBe(37);
|
||||
for (let i = 1; i < m.length; i++) expect(m[i]!.end - m[i]!.start).toBeLessThanOrEqual(m[i - 1]!.end - m[i - 1]!.start);
|
||||
for (const s of m) expect(s.x, s.id).toBeLessThanOrEqual(0.15); // cuts, not dissolves
|
||||
expect(T.DURATION).toBeGreaterThanOrEqual(40);
|
||||
expect(T.DURATION).toBeLessThanOrEqual(48);
|
||||
// the montage: 3 s per beat
|
||||
for (const id of T.MONTAGE) { const s = T.SCENES.find(x => x.id === id)!; expect(s.end - s.start, id).toBe(3); }
|
||||
for (const s of T.SCENES) expect(s.x, s.id).toBe(0);
|
||||
// every shot says why it cuts (the animatic's shot list)
|
||||
for (const s of T.SCENES) expect(T.SHOT_NOTES[s.id], s.id).toBeTruthy();
|
||||
});
|
||||
test('every cut lands on a beat of music.js (120 BPM: whole half seconds), scenes 3-6 s', () => {
|
||||
test('every cut lands on a beat of music.js (120 BPM: whole half seconds), shots 2-4 s, the end card 5 s', () => {
|
||||
const music = readFileSync(join(ROOT, 'media/teaser/music.js'), 'utf8');
|
||||
expect(music).toContain('export const STEP = 0.5;');
|
||||
expect(T.SCENES[0]!.start).toBe(0);
|
||||
for (let i = 1; i < T.SCENES.length; i++) expect(T.SCENES[i]!.start).toBe(T.SCENES[i - 1]!.end);
|
||||
for (const s of T.SCENES) {
|
||||
expect(Number.isInteger(s.start / 0.5), s.id).toBe(true);
|
||||
expect(s.end - s.start, s.id).toBeGreaterThanOrEqual(3);
|
||||
expect(s.end - s.start, s.id).toBeLessThanOrEqual(6);
|
||||
expect(s.end - s.start, s.id).toBeGreaterThanOrEqual(2);
|
||||
expect(s.end - s.start, s.id).toBeLessThanOrEqual(s.id === 'end' ? 5 : 4);
|
||||
}
|
||||
// the fixes chime on beats too
|
||||
// the fixes chime on beats too, and the pile lands one card per beat
|
||||
const scan = T.SCENES.find(s => s.id === 'scan')!;
|
||||
for (const f of T.FIX_AT) expect(Number.isInteger((scan.start + f) / 0.5), String(f)).toBe(true);
|
||||
T.PILE.forEach((c, i) => expect(c.at, c.kind).toBe(i * 0.5));
|
||||
});
|
||||
test('captions: at most one short line per scene, fully on screen for at least 2.5 s, inside its scene', () => {
|
||||
test('captions: at most six words, fully on screen for at least 2 s, never over the end card', () => {
|
||||
const c = T.captionTimes();
|
||||
expect(new Set(c.map(x => x.id)).size).toBe(c.length);
|
||||
const end = T.SCENES.find(s => s.id === 'end')!;
|
||||
for (const x of c) {
|
||||
const s = T.SCENES.find(y => y.id === x.id)!;
|
||||
expect(x.hold, x.text).toBeGreaterThanOrEqual(2.5);
|
||||
expect(x.at, x.text).toBeGreaterThanOrEqual(s.start);
|
||||
expect(x.gone, x.text).toBeLessThanOrEqual(s.end);
|
||||
expect(x.text.split(' ').length, x.text).toBeLessThanOrEqual(8);
|
||||
expect(x.hold, x.text).toBeGreaterThanOrEqual(2);
|
||||
expect(x.at, x.text).toBeGreaterThanOrEqual(T.SCENES.find(y => y.id === x.id)!.start);
|
||||
expect(x.gone, x.text).toBeLessThanOrEqual(end.start);
|
||||
expect(x.text.split(' ').length, x.text).toBeLessThanOrEqual(6);
|
||||
}
|
||||
// the montage words sit at the edge, the story lines at the top; the click and the end card carry no caption
|
||||
for (let i = 1; i < c.length; i++) expect(c[i]!.at, c[i]!.text).toBeGreaterThanOrEqual(c[i - 1]!.gone);
|
||||
for (const x of c) expect(x.pos, x.id).toBe(T.MONTAGE.includes(x.id) ? 'edge' : 'top');
|
||||
expect(c.map(x => x.id)).not.toContain('waiting');
|
||||
expect(c.map(x => x.id)).not.toContain('end');
|
||||
expect(c.map(x => x.text)).toEqual(['The game is done.', 'Then come the stores.', 'One missing line, and you start over.', 'So I handed it all to my AI.',
|
||||
'Builds.', 'Screenshots, every size.', 'Products, 175 countries.', 'Forms.', 'Consoles.', 'Nothing left for a reviewer to catch.', 'One button is yours.']);
|
||||
expect(c.map(x => x.text)).toEqual(['Your game is done.', 'Then come the stores.', 'One line. Rejected.', 'So my AI did it.',
|
||||
'Builds.', 'Screenshots, every size.', 'Products, 175 countries.', 'Forms.', 'Consoles.', 'Nothing left to reject.', 'You press Submit.']);
|
||||
});
|
||||
test('the end card: the two lines on screen for at least 2.5 s', () => {
|
||||
test('the cold open moves on frame 0: no fade from black, the screens already rushing past', () => {
|
||||
expect(T.CAPTIONS[0]!.at).toBeLessThanOrEqual(0.2);
|
||||
expect(T.GAME_SPEED).toBeGreaterThan(200);
|
||||
const js = readFileSync(join(ROOT, 'media/teaser/teaser.js'), 'utf8');
|
||||
expect(js).toContain("set(el, 'opacity', String(i === 0 ? 1 :");
|
||||
});
|
||||
test('the end card: the logo, `npx storebound init` and storebound.dev, complete for at least the last 4 s', () => {
|
||||
const e = T.SCENES.find(s => s.id === 'end')!;
|
||||
expect(T.END_LINE).toBe('From an empty folder to Submit.');
|
||||
expect(T.END_URL).toBe('Open source · storebound.dev');
|
||||
expect(e.end - e.start - T.END.urlAt - T.END.d).toBeGreaterThanOrEqual(2.5);
|
||||
expect(T.SCENES.at(-1)!.id).toBe('end');
|
||||
expect(T.END_CMD).toBe('npx storebound init');
|
||||
expect(T.END_URL).toBe('storebound.dev');
|
||||
expect(T.DURATION - (e.start + T.END.full)).toBeGreaterThanOrEqual(4);
|
||||
const html = readFileSync(join(ROOT, 'media/teaser/index.html'), 'utf8');
|
||||
expect(html).toMatch(/#brand \.cmdl \{[^}]*font: 800 7\dpx/);
|
||||
});
|
||||
test('the picture darkens over the rejection and lifts on the drop', () => {
|
||||
test('the picture darkens with the rejection and lifts on the drop', () => {
|
||||
const r = T.SCENES.find(s => s.id === 'reject')!, b = T.SCENES.find(s => s.id === 'builds')!;
|
||||
expect(T.veil(r.start)).toBe(0);
|
||||
expect(T.veil(r.end - 0.1)).toBeGreaterThan(0.5);
|
||||
expect(T.veil(b.start)).toBe(0);
|
||||
});
|
||||
test('the pile speeds up: each card lands sooner after the one before', () => {
|
||||
for (let i = 2; i < T.PILE.length; i++) expect(T.PILE[i]!.at - T.PILE[i - 1]!.at).toBeLessThanOrEqual(T.PILE[i - 1]!.at - T.PILE[i - 2]!.at + 1e-9);
|
||||
});
|
||||
});
|
||||
|
||||
// The desktop frames: the real app (fixture mode) in the consoles beat, the button and the waiting scene, captured by
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue