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(() => {

View file

@ -0,0 +1,13 @@
// !$*UTF8*$!
{
archiveVersion = 1;
objects = {
C1B2C3D4E5F6000000000002 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
INFOPLIST_FILE = Planner/Info.plist;
PRODUCT_BUNDLE_IDENTIFIER = com.example.planner;
};
};
};
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Planner</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
</dict>
</plist>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<true/>
<key>NSPrivacyTrackingDomains</key>
<array/>
</dict>
</plist>

View file

@ -0,0 +1,4 @@
# teaser-manifest
The teaser reject shot's ITMS-91064 card: an app privacy manifest with NSPrivacyTracking true and an empty
NSPrivacyTrackingDomains (tests/teaser.test.ts checks the line).

View file

@ -19,14 +19,16 @@ const MOCK = join(import.meta.dir, 'helpers', 'teaser-store-mock.ts');
interface Scene { id: string; start: number; end: number; x: number }
type Line = { at: number; text: string; cmd?: boolean; head?: boolean; final?: boolean };
interface Teaser {
FORMAT: { w: number; h: number }; DURATION: number; SCENES: Scene[]; MONTAGE: string[];
FORMAT: { w: number; h: number }; DURATION: number; FPS: number; SCENES: Scene[]; MONTAGE: string[];
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_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;
PROMPT: string; REJECTS: { fixture: string; rule: string; title: string; head: string }[]; REJECT_STEP: number; SAFE: number;
STAMP: { at: number; d: number; jolt: number }; REJECT_LAND: { d: number }; REJECT_PUSH: [number, number]; REJECT_DARK: number;
ticks: () => { t: number; kind: string }[];
BUILD_LINES: Line[]; SHOT_SIZES: { id: string }[]; SHOT_LANGS: string[]; PRODUCT_LINES: Line[];
DECL_LINES: { what: string; value: string; where: string }[]; DECL_ASK: string;
CONSOLE_CMD: string; CONSOLE_LINES: Line[]; CONSOLES_SWAP: number[];
@ -37,6 +39,7 @@ interface Teaser {
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>;
}
type Box = { x: number; y: number; w: number; h: number };
const T = (await import(join(ROOT, 'media/teaser/script.js'))) as Teaser;
/** Output lines without the clock prefix and the "(0s)" timer, as the video shows them. */
@ -153,11 +156,15 @@ describe('teaser: the scan text is real', () => {
let before = '', after = '';
beforeAll(() => { before = scanText('teaser', false); after = scanText('teaser-fixed', true); });
test('reject: the account-deletion finding, its own header and evidence line', () => {
const out = lines(before);
expect(out).toContain(` 2. ${T.REJECT.head}`);
expect(out).toContain(` ${T.REJECT.at}`);
expect(T.REJECT.head).toContain('5.1.1(v)');
test('reject: each card is a real finding line of `storebound scan` (teaser, teaser-manifest), its rule number in it', () => {
const manifest = run(['scan', '--dir', join(FX, 'teaser-manifest')], ROOT, {}).out;
for (const f of T.REJECTS) {
const out = f.fixture === 'teaser' ? before : manifest;
expect(out, f.rule).toMatch(new RegExp(`^ \\d\\. ${f.head.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'));
expect(f.head, f.rule).toContain(f.rule.replace('Guideline ', ''));
expect(f.title.split(' ').length, f.title).toBeLessThanOrEqual(6);
}
expect(T.REJECTS.map(f => f.rule)).toEqual(['Guideline 2.1(a)', 'Guideline 5.1.1(v)', 'Guideline 5.1.2(i)', 'ITMS-91064']);
});
test('scan: the risks line, all five findings in order, and the clean re-scan of teaser-fixed', () => {
expect(T.SCAN_CMD).toBe('storebound scan');
@ -239,7 +246,7 @@ describe('teaser: timing and format', () => {
}
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.text)).toEqual(['Your game is done.', 'Then come the stores.', 'One line. Rejected.', 'So my AI did it.',
expect(c.map(x => x.text)).toEqual(['Your game is done.', 'Then come the stores.', 'Rejected. Again.', 'So my AI did it.',
'Builds.', 'Screenshots, every size.', 'Products, 175 countries.', 'Forms.', 'Consoles.', 'Nothing left to reject.', 'You press Submit.']);
});
test('the cold open moves on frame 0: no fade from black, the screens already rushing past', () => {
@ -257,6 +264,37 @@ describe('teaser: timing and format', () => {
const html = readFileSync(join(ROOT, 'media/teaser/index.html'), 'utf8');
expect(html).toMatch(/#brand \.cmdl \{[^}]*font: 800 7\dpx/);
});
test('reject: 4 s, the cards land one per beat and all read >= 1 s before the REJECTED stamp hits on a beat, then it darkens', () => {
const r = T.SCENES.find(s => s.id === 'reject')!;
expect(r.end - r.start).toBe(4);
expect(T.REJECTS.length).toBeLessThanOrEqual(5);
T.REJECTS.forEach((_, i) => expect(Number.isInteger((r.start + i * T.REJECT_STEP) / 0.5)).toBe(true));
expect(T.STAMP.at - T.STAMP.d - ((T.REJECTS.length - 1) * T.REJECT_STEP + T.REJECT_LAND.d)).toBeGreaterThanOrEqual(0.7);
expect(Number.isInteger((r.start + T.STAMP.at) / 0.5)).toBe(true);
expect(T.ticks().some(x => x.kind === 'stamp' && x.t === r.start + T.STAMP.at)).toBe(true);
expect(T.REJECT_DARK).toBeGreaterThan(T.STAMP.at);
expect(T.STAMP.jolt).toBeLessThanOrEqual(6); // no shake: one tiny 1-frame jolt at most
expect(readFileSync(join(ROOT, 'media/teaser/teaser.js'), 'utf8')).not.toMatch(/shake/);
});
// The render's layout data: `render.ts --animatic` writes every reject card's box at every frame of the shot.
const CHECKS = join(ROOT, 'media/teaser/out/animatic-checks.json');
test.skipIf(!existsSync(CHECKS))('reject: every card and the stamp stay inside the 6% safe margins at every frame (animatic layout data)', () => {
const j = JSON.parse(readFileSync(CHECKS, 'utf8')) as { duration: number; rejectLayout?: { t: number; cards: Box[]; stamp: Box | null }[] };
expect(j.duration).toBe(T.DURATION);
const r = T.SCENES.find(s => s.id === 'reject')!;
expect(j.rejectLayout?.length).toBe(Math.round((r.end - r.start) * T.FPS));
const mx = T.SAFE * T.FORMAT.w, my = T.SAFE * T.FORMAT.h;
for (const f of j.rejectLayout!) for (const [k, b] of [...f.cards.map((c, i) => [`card ${i + 1}`, c] as const), ['stamp', f.stamp] as const]) {
if (!b) continue;
const at = `${k} at ${f.t.toFixed(3)} s`;
expect(b.x, at).toBeGreaterThanOrEqual(mx);
expect(b.y, at).toBeGreaterThanOrEqual(my);
expect(b.x + b.w, at).toBeLessThanOrEqual(T.FORMAT.w - mx);
expect(b.y + b.h, at).toBeLessThanOrEqual(T.FORMAT.h - my);
}
expect(j.rejectLayout!.at(-1)!.cards.length).toBe(T.REJECTS.length);
expect(j.rejectLayout!.some(f => f.stamp)).toBe(true);
});
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);