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,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' })),