From 20a764b3eefc613fb7a43048c292b358de343bed Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 05:22:47 +0800 Subject: [PATCH] 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 --- media/teaser/README.md | 27 +++---- media/teaser/index.html | 21 ++--- media/teaser/music.js | 1 + media/teaser/render.ts | 29 ++++--- media/teaser/script.js | 76 +++++++++++-------- media/teaser/teaser.js | 46 +++++++---- .../Planner.xcodeproj/project.pbxproj | 13 ++++ .../scan/teaser-manifest/Planner/Info.plist | 10 +++ .../Planner/PrivacyInfo.xcprivacy | 10 +++ tests/fixtures/scan/teaser-manifest/README.md | 4 + tests/teaser.test.ts | 54 +++++++++++-- 11 files changed, 205 insertions(+), 86 deletions(-) create mode 100644 tests/fixtures/scan/teaser-manifest/Planner.xcodeproj/project.pbxproj create mode 100644 tests/fixtures/scan/teaser-manifest/Planner/Info.plist create mode 100644 tests/fixtures/scan/teaser-manifest/Planner/PrivacyInfo.xcprivacy create mode 100644 tests/fixtures/scan/teaser-manifest/README.md diff --git a/media/teaser/README.md b/media/teaser/README.md index 442bbe3..c58bcb8 100644 --- a/media/teaser/README.md +++ b/media/teaser/README.md @@ -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 --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) diff --git a/media/teaser/index.html b/media/teaser/index.html index f34105c..1cb5168 100644 --- a/media/teaser/index.html +++ b/media/teaser/index.html @@ -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; }
-
+
REJECTED
You, to your AI agent
diff --git a/media/teaser/music.js b/media/teaser/music.js index cb79024..bcb25d8 100644 --- a/media/teaser/music.js +++ b/media/teaser/music.js @@ -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); } diff --git a/media/teaser/render.ts b/media/teaser/render.ts index a76f3c3..85c739b 100644 --- a/media/teaser/render.ts +++ b/media/teaser/render.ts @@ -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 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 { 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 { } 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 { 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 { // 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 => `
${k}s ${shotAt(k)}
`).join(''); + const cells = secs.map(k => `
${k.toFixed(2)}s ${shotAt(k)}
`).join(''); const sheet = await browser.newPage({ viewport: { width: cols * 330 + 20, height: 400 }, deviceScaleFactor: 1 }); await sheet.setContent(`${cells}`); await sheet.screenshot({ path: join(dir, 'contact-sheet.png'), fullPage: true }); @@ -254,7 +263,7 @@ async function animatic(base: string, dir: string): Promise { 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(); } } diff --git a/media/teaser/script.js b/media/teaser/script.js index 5c6ecc9..4c6a392 100644 --- a/media/teaser/script.js +++ b/media/teaser/script.js @@ -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' })), diff --git a/media/teaser/teaser.js b/media/teaser/teaser.js index 66b3fa3..2ad4cf4 100644 --- a/media/teaser/teaser.js +++ b/media/teaser/teaser.js @@ -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 = `${m[1]}${esc(m[2])}· App Store ·${esc(m[3])}`; - findEl.querySelector('.l2').innerHTML = esc(REJECT.at).replace(/^(at \S+)/, '$1'); -} +// ---------- 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 `
${m[1]}${esc(f.rule)}
${esc(f.title)}
${esc(m[2])}
`; +}).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(() => { diff --git a/tests/fixtures/scan/teaser-manifest/Planner.xcodeproj/project.pbxproj b/tests/fixtures/scan/teaser-manifest/Planner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..5343a49 --- /dev/null +++ b/tests/fixtures/scan/teaser-manifest/Planner.xcodeproj/project.pbxproj @@ -0,0 +1,13 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + objects = { + C1B2C3D4E5F6000000000002 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + INFOPLIST_FILE = Planner/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.example.planner; + }; + }; + }; +} diff --git a/tests/fixtures/scan/teaser-manifest/Planner/Info.plist b/tests/fixtures/scan/teaser-manifest/Planner/Info.plist new file mode 100644 index 0000000..7339450 --- /dev/null +++ b/tests/fixtures/scan/teaser-manifest/Planner/Info.plist @@ -0,0 +1,10 @@ + + + + + CFBundleDisplayName + Planner + ITSAppUsesNonExemptEncryption + + + diff --git a/tests/fixtures/scan/teaser-manifest/Planner/PrivacyInfo.xcprivacy b/tests/fixtures/scan/teaser-manifest/Planner/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..04afe06 --- /dev/null +++ b/tests/fixtures/scan/teaser-manifest/Planner/PrivacyInfo.xcprivacy @@ -0,0 +1,10 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/tests/fixtures/scan/teaser-manifest/README.md b/tests/fixtures/scan/teaser-manifest/README.md new file mode 100644 index 0000000..16fbd70 --- /dev/null +++ b/tests/fixtures/scan/teaser-manifest/README.md @@ -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). diff --git a/tests/teaser.test.ts b/tests/teaser.test.ts index c03a4d5..0302a2d 100644 --- a/tests/teaser.test.ts +++ b/tests/teaser.test.ts @@ -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; - 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; } +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);