storebound/media/teaser/script.js

361 lines
27 KiB
JavaScript

// Teaser timeline (v9, the story cut tight): the single source of truth for the picture (teaser.js) and the sound
// (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-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 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`
// (the CSV; its row count from `storebound check`), `storebound prices ppp --offline --dry` (the price sheet),
// all in a fresh `storebound init` folder with two products
// reject `storebound scan` of tests/fixtures/scan/teaser
// builds `storebound apple build --dry` and `storebound play build --dry` in a Capacitor project
// products `storebound play products --dry` and `storebound catalog diff` (the teaser store double)
// forms the declarations proposal of `storebound scan` and the Data safety rows
// consoles `storebound console plan apple-app-privacy`, then the desktop app's plan panel with that real plan
// scan `storebound scan` of tests/fixtures/scan/teaser, then of teaser-fixed
// The game screens are examples/demo-game's own captures (store/shots/frames), the screenshot tiles its real renders,
// the desktop frames the app in fixture mode (desktop.ts).
export const FPS = 30;
/** 16:9 only: the master; render.ts also makes the 1280x720 web version from it. */
export const FORMAT = { w: 1920, h: 1080 };
/** 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', 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],
];
export const SCENES = LENGTHS.reduce((a, [id, len, x]) => { const start = a.at(-1)?.end ?? 0; return [...a, { id, start, end: start + len, x }]; }, []);
export const DURATION = SCENES.at(-1).end;
export const scene = id => SCENES.find(s => s.id === id);
/** The montage: one word or short phrase at the edge, cuts on the beat. */
export const MONTAGE = ['builds', 'shots', 'products', 'forms', 'consoles'];
/** Why each shot cuts where it does (the shot list, render.ts --animatic). */
export const SHOT_NOTES = {
game: 'the demo game\'s four real screens rush past, push in; cut as the push lands',
pile: 'App Privacy, Data safety CSV, screenshot sizes, prices: one real card per beat; cut when the wall is full',
reject: 'the 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',
products: '`play products --dry`, `catalog diff`: 175 region prices, 0 mismatches; cut on the 0',
forms: 'scan\'s declarations proposal ticks the Data safety rows, the count runs to 768; cut when it lands',
consoles: '`console plan apple-app-privacy`, then the app\'s plan panel, the owner\'s Publish ringed; cut on the ring',
scan: '`storebound scan`: 5 findings turn green one per beat, the clean re-scan; cut on 0 blockers',
button: 'the desktop app\'s Send for review, the camera pushes onto Submit, the pointer arrives; cut on arrival',
waiting: 'the click, then the app\'s own "Waiting for review", ringed; cut when it settles',
end: 'logo, `npx storebound init` typed out big, storebound.dev; holds 4 s (caret blink, slow push)',
};
/** One camera move per shot: from -> to (z = zoom, x / y = pan in px), a quick push that settles at 80 % of the shot. */
export const CAMERA = {
game: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.14, x: 0, y: 30 } },
pile: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 0, y: 10 } },
reject: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
ask: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.12, x: -60, y: 0 } },
builds: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 0, y: -40 } },
shots: { from: { z: 0.98, x: 0, y: 0 }, to: { z: 1.08, x: 0, y: 0 } },
products: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 0, y: -50 } },
forms: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.07, x: 30, y: 0 } },
consoles: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
scan: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.08, x: 0, y: -20 } },
button: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
waiting: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
end: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: 0 } },
};
/** Captions: at most six words, each fully on screen for at least 2 s. top = the story line, big and centred at the
* top; edge = the montage word, bottom left. at / until: absolute seconds. *word* = highlighted. */
export const CAPTIONS = [
{ id: 'game', pos: 'top', text: '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: '*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] };
/** When each caption is fully on screen. */
export function captionTimes() {
return CAPTIONS.map(c => {
const [fin, fout] = CAP_FADE[c.pos];
const from = c.at + fin, to = c.until - fout;
return { id: c.id, text: c.text.replace(/\*/g, ''), pos: c.pos, at: c.at, from, to, gone: c.until, hold: to - from };
});
}
/** The dark veil over the picture (0-1) at time t: it falls with the rejection, holds through the request, gone on the
* first beat of the montage. */
export function veil(t) {
const r = scene('reject'), a = scene('ask');
if (t < r.start + 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;
}
// ---------- 1 game: the demo game's real screens ----------
/** examples/demo-game/store/shots/frames/en/phone/<id>.png (the game's own captures), left to right. */
export const GAME_SCREENS = ['play', 'combo', 'map', 'stars'];
/** The cold open: the screens rush past (px/s) while the camera pushes in; already moving on frame 0. */
export const GAME_AT = 0, GAME_STEP = 0, GAME_SPEED = 520;
// ---------- 2 pile: the store work piles up ----------
/** Real lines (see the header). */
export const PRIVACY_ANSWERS = [
' [ok] Do you or your third-party partners collect data from this app?: Yes -> pick "Yes, we collect data from this app" @ the Data Collection question (Get Started)',
' [ok] Data type: Contact Info > Name: No -> untick "Name" @ the Data Collection dialog (Edit next to Data Types): the data type list',
' [ok] Data type: Contact Info > Email Address: Yes -> tick "Email Address" @ the Data Collection dialog (Edit next to Data Types): the data type list',
' [ok] Data type: Contact Info > Phone Number: No -> untick "Phone Number" @ the Data Collection dialog (Edit next to Data Types): the data type list',
' [ok] Data type: Contact Info > Physical Address: No -> untick "Physical Address" @ the Data Collection dialog (Edit next to Data Types): the data type list',
' [ok] Data type: Contact Info > Other User Contact Info: No -> untick "Other User Contact Info" @ the Data Collection dialog (Edit next to Data Types): the data type list',
' [ok] Data type: Health & Fitness > Health: No -> untick "Health" @ the Data Collection dialog (Edit next to Data Types): the data type list',
' [ok] Data type: Health & Fitness > Fitness: No -> untick "Fitness" @ the Data Collection dialog (Edit next to Data Types): the data type list',
' [ok] Data type: Financial Info > Payment Info: No -> untick "Payment Info" @ the Data Collection dialog (Edit next to Data Types): the data type list',
' [ok] Data type: Financial Info > Credit Info: No -> untick "Credit Info" @ the Data Collection dialog (Edit next to Data Types): the data type list',
' [ok] Data type: Financial Info > Other Financial Info: No -> untick "Other Financial Info" @ the Data Collection dialog (Edit next to Data Types): the data type list',
' [ok] Data type: Location > Precise Location: No -> untick "Precise Location" @ the Data Collection dialog (Edit next to Data Types): the data type list',
];
export const PRIVACY_STEPS = [
' 1. Open App Store Connect > Apps > the app > App Privacy (sidebar): https://appstoreconnect.apple.com/apps',
' 2. First time: click "Get Started" and answer the Data Collection question; then Next. [click: "Get Started"]',
' 5. "Email Address" step 1, purposes: tick and untick the 6 checkboxes as answered, then Next. [click: "Next"]',
' 6. "Email Address" step 2, linked to the user: pick the radio, then Next. [click: "Next"]',
' 8. "Email Address" step 5, tracking: pick the radio, then Save (a draft). Never "Definitions and Examples". The dialog closes; the row shows Edit and the summary. [click: "Save"]',
];
export const DATA_SAFETY_CSV = [
'PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA,,TRUE,REQUIRED,Does your app collect or share any of the required user data types?',
'PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT,,TRUE,MAYBE_REQUIRED,Is all of the user data collected by your app encrypted in transit?',
'PSL_SUPPORTED_ACCOUNT_CREATION_METHODS,PSL_ACM_USER_ID_PASSWORD,TRUE,MULTIPLE_CHOICE,Which of the following methods of account creation does your app support? Select all that apply / Username and password',
'PSL_ACCOUNT_DELETION_URL,,https://example.com/delete-account,MAYBE_REQUIRED,Add a link that users can use to request that their account and associated data is deleted',
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_YES,TRUE,SINGLE_CHOICE,Do you provide a way for users to request that their data is deleted? / Yes',
'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,,SINGLE_CHOICE,Do you provide a way for users to request that their data is deleted? / No',
'PSL_DATA_DELETION_URL,,https://example.com/delete-account,MAYBE_REQUIRED,Delete data URL',
'PSL_DATA_TYPES_PERSONAL,PSL_NAME,,MULTIPLE_CHOICE,Personal info / Name',
'PSL_DATA_TYPES_PERSONAL,PSL_EMAIL,TRUE,MULTIPLE_CHOICE,Personal info / Email address',
'PSL_DATA_TYPES_PERSONAL,PSL_USER_ACCOUNT,TRUE,MULTIPLE_CHOICE,Personal info / User IDs',
'PSL_DATA_TYPES_PERSONAL,PSL_ADDRESS,,MULTIPLE_CHOICE,Personal info / Address',
'PSL_DATA_TYPES_PERSONAL,PSL_PHONE,,MULTIPLE_CHOICE,Personal info / Phone number',
'PSL_DATA_TYPES_PERSONAL,PSL_RACE_ETHNICITY,,MULTIPLE_CHOICE,Personal info / Race and ethnicity',
];
/** Data safety CSV rows `storebound check` counts for the config. */
export const DECL_TOTAL = 768;
export const PRICE_ROWS = [
'com.example.app.no_ads,apple,AF,USD,0.89,,manual', 'com.example.app.no_ads,apple,AL,USD,0.99,,manual',
'com.example.app.no_ads,apple,AM,USD,0.99,,manual', 'com.example.app.no_ads,apple,AO,USD,0.99,,manual',
'com.example.app.no_ads,apple,AR,USD,0.99,,manual', 'com.example.app.no_ads,apple,AZ,USD,0.89,,manual',
'com.example.app.no_ads,apple,BA,EUR,0.99,,manual', 'com.example.app.no_ads,apple,BF,USD,0.99,,manual',
'com.example.app.no_ads,apple,BJ,USD,0.99,,manual', 'com.example.app.no_ads,apple,BO,USD,0.99,,manual',
'com.example.app.no_ads,apple,BR,BRL,7.99,,manual', 'com.example.app.no_ads,apple,BT,USD,0.89,,manual',
'com.example.app.no_ads,apple,BW,USD,0.99,,manual', 'com.example.app.no_ads,apple,BY,USD,0.89,,manual',
'com.example.app.no_ads,apple,CD,USD,0.99,,manual', 'com.example.app.no_ads,apple,CG,USD,0.99,,manual',
'com.example.app.no_ads,apple,CI,USD,0.99,,manual', 'com.example.app.no_ads,apple,CM,USD,0.99,,manual',
'com.example.app.no_ads,apple,CO,COP,5500,,manual', 'com.example.app.no_ads,apple,CV,USD,0.99,,manual',
'com.example.app.no_ads,apple,DM,USD,0.99,,manual', 'com.example.app.no_ads,apple,DO,USD,0.99,,manual',
];
export const PRICE_CMD = 'prices ppp --offline --dry --store apple --only com.example.app.no_ads';
/** The cards that drop onto the pile, faster and faster. at = seconds into the scene; x / y = the card's centre
* (px), w = width, rot = degrees. kind: what it shows. */
export const PILE = [
{ at: 0.0, kind: 'privacy', x: 640, y: 540, w: 1000, rot: -3 },
{ at: 0.5, kind: 'csv', x: 1260, y: 600, w: 980, rot: 2.5 },
{ at: 1.0, kind: 'shots', x: 820, y: 680, w: 900, rot: -1.5 },
{ at: 1.5, kind: 'prices', x: 1240, y: 520, w: 760, rot: 4 },
{ at: 2.0, kind: 'steps', x: 760, y: 700, w: 1040, rot: 2 },
{ at: 2.5, kind: 'csv', x: 1100, y: 620, w: 1000, rot: -4.5, from: 7 },
{ at: 3.0, kind: 'shots', x: 940, y: 560, w: 940, rot: 3.5, lang: 'ja' },
{ at: 3.5, kind: 'prices', x: 980, y: 640, w: 820, rot: -2.5, from: 11 },
]
/** The card headers (what each card is; no claims). */
export const PILE_TITLES = { privacy: 'App Privacy · answer sheet', steps: 'App Privacy · console steps', csv: `Data safety · ${DECL_TOTAL} rows`, shots: 'Screenshots', prices: 'Prices · every region' };
// ---------- 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',
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: 'Manifest refused',
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.';
export const PROMPT_AT = 0.1, PROMPT_CPS = 31;
/** Enter: the request is sent (a soft accent), seconds into the scene. */
export const PROMPT_SENT = 2.0;
// ---------- 5 montage ----------
/** builds: real lines of `apple build --dry` and `play build --dry` (clock prefix and "(0s)" left out). */
export const BUILD_LINES = [
{ at: 0.1, text: '$ storebound apple build --dry --build-number 52 --version 1.0.0', cmd: true },
{ at: 0.45, text: '[build] capacitor: ios/App/App.xcodeproj scheme App (Release), build 52, version 1.0.0, signing automatic' },
{ at: 0.75, text: '[build] build planned (dry run)' },
{ at: 1.25, text: '$ storebound play build --dry --version-code 52', cmd: true },
{ at: 1.6, text: '[build] capacitor: android :app:bundleRelease, versionCode 52' },
{ at: 1.85, text: "[build] signing: the project's own signingConfig" },
{ at: 2.1, text: '[build] would run (in android): ./gradlew :app:bundleRelease --no-daemon --console=plain -Pandroid.injected.version.code=52 -PversionCode=52' },
{ at: 2.4, text: '[build] build planned (dry run)' },
];
/** shots: the demo game's renders, one column per store size (cli/shots/lib.ts SIZES), one row per language. */
export const SHOT_SIZES = [
{ id: 'ios69', label: 'iPhone 6.9″' }, { id: 'ios65', label: 'iPhone 6.5″' }, { id: 'ipad13', label: 'iPad 13″' },
{ id: 'play', label: 'Play phone' }, { id: 'tab7', label: 'Play 7″' }, { id: 'tab10', label: 'Play 10″' }, { id: 'feature', label: 'Feature graphic' },
];
export const SHOT_LANGS = ['en', 'tr', 'ja', 'ar'];
export const SHOTS_AT = 0.05, SHOTS_STEP = 0.3, SHOTS_ROW = 0.1;
/** products: real output (clock prefix and the "(0s)" timer left out). */
export const PRODUCT_LINES = [
{ at: 0.07, text: '$ storebound play products --dry', cmd: true },
{ at: 0.41, text: '[play] 1/2 com.example.app.no_ads: would create · 174 regions · 2 listings (dry)' },
{ at: 0.65, text: '[play] 2/2 com.example.app.gems_100: would create · 174 regions · 2 listings (dry)' },
{ at: 1.08, text: '$ storebound catalog diff', cmd: true },
{ at: 1.42, text: 'ok [apple] com.example.app.gems_100: consumable, 175 region prices, READY_TO_SUBMIT' },
{ at: 1.62, text: 'ok [play] com.example.app.gems_100: consumable, 174 region prices, ACTIVE' },
{ at: 1.82, text: 'ok [apple] com.example.app.no_ads: non_consumable, 175 region prices, READY_TO_SUBMIT' },
{ at: 2.03, text: 'ok [play] com.example.app.no_ads: non_consumable, 174 region prices, ACTIVE' },
{ at: 2.36, text: '[catalog] 2 catalog items: 0 mismatch(es), 0 warning(s), 0 price difference(s) between the stores' },
];
/** forms: the declarations proposal (scan of the teaser fixture), then the Data safety rows it fills. */
export const DECL_LINES = [
{ what: 'PSL_DEVICE_ID', value: 'collect: advertising, analytics, fraud', where: 'Podfile.lock:2' },
{ what: 'PSL_APPROX_LOCATION', value: 'collect: advertising, analytics, fraud', where: 'Podfile.lock:2' },
{ what: 'PSL_EMAIL', value: 'collect: account, functionality', where: 'Podfile.lock:3' },
{ what: 'PSL_USER_ACCOUNT', value: 'collect: account, functionality', where: 'Podfile.lock:3' },
];
export const DECL_ASK = 'ask owner encryptedInTransit: ? · all traffic over HTTPS / TLS?';
export const DECL_AT = 0.1, DECL_STEP = 0.18, ASK_AT = 0.9;
/** Play Data safety rows the detected answers fill (Play's labels for the PSL_* types above and the rest). */
export const DATA_SAFETY_ROWS = ['Device or other IDs', 'Approximate location', 'App interactions', 'Diagnostics', 'Name', 'Email address', 'User IDs'];
export const FORM_AT = 0.5, FORM_STEP = 0.16, FORMS_COUNT_AT = 1.6;
/** consoles: the plan in the terminal, then the app's plan panel (DESK.consoles). */
export const CONSOLE_CMD = 'storebound console plan apple-app-privacy';
export const CONSOLE_LINES = [
{ at: 0.12, text: 'App Store Connect: App Privacy', head: true },
{ at: 0.22, text: 'Steps:', head: true },
{ at: 0.3, text: ' 1. Open App Store Connect > Apps > the app > App Privacy (sidebar): https://appstoreconnect.apple.com/apps' },
{ at: 0.38, text: ' 2. First time: click "Get Started" and answer the Data Collection question; then Next. [click: "Get Started"]' },
{ at: 0.5, text: 'Answer sheet:', head: true },
{ at: 0.58, text: ' [ok] Data type: Contact Info > Email Address: Yes -> tick "Email Address" @ the Data Collection dialog (Edit next to Data Types): the data type list' },
{ at: 0.7, text: 'Never press: "Publish", "Submit", "Create", "Create app", "Send", "Release", "Definitions and Examples", "?", "More information"' },
{ at: 0.82, text: "Final button (the owner's): Publish", final: true },
];
/** The terminal gives way to the app's plan panel over this window, seconds into the scene. */
export const CONSOLES_SWAP = [1.05, 0.35];
// ---------- 6 scan: every finding turns green, the re-scan is clean ----------
export const SCAN_CMD = 'storebound scan';
export const RISKS_LINE = 'Rejection risks: 2 blocker, 3 high, 0 medium, 0 low';
/** The five findings' own header lines (after the number). */
export const FINDINGS = [
'BLOCKER 2.1(a) · App Store · Sign-in is required but there is no demo account for App Review',
'BLOCKER 5.1.1(v) · App Store · Accounts can be created but no in-app account deletion was found',
'HIGH 4.8 · App Store · Third-party login (Google / Facebook / ...) without Sign in with Apple or another equivalent login option',
'HIGH 3.1.1 · App Store · Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found',
'HIGH 5.1.2(i) · App Store · Tracking-capable code without App Tracking Transparency (Google AdMob, IDFA)',
];
export const FINDINGS_AT = 0.2, FINDINGS_STEP = 0.06;
/** Each finding turns green (fixed) on a beat, then the re-scan of the fixed copy (tests/fixtures/scan/teaser-fixed). */
export const FIX_AT = [0.5, 1.0, 1.5, 2.0, 2.5];
export const RESCAN_AT = 3.0;
export const RESCAN_LINE = 'Rejection risks: 0 blocker, 0 high, 0 medium, 0 low';
// ---------- desktop: real frames of the app in fixture mode (desktop.ts) ----------
/** One entry per scene. groups: what the window shows, in order (one captured frame each); swap: the dissolve from
* group 1 to group 2 (seconds into the scene). cam: one push, f = focus point (fraction of the 1280x800 frame),
* z = zoom. rings: a quiet ring on a captured box (boxes.json), no text. pointer: glides onto a box and rests there;
* click = the moment it presses (only the owner's button, in the waiting scene). */
export const DESK = {
consoles: {
groups: [{ frame: 'console-plan' }],
show: CONSOLES_SWAP,
cam: { from: { f: [0.5, 0.56], z: 1.12 }, to: { f: [0.5, 0.58], z: 1.22 } },
rings: [{ at: 1.5, until: 3.4, target: 'cpFinal', green: true }],
},
// Releases > Send for review: build 52 picked, the one button left; the camera settles on it, the pointer rests
button: {
groups: [{ frame: 'submit-ready' }],
cam: { from: { f: [0.45, 0.5], z: 1.0 }, to: { f: [0.3, 0.68], z: 1.55 } },
rings: [{ at: 1.3, until: 2.6, target: 'submitBtn' }],
pointer: { at: 0.4, d: 0.9, target: 'submitBtn' },
},
// the press, then the app's own "Waiting for review" state
waiting: {
groups: [{ frame: 'submit-ready' }, { frame: 'submit-waiting' }],
swap: [0.45, 0.9],
cam: { from: { f: [0.3, 0.68], z: 1.55 }, to: { f: [0.4, 0.4], z: 1.3 } },
rings: [{ at: 1.0, until: 3.4, target: 'waitingItem', green: true }],
pointer: { at: -1, d: 0.01, target: 'submitBtn', click: 0.2, until: 0.6 },
},
};
export const DESK_SCENES = Object.keys(DESK);
/** Every captured frame a desktop scene names. */
export const DESK_FRAMES = [...new Set(Object.values(DESK).flatMap(d => d.groups.map(g => g.frame)))];
/** The click on Submit (absolute seconds): the music's resolve starts here. */
export const CLICK_T = scene('waiting').start + DESK.waiting.pointer.click;
// ---------- 9 end card: the last thing on screen ----------
/** Seconds into the end shot: the logo and word slam in, the install line is typed, then the site. Everything is on
* screen by `full`; the rest (at least 4 s) holds with a blinking caret and a slow push. */
export const END_CMD = 'npx storebound init';
export const END_URL = 'storebound.dev';
export const END = { markAt: 0, wordAt: 0.06, cmdAt: 0.15, cps: 40, urlAt: 0.7, d: 0.2 };
END.full = Math.max(END.cmdAt + END_CMD.length / END.cps, END.urlAt + END.d);
// ---------- sound cues (music.js) ----------
/** Cuts with a sound accent (every scene change but the seamless button -> waiting one). */
export const CUTS = SCENES.slice(1).filter(s => s.id !== 'waiting').map(s => ({ t: s.start, id: s.id }));
/** Ticks: cards landing (thud), lines and rows (tiny), the finding (low), fixes (bright, rising), the send (soft). */
export function ticks() {
const at = id => scene(id).start;
return [
...PILE.map((c, i) => ({ t: at('pile') + c.at, kind: 'thud', i })),
...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' })),
...DATA_SAFETY_ROWS.map((_, i) => ({ t: at('forms') + FORM_AT + i * FORM_STEP, kind: 'tiny' })),
...CONSOLE_LINES.map(l => ({ t: at('consoles') + l.at, kind: 'tiny' })),
...FIX_AT.map((t, i) => ({ t: at('scan') + t, kind: 'good', step: i })),
{ t: at('scan') + RESCAN_AT, kind: 'clean' },
];
}
/** Seconds at which each typed character lands (soft key clicks). */
export function keyTimes() {
const out = [];
for (let i = 0; i < PROMPT.length; i++) if (PROMPT[i] !== ' ' || i % 2) out.push(scene('ask').start + PROMPT_AT + i / PROMPT_CPS);
for (let i = 0; i < END_CMD.length; i++) out.push(scene('end').start + END.cmdAt + i / END.cps);
return out;
}