teaser: the reject shot redone (7-11 s, 4 s): the common rejections storebound catches as real scan finding cards landing one per beat (2.1(a) demo account, 5.1.1(v) account deletion, 5.1.2(i) ATT from tests/fixtures/scan/teaser; ITMS-91064 from the new teaser-manifest fixture), guideline + one plain line each, then our own REJECTED stamp hits the stack on the beat at 9.5 s, no shake (one 4 px 1-frame jolt), a 3% push, the picture darkens into the cut; caption 'Rejected. Again.'; later shots +1.5 s (42.5 s, every cut on a beat, music follows the scenes, a thud per card and a hit on the stamp); render.ts --animatic --range a,b and --sheet a,b,fps, and the reject cards' boxes per frame in animatic-checks.json; tests: real finding lines, beat timing, and every card inside 6% safe margins at every frame

This commit is contained in:
CRTkafa 2026-10-14 05:22:47 +08:00
parent 511716bfd5
commit 20a764b3ee
11 changed files with 205 additions and 86 deletions

View file

@ -1,4 +1,4 @@
# storebound teaser (v9, 41 s, 16:9)
# storebound teaser (v9, 42.5 s, 16:9)
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
@ -16,17 +16,17 @@ Chrome and encoded with ffmpeg; the music and sound design are synthesized in th
|---|---|---|---|
| 0-3 | the demo game's own screens rush past, a push in | Your game is done. | drives from frame 0: kick, bass, arpeggio |
| 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: B minor pulse, kicks, 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, the clock keeps ticking |
| 9.5-12 | one request typed to the AI agent, sent on the beat | So my AI did it. | a short drop: an impact, half-time beat, 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) | a confident kick and bass on D, the chord; fades over the last 1 s |
| 7-11 | the common rejections storebound catches, as real scan finding cards landing one per beat (2.1(a) demo account, 5.1.1(v) account deletion, 5.1.2(i) ATT, ITMS-91064 privacy manifest), every card inside 6% safe margins; at 9.5 s our own REJECTED stamp hits the stack; the picture darkens into the cut | Rejected. Again. | a low boom, a thud per card, a hit on the stamp |
| 11-13.5 | one request typed to the AI agent, sent on the beat | So my AI did it. | a short drop: an impact, half-time beat, key clicks, a swell |
| 13.5-16.5 | `apple build --dry`, `play build --dry`: build 52, versionCode 52 | Builds. | the drop |
| 16.5-19.5 | the demo game's renders: 7 store sizes x 4 languages fill in | Screenshots, every size. | + hats |
| 19.5-22.5 | `play products --dry`, `catalog diff` (175 / 174 region prices, 0 mismatches) | Products, 175 countries. | + claps |
| 22.5-25.5 | the scan's declarations proposal, the Data safety rows ticking, 768 answers | Forms. | + sixteenths |
| 25.5-28.5 | `console plan apple-app-privacy`, then the desktop app's plan panel, the owner's Publish ringed | Consoles. | + lead |
| 28.5-32.5 | `storebound scan`: five findings turn green one per beat, the clean re-scan | Nothing left to reject. | a chime per fix |
| 32.5-34.5 | the desktop app's Send for review, the camera pushes onto Submit, the pointer arrives | You press Submit. | the music stops |
| 34.5-37.5 | the click, then the app's own "Waiting for review" | (same line) | the resolve |
| 37.5-42.5 | the logo, `npx storebound init` typed out, storebound.dev; holds 4 s | (none) | a confident kick and bass on D, the chord; fades over the last 1 s |
The demo game has 4 languages (en, tr, ja, ar), so the screenshot beat says "every size", not a language count.
@ -52,7 +52,7 @@ clock prefix and the "(0s)" timer left out), in a fresh `storebound init` folder
- `storebound apple build --dry` and `storebound play build --dry` in a minimal Capacitor project.
- `storebound play products --dry` and `catalog diff`, with the stores answered by
`tests/helpers/teaser-store-mock.ts` (preloaded through a `bunfig.toml` in the folder; nothing reaches the network).
- `storebound scan` of `tests/fixtures/scan/teaser` (the 5.1.1(v) finding, all five findings, the declarations
- `storebound scan` of `tests/fixtures/scan/teaser` (the reject cards' 2.1(a), 5.1.1(v) and 5.1.2(i) findings; the ITMS-91064 card from `teaser-manifest`, all five findings, the declarations
proposal) and of `teaser-fixed` after the fixes.
The test also checks the timing (every cut on a half-second beat, scenes 3-6 s, the montage at 19-37 s speeding up with
@ -72,6 +72,7 @@ bun media/teaser/render.ts > media/teaser/out/render.log 2>&1 & # master, soun
bun media/teaser/render.ts --scale 2 > media/teaser/out/render-4k.log 2>&1 & # 3840x2160 master for X (about 40 Mbps, AAC 320k): about 45 min
bun media/teaser/render.ts --desktop # capture the desktop app frames again (after a UI change)
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 --animatic <dir> --range 5,14 --sheet 6,12,4 # one region only, contact sheet at 4 frames/s; writes the reject cards' boxes to out/animatic-checks.json (safe-margin test)
bun media/teaser/render.ts --stills 2.5,13.5,38 # PNG stills in out/stills for a quick layout check
bun media/teaser/render.ts --sound # the music only (out/music.wav)
bun media/teaser/render.ts --serve --port 8750 # live preview: /index.html?play=1 (or ?t=12.5)

View file

@ -83,14 +83,17 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
.pcard .tile { position: absolute; border-radius: 6px; background: center / cover no-repeat; box-shadow: 0 6px 16px rgba(0,0,0,.5), 0 0 0 1px rgba(255,255,255,.08); }
/* ---------- 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: 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; }
#finding .l2 { margin-top: 18px; white-space: nowrap; font-size: 26px; color: var(--ink-2); }
#finding .l2 .loc { font-weight: 700; }
#rejects { left: 50%; top: 61%; width: 1560px; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 28px 32px; transform-origin: 50% 50%; }
.rj { position: relative; padding: 26px 32px 28px; border-radius: 24px; background: #1a0d12; border: 3px solid rgba(255,90,95,.6);
box-shadow: 0 0 0 8px rgba(229,72,77,.1), 0 30px 80px rgba(0,0,0,.6); opacity: 0; transform-origin: 50% 50%; }
.rj .rule { display: flex; align-items: center; gap: 14px; font-family: var(--mono); font-size: 26px; font-weight: 800; color: #fff; }
.rj .sev { color: #fff; background: var(--red-2); border-radius: 8px; padding: 2px 11px; font-size: 19px; font-weight: 800; }
.rj .title { margin-top: 12px; font-family: var(--display); font-size: 48px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.08; color: #fff; white-space: nowrap; }
.rj .real { margin-top: 16px; padding-top: 12px; border-top: 2px solid rgba(255,90,95,.22); font-family: var(--mono); font-size: 16px; line-height: 1.45; color: #d9a9ab; overflow-wrap: anywhere; }
/* the REJECTED stamp: our own graphic, a rotated rounded rectangle with bold type, over the stack */
#stamp { left: 50%; top: 61%; padding: 12px 44px 14px; border: 12px solid var(--red-2); border-radius: 28px; color: var(--red-2);
background: rgba(20,8,12,.82); font-family: var(--display); font-size: 140px; font-weight: 900; letter-spacing: 0.06em; line-height: 1;
opacity: 0; white-space: nowrap; box-shadow: 0 0 0 4px rgba(229,72,77,.2) inset, 0 30px 90px rgba(0,0,0,.6); }
/* ---------- 4 ask: the request ---------- */
#prompt { left: 260px; top: 470px; width: 1400px; padding: 30px 38px; display: flex; flex-direction: column; gap: 16px;
@ -182,7 +185,7 @@ html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
<div class="scene" id="s-game"><div class="cam" id="game"></div></div>
<div class="scene" id="s-pile"><div class="cam" id="pile"></div></div>
<div class="scene" id="s-reject"><div class="cam">
<div id="finding" class="abs"><div class="l1"></div><div class="l2"></div></div>
<div id="rejects" class="abs"></div><div id="stamp" class="abs">REJECTED</div>
</div></div>
<div class="scene" id="s-ask"><div class="cam">
<div id="prompt" class="abs"><span class="who"><span class="pi i-term"></span>You, to your AI agent</span><span class="txt"></span><span class="sent"><span class="pi i-send"></span></span></div>

View file

@ -302,6 +302,7 @@ export async function renderMusic() {
if (x.kind === 'tiny') tick(x.t, 0.02, 2800);
else if (x.kind === 'thud') thud(x.t, 0.12 + x.i * 0.015);
else if (x.kind === 'low') tone(x.t, 38, 0.05, 0.9, 'triangle');
else if (x.kind === 'stamp') { kick(x.t, 0.55, master); thud(x.t, 0.3); boom(x.t, 0.35); tick(x.t, 0.08, 1800); }
else if (x.kind === 'send') { tone(x.t, 81, 0.03, 0.3); tone(x.t + 0.1, 86, 0.025, 0.6); }
else if (x.kind === 'good') { const m = [74, 76, 78, 81, 83][x.step] ?? 86; tone(x.t, m + 12, 0.045, 0.5, 'sine', -0.3 + 0.15 * x.step); tone(x.t, m + 24, 0.012, 0.3); }
else if (x.kind === 'clean') { tone(x.t, 86, 0.04, 0.8); tone(x.t + 0.08, 90, 0.03, 0.9); tone(x.t + 0.16, 93, 0.025, 1.0); }

View file

@ -7,7 +7,8 @@
// 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
// (960x540, 30 fps, quick encode, with sound), contact-sheet.png;
// --range 5,14 renders only that region, --sheet 6,12,4 sets the sheet's span and rate
// (one frame per second, timestamped), shots.md, checks.json
// bun media/teaser/render.ts --scale 2 the 3840x2160 master for 4K uploads: the same page at device scale 2
// (text and terminals rendered natively), the desktop frames captured
@ -125,7 +126,8 @@ async function main(): Promise<void> {
mkdirSync(join(OUT, 'stills'), { recursive: true });
for (const t of stills.split(',').map(Number)) await Bun.write(join(OUT, 'stills', `${t.toFixed(2)}.png`), await shot(t));
log(`${stills.split(',').length} stills in out/stills`);
if (errors.length) throw new Error(`page errors:\n ${errors.join('\n ')}`);
// the reject card at every frame of its shot (safe margins, tests/teaser.test.ts)
if (errors.length) throw new Error(`page errors:\n ${errors.join('\n ')}`);
await page.close();
return;
}
@ -149,7 +151,8 @@ async function main(): Promise<void> {
}
await enc.stdin.end();
if (await enc.exited !== 0) throw new Error('ffmpeg encode failed');
if (errors.length) throw new Error(`page errors:\n ${[...new Set(errors)].join('\n ')}`);
// the reject card at every frame of its shot (safe margins, tests/teaser.test.ts)
if (errors.length) throw new Error(`page errors:\n ${[...new Set(errors)].join('\n ')}`);
ff('-ss', String(DURATION - 0.5), '-i', silent, '-frames:v', '1', '-q:v', '3', join(OUT, `poster-${OUT_W}x${OUT_H}.jpg`));
log(`${silent} (${(statSync(silent).size / 1e6).toFixed(1)} MB)`);
await page.close();
@ -216,9 +219,11 @@ async function animatic(base: string, dir: string): Promise<void> {
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);
// --range a,b: only that region (a re-cut of one shot); --sheet a,b,fps: the contact sheet's span and rate
const [r0, r1] = (opt('range') ?? `0,${DURATION}`).split(',').map(Number) as [number, number];
const frames = Math.round((r1 - r0) * FPS);
for (let i = 0; i < frames; i++) {
await page.evaluate(`teaser.seek(${i / FPS})`);
await page.evaluate(`teaser.seek(${r0 + i / FPS})`);
enc.stdin.write(await page.screenshot({ type: 'png' }));
if (i % 150 === 0) log(`draft frame ${i + 1}/${frames}`);
}
@ -227,21 +232,25 @@ async function animatic(base: string, dir: string): Promise<void> {
// 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})`));
// the reject card at every frame of its shot (safe margins, tests/teaser.test.ts)
const rj = S.scene('reject'), rejectLayout: unknown[] = [];
for (let i = 0; i < Math.round((rj.end - rj.start) * FPS); i++) rejectLayout.push(await page.evaluate(`teaser.rejectLayout(${rj.start + i / FPS})`));
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'));
ff('-i', silent, '-ss', String(r0), '-t', String(r1 - r0), '-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 [s0, s1, sfps] = (opt('sheet') ?? `${r0},${r1},1`).split(',').map(Number) as [number, number, number];
const secs = Array.from({ length: Math.round((s1 - s0) * sfps) }, (_, k) => s0 + k / sfps).filter(k => k >= r0 && k < r1);
for (const k of secs) ff('-ss', String(Math.min(k - r0, r1 - r0 - 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 cells = secs.map(k => `<figure><img src="data:image/jpeg;base64,${Buffer.from(readFileSync(join(tmp, `${k}.jpg`))).toString('base64')}"><figcaption><b>${k.toFixed(2)}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 });
@ -254,7 +263,7 @@ async function animatic(base: string, dir: string): Promise<void> {
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));
writeFileSync(join(OUT, 'animatic-checks.json'), JSON.stringify({ duration: DURATION, fps: FPS, frame: FORMAT, endLayout: layout, rejectLayout }, null, 2));
log(`animatic: ${dir} (draft.mp4, contact-sheet.png, shots.md); layout -> out/animatic-checks.json`);
} finally { await browser.close(); }
}

View file

@ -1,18 +1,18 @@
// 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
// (music.js). 42.5 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-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. /
// reject 7-11 real scan findings land one per beat, then the REJECTED stamp "Rejected. Again."
// ask 11-13.5 one request typed to the AI agent, sent on the beat "So my AI did it."
// builds..consoles 13.5-28.5 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)
// scan 28.5-32.5 `storebound scan`: one finding turns green per beat, the clean re-scan "Nothing left to reject."
// button 32.5-34.5 the desktop app's Send for review, the camera pushes onto Submit "You press Submit."
// waiting 34.5-37.5 the click, then the app's own "Waiting for review" (same line)
// end 37.5-42.5 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`
@ -33,7 +33,7 @@ export const FORMAT = { w: 1920, h: 1080 };
/** Shots: id, length (s), the transition into it (s; 0 = a hard cut on the beat). */
const LENGTHS = [
['game', 3, 0], ['pile', 4, 0], ['reject', 2.5, 0], ['ask', 2.5, 0],
['game', 3, 0], ['pile', 4, 0], ['reject', 4, 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],
];
@ -46,7 +46,7 @@ export const MONTAGE = ['builds', 'shots', 'products', 'forms', 'consoles'];
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',
reject: 'the common rejections storebound catches, as real scan finding cards (2.1(a) demo account, 5.1.1(v) account deletion, 5.1.2(i) ATT, ITMS-91064 privacy manifest), one per beat, all inside the safe margins; the REJECTED stamp hits the stack on the beat at 2.5 s; the picture darkens into the cut',
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',
@ -81,15 +81,15 @@ export const CAMERA = {
export const CAPTIONS = [
{ 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 },
{ id: 'reject', pos: 'top', text: '*Rejected.* Again.', at: 7.05, until: 10.95, hl: 'red' },
{ id: 'ask', pos: 'top', text: 'So my AI *did it.*', at: 11.05, until: 13.45 },
{ id: 'builds', pos: 'edge', text: 'Builds.', at: 13.55, until: 16.45 },
{ id: 'shots', pos: 'edge', text: 'Screenshots, every size.', at: 16.55, until: 19.45 },
{ id: 'products', pos: 'edge', text: 'Products, 175 countries.', at: 19.55, until: 22.45 },
{ id: 'forms', pos: 'edge', text: 'Forms.', at: 22.55, until: 25.45 },
{ id: 'consoles', pos: 'edge', text: 'Consoles.', at: 25.55, until: 28.45 },
{ id: 'scan', pos: 'top', text: 'Nothing left to *reject.*', at: 30.1, until: 32.45, hl: 'green' },
{ id: 'button', pos: 'top', text: 'You press *Submit.*', at: 32.6, until: 37.45 },
];
/** Fades (s): quick, a cut is a cut. */
export const CAP_FADE = { top: [0.12, 0.08], edge: [0.1, 0.08] };
@ -105,8 +105,8 @@ export function captionTimes() {
* first beat of the montage. */
export function veil(t) {
const r = scene('reject'), a = scene('ask');
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 < r.start + REJECT_DARK) return 0;
if (t < r.end) return 0.7 * Math.min(1, (t - r.start - REJECT_DARK) / (r.end - r.start - REJECT_DARK - 0.1));
if (t < a.end) return 0.6;
return 0;
}
@ -186,14 +186,29 @@ export const PILE = [
/** 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' };
// ---------- 3 reject: one finding grows to fill the frame ----------
/** The scan's own lines for the account-deletion finding (tests/fixtures/scan/teaser). */
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() } }',
};
/** 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 };
// ---------- 3 reject: the common rejections storebound catches, one card per beat, then the stamp ----------
/** Real `storebound scan` findings: guideline + one plain line, the scan's own header below in small type.
* head = the finding line as printed (tests/teaser.test.ts checks it in the fixture's scan output). */
export const REJECTS = [
{ fixture: 'teaser', rule: 'Guideline 2.1(a)', title: 'No demo account for the reviewer',
head: 'BLOCKER 2.1(a) · App Store · Sign-in is required but there is no demo account for App Review' },
{ fixture: 'teaser', rule: 'Guideline 5.1.1(v)', title: 'No way to delete the account',
head: 'BLOCKER 5.1.1(v) · App Store · Accounts can be created but no in-app account deletion was found' },
{ fixture: 'teaser', rule: 'Guideline 5.1.2(i)', title: 'Tracking without asking (ATT)',
head: 'HIGH 5.1.2(i) · App Store · Tracking-capable code without App Tracking Transparency (Google AdMob, IDFA)' },
{ fixture: 'teaser-manifest', rule: 'ITMS-91064', title: 'Privacy manifest refused at upload',
head: 'BLOCKER Privacy manifest (ITMS-91064) · App Store · Planner/PrivacyInfo.xcprivacy: NSPrivacyTracking true with an empty NSPrivacyTrackingDomains (Apple refuses the build: ITMS-91064)' },
];
/** The cards land one per beat (s into the shot; scale `land.from` -> 1 in `land.d` s, no shake). The REJECTED stamp
* hits on the beat at STAMP.at (scale from -> 1 in d s, one 1-frame jolt of `jolt` px); a slow push; the picture
* darkens from REJECT_DARK into the cut. */
export const REJECT_STEP = 0.5;
export const REJECT_LAND = { from: 1.06, d: 0.16 };
export const REJECT_PUSH = [1.0, 1.03];
export const STAMP = { at: 2.5, d: 0.12, from: 2.4, rot: -8, jolt: 4 };
export const REJECT_DARK = 3.2;
/** Safe margins: every card stays this far (fraction of width / height) inside the frame at every frame. */
export const SAFE = 0.06;
// ---------- 4 ask: the request to the agent ----------
export const PROMPT = 'Get this game ready for review on both stores.';
@ -326,7 +341,8 @@ export function ticks() {
const at = id => scene(id).start;
return [
...PILE.map((c, i) => ({ t: at('pile') + c.at, kind: 'thud', i })),
{ t: at('reject') + 0.05, kind: 'low' },
...REJECTS.map((_, i) => ({ t: at('reject') + i * REJECT_STEP, kind: 'thud', i })),
{ t: at('reject') + STAMP.at, kind: 'stamp' },
{ t: at('ask') + PROMPT_SENT, kind: 'send' },
...BUILD_LINES.filter(l => !l.cmd).map(l => ({ t: at('builds') + l.at, kind: 'tiny' })),
...PRODUCT_LINES.filter(l => !l.cmd).map(l => ({ t: at('products') + l.at, kind: 'tiny' })),

View file

@ -4,7 +4,7 @@ import { ASK_AT, BUILD_LINES, CAMERA, CAP_FADE, CAPTIONS, captionTimes, CONSOLE_
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, 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';
REJECT_DARK, REJECT_LAND, REJECT_PUSH, REJECT_STEP, REJECTS, STAMP, 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;
@ -170,22 +170,29 @@ function pile(t) {
});
}
// ---------- 3 reject: one finding grows to fill the frame ----------
const findEl = $('#finding');
{
const m = REJECT.head.match(/^(BLOCKER) (\S+) · App Store · (.+)$/);
findEl.querySelector('.l1').innerHTML = `<span class="sev">${m[1]}</span><span class="gl">${esc(m[2])}</span><span>· App Store ·</span><span>${esc(m[3])}</span>`;
findEl.querySelector('.l2').innerHTML = esc(REJECT.at).replace(/^(at \S+)/, '<span class="loc">$1</span>');
}
// ---------- 3 reject: real scan findings land one per beat, then the REJECTED stamp ----------
const rejEl = $('#rejects'), stampEl = $('#stamp');
rejEl.innerHTML = REJECTS.map(f => {
const m = f.head.match(/^(\S+)\s+.*? · App Store · (.+)$/);
return `<div class="rj"><div class="rule"><span class="sev">${m[1]}</span><span>${esc(f.rule)}</span></div><div class="title">${esc(f.title)}</div><div class="real">${esc(m[2])}</div></div>`;
}).join('');
const rjEls = [...rejEl.children];
function reject(t) {
const s = scene('reject'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start;
// 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)})`);
const r = t - s.start, len = s.end - s.start;
const jolt = r >= STAMP.at && r < STAMP.at + 1 / 30 ? STAMP.jolt : 0;
const z = lerp(REJECT_PUSH[0], REJECT_PUSH[1], esin(prog(r, 0, len)));
set(rejEl, 'transform', `translate(calc(-50% + ${jolt}px), calc(-50% + ${jolt / 2}px)) scale(${z.toFixed(4)})`);
set(rejEl, 'filter', `brightness(${(1 - 0.45 * eio(prog(r, REJECT_DARK, len - REJECT_DARK))).toFixed(3)})`);
rjEls.forEach((el, i) => {
const lp = prog(r, i * REJECT_STEP - (i ? 0 : LEAD), REJECT_LAND.d + (i ? 0 : LEAD));
set(el, 'opacity', String(clamp(lp * 4)));
set(el, 'transform', `scale(${lerp(REJECT_LAND.from, 1, eoc(lp)).toFixed(4)})`);
});
const sp = prog(r, STAMP.at - STAMP.d, STAMP.d);
set(stampEl, 'opacity', String(r < STAMP.at - STAMP.d ? 0 : clamp(sp * 3)));
set(stampEl, 'filter', rejEl.style.filter);
set(stampEl, 'transform', `translate(calc(-50% + ${jolt}px), -50%) rotate(${STAMP.rot}deg) scale(${lerp(STAMP.from, 1, eoc(sp)).toFixed(4)})`);
}
// ---------- 4 ask: the request to the agent ----------
@ -419,6 +426,13 @@ function end(t) {
// 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');
}
/** The reject cards' boxes (and the stamp's) as drawn at time t, for the safe-margin check. */
function rejectLayout(t) {
seek(t);
const k = W / stage.getBoundingClientRect().width;
const box = el => { const b = el.getBoundingClientRect(); return { x: b.x * k, y: b.y * k, w: b.width * k, h: b.height * k }; };
return { t, cards: rjEls.filter(el => Number(el.style.opacity) > 0).map(box), stamp: Number(stampEl.style.opacity) > 0 ? box(stampEl) : null };
}
/** Layout data for the animatic checks: the end card's install line, as drawn at time t. */
function layout(t) {
seek(t);
@ -462,7 +476,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, layout, DURATION, FPS, W, H, captionTimes };
window.teaser = { ready, seek, layout, rejectLayout, DURATION, FPS, W, H, captionTimes };
if (q.has('t')) ready.then(() => seek(Number(q.get('t'))));
if (q.has('play')) ready.then(() => {