storebound/media/teaser/script.js

333 lines
22 KiB
JavaScript

// Teaser timeline (v7): the single source of truth for the picture (teaser.js) and the sound (music.js).
// One idea per scene, 4-5 s each (62 s in all), one camera move per scene with a quick ease-out, short dissolves
// on the cuts (no whips, no rapid montages). Long terminal output streams faster instead of being cut. Every scene has
// one plain-English caption at the top that is fully on screen for at least 3 s; the lower part shows the real
// product: CLI output and desktop-app frames.
//
// Every CLI line shown here is real output; tests/teaser.test.ts runs each command and checks every line:
// init `storebound init` in an empty folder (the clock prefix left out)
// plan `storebound release plan 1.0.0` in that folder, the stores answered by tests/helpers/teaser-store-mock.ts
// (a brand-new app record: nothing on either store yet); the "-> fix" lines left out
// products `storebound play products --dry` and `storebound catalog diff` in that folder with two products added
// to the config, the same store double (before: nothing on the stores; after: both products on both)
// forms the declarations proposal of `storebound scan` (tests/fixtures/scan/teaser) and the Data safety
// answer count of `storebound check`
// consoles `storebound console plan apple-app-privacy` in the init folder (offline)
// rejections `storebound scan` of tests/fixtures/scan/teaser and, after the fixes, of teaser-fixed
// The store text is examples/demo-game/store/shots/copy.json, the screenshots its real renders (render.ts copies
// them), the desktop frames the app in fixture mode (desktop.ts; its console plan panel shows the real plan of
// `console plan apple-app-privacy --json`). The plain reasons under the findings and the
// callouts are annotations, set apart from the terminal text.
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 };
/** Scene lengths in seconds (4-5 s each). The next scene dissolves in over DISSOLVE s centred on the cut. */
const LENGTHS = [['hook', 4.5], ['init', 4.5], ['plan', 5], ['store', 5], ['products', 4.5], ['forms', 5], ['consoles', 5], ['rejections', 5], ['portfolio', 4.5], ['charts', 4.5], ['catalog', 4.5], ['resubmit', 5], ['submit', 5]];
export const SCENES = LENGTHS.reduce((a, [id, len]) => { const start = a.at(-1)?.end ?? 0; return [...a, { id, start, end: start + len }]; }, []);
export const DURATION = SCENES.at(-1).end;
export const DISSOLVE = 0.4;
export const scene = id => SCENES.find(s => s.id === id);
/**
* One camera move per scene, over the whole scene: from -> to, a quick ease-out that settles before the cut.
* z = zoom, x / y = pan in px (applied to the scene's content, never to the caption).
*/
export const CAMERA = {
hook: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: 6 } },
init: { from: { z: 1.0, x: 20, y: 0 }, to: { z: 1.06, x: -40, y: 0 } },
plan: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: -24 } },
store: { from: { z: 1.02, x: 40, y: 0 }, to: { z: 1.02, x: -40, y: 0 } },
products: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.06, x: 0, y: -10 } },
forms: { from: { z: 1.02, x: 30, y: 0 }, to: { z: 1.02, x: -30, y: 0 } },
// the terminal, then the app's plan panel dissolving in over it: one slow push over both
consoles: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.03, x: 0, y: 4 } },
rejections: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: -12 } },
// the desktop scenes: the push happens inside the app window (DESK)
portfolio: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
charts: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
catalog: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
resubmit: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
submit: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
};
/** Captions: one per scene, at the top. *word* = highlighted. Lines are set as written. */
export const CAPTIONS = [
{ id: 'hook', lines: ['Your app is done. The stores are *not.*'], hl: 'red' },
{ id: 'init', lines: ['storebound takes it from an *empty folder.*'] },
{ id: 'plan', lines: ['You ask once. It plans *every step.*'] },
{ id: 'store', lines: ['Store text and screenshots.', '*Every language, every size.*'] },
{ id: 'products', lines: ['In-app products on both stores,', 'priced in *175 countries.*'] },
{ id: 'forms', lines: ['The forms, answered from your code.', 'It asks what it *can\'t know.*'] },
{ id: 'consoles', lines: ['No API? *Your AI fills the console.*', 'It stops at Publish.'] },
{ id: 'rejections', lines: ['It catches rejections *before Apple does.*'], hl: 'green' },
{ id: 'portfolio', lines: ['Stop switching between two consoles.', 'Every app, both stores, *one window.*'] },
{ id: 'charts', lines: ['Installs, conversion and crashes, *charted.*'] },
{ id: 'catalog', lines: ['Products and prices on both stores, *side by side.*'] },
{ id: 'resubmit', lines: ['Rejected? Fix it, *resubmit in one click.*'] },
{ id: 'submit', lines: ['You press *Submit.* That\'s it.'] },
];
/** Caption fade: in over CAP_IN from CAP_DELAY after the scene starts; out over CAP_OUT, ending CAP_END before it ends. */
export const CAP_DELAY = 0.1, CAP_IN = 0.3, CAP_OUT = 0.3, CAP_END = 0.05;
/** When each caption is fully on screen (tests: at least 3 s). */
export function captionTimes() {
return CAPTIONS.map(c => {
const s = scene(c.id);
const at = s.start + CAP_DELAY, from = at + CAP_IN, to = s.end - CAP_END - CAP_OUT, gone = s.end - CAP_END;
return { id: c.id, text: c.lines.join(' ').replace(/\*/g, ''), at, from, to, gone, hold: to - from };
});
}
// ---------- 1 hook: the app is done, the stores' to-do list, one rejection mail ----------
export const APP_NAME = 'Prism Puddle';
/** The stores' to-do list (line by line at reading speed). at = seconds into the scene. */
export const TODO = [
{ at: 0.35, text: '2 store consoles' },
{ at: 0.7, text: 'dozens of forms' },
{ at: 1.05, text: '7 screenshot sizes × 14 languages' },
{ at: 1.4, text: 'prices in 175 countries' },
];
/** A generic, made-up inbox row (no mail-app branding, no real names or addresses): fades in over MAIL_IN from `at`,
* then holds to the cut. */
export const MAIL_IN = 0.4;
export const MAIL = { at: 1.7, from: 'App Store Connect', date: '9:41 AM', subject: "There's an issue with your Prism Puddle submission", guideline: 'Guideline 2.1 · Information Needed' };
// ---------- 2 init: an empty folder, `storebound init` ----------
export const FOLDER = 'my-app';
export const INIT_CMD = 'bunx storebound init';
export const INIT_TYPE_AT = 0.35, INIT_CPS = 22, INIT_OUT_AT = 1.45, INIT_STEP = 0.12;
/** Real output of `storebound init` (clock prefix left out). file = the file the line creates (the folder list). */
export const INIT_LINES = [
'[init] create storebound.config.ts',
'[init] create store/listing.md',
'[init] create store/appstore.md',
'[init] create store/shots/copy.json',
'[init] create store/app-privacy.md',
'[init] create store/declarations.md',
'[init] create store/privacy-policy.html',
'[init] create .storebound/credentials.json',
'[init] create .storebound/testers.csv',
'[init] .gitignore: added .storebound/',
];
// ---------- 3 plan: the agent prompt, `storebound release plan` ----------
export const PROMPT = 'Get my app ready for both stores.';
export const PROMPT_AT = 0.2, PROMPT_CPS = 40;
export const PLAN_CMD = 'storebound release plan 1.0.0';
export const PLAN_CMD_AT = 1.25, PLAN_AT = 1.55, PLAN_STEP = 0.13;
/** Real lines of the plan (a brand-new app record on both stores); the "-> fix" lines and some steps left out. */
export const PLAN_LINES = [
'Example App 1.0.0',
' both stores (fix first):',
' 1. CONFIG App Review needs a demo account (sign-in required) · add reviewDemoAccount to .storebound/credentials.json · review-access skill',
' 2. CONFIG missing file: store/icon/icon.png',
' App Store: not on the store',
' 5. STORE 1.0.0 is not on App Store yet: upload a build (Xcode / Transporter), then create the version with its texts',
' 6. BLOCKER no App Store version being prepared',
' 12. BLOCKER no app price schedule',
' Google Play: not on the store',
' 16. STORE 1.0.0 is not on Google Play yet: upload the AAB to a testing track',
' 20. BLOCKER no release on any track',
];
/** Steps in the whole plan (the numbered lines of the real output). */
export const PLAN_STEPS = 26;
export const PLAN_PILL = `*${PLAN_STEPS}* steps · both stores · in fix order`;
// ---------- 4 store: listing text in every language, screenshots in every size ----------
/** examples/demo-game/store/shots/copy.json, the hero title per language. */
export const COPY_FILE = 'store/shots/copy.json';
export const COPY = [
{ lang: 'en', title: ['Catch every', 'color drop!'] },
{ lang: 'tr', title: ['Renkli damlaları', 'yakala!'] },
{ lang: 'ja', title: ['カラフルなしずくを', 'キャッチ!'] },
{ lang: 'ar', title: ['التقط كل', 'قطرة ملوّنة!'], rtl: true },
];
export const COPY_AT = 0.3, COPY_STEP = 0.3;
/** 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\n6.9″' },
{ id: 'ios65', label: 'iPhone\n6.5″' },
{ id: 'ipad13', label: 'iPad\n13″' },
{ id: 'play', label: 'Play\nphone' },
{ id: 'tab7', label: 'Play\n7″ tab' },
{ id: 'tab10', label: 'Play\n10″ tab' },
{ id: 'feature', label: 'Play\nfeature graphic' },
];
export const SHOT_LANGS = COPY.map(c => c.lang);
export const SHOTS_AT = 0.6, SHOTS_STEP = 0.25;
export const SHOTS_CMD = 'storebound shots render';
export const SHOTS_PILL = `*${SHOT_SIZES.length}* sizes × *${COPY.length}* languages`;
// ---------- 5 products: `play products --dry`, then `catalog diff` ----------
/** Real output (clock prefix and the "(0s)" timer left out). at = seconds into the scene. */
export const PRODUCT_LINES = [
{ at: 0.3, text: '$ storebound play products --dry', cmd: true },
{ at: 0.6, text: '[play] 1/2 com.example.app.no_ads: would create · 174 regions · 2 listings (dry)' },
{ at: 0.8, text: '[play] 2/2 com.example.app.gems_100: would create · 174 regions · 2 listings (dry)' },
{ at: 1.0, text: '[play] products checked (dry run, nothing written)' },
{ at: 1.35, text: '$ storebound catalog diff', cmd: true },
{ at: 1.65, text: 'ok [apple] com.example.app.gems_100: consumable, 175 region prices, READY_TO_SUBMIT' },
{ at: 1.85, text: 'ok [play] com.example.app.gems_100: consumable, 174 region prices, ACTIVE' },
{ at: 2.05, text: 'ok [apple] com.example.app.no_ads: non_consumable, 175 region prices, READY_TO_SUBMIT' },
{ at: 2.25, text: 'ok [play] com.example.app.no_ads: non_consumable, 174 region prices, ACTIVE' },
{ at: 2.5, text: '[catalog] 2 catalog items: 0 mismatch(es), 0 warning(s), 0 price difference(s) between the stores' },
];
export const PRODUCTS_PILL = { at: 2.9, text: '*175* App Store regions · *174* Google Play regions · parity *✓*' };
// ---------- 6 forms: the declarations proposal from the scan, then the Data safety answers ----------
/** Real lines of the proposal (scan of the teaser fixture). */
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.4, DECL_STEP = 0.25, ASK_AT = 1.5;
/** Play Data safety form rows the detected answers fill (Play's own 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 = 1.9, FORM_STEP = 0.14;
/** Data safety CSV rows `storebound check` builds from the config. */
export const DECL_TOTAL = 768;
export const FORMS_PILL = { at: 3.0, text: 'Data safety answers' };
// ---------- 7 consoles: the console plan of a form without an API, then the app's plan panel ----------
export const CONSOLE_CMD = 'storebound console plan apple-app-privacy';
/** The command is typed (fast), then the output streams. */
export const CONSOLE_TYPE_AT = 0.05, CONSOLE_CPS = 120;
/** Real lines of the plan (a fresh `storebound init` folder); the other steps and answers left out. at = seconds into
* the scene. final = the owner's button, highlighted. */
export const CONSOLE_LINES = [
{ at: 0.45, text: 'App Store Connect: App Privacy', head: true },
{ at: 0.6, text: 'Steps:', head: true },
{ at: 0.72, text: ' 1. Open App Store Connect > Apps > the app > App Privacy (sidebar).' },
{ at: 0.84, text: ' 2. First time: click "Get Started" and answer the Data Collection question; then Next. [click: "Get Started"]' },
{ at: 0.96, text: ' 39. Stop. Do NOT press Publish: tell the owner to review the summary on the App Privacy page and press Publish, then Publish again in the "Publish Your App Privacy Responses?" dialog (it puts the answers on the App Store product page). Afterwards the page shows "Published … ago by …".' },
{ at: 1.14, text: 'Answer sheet:', head: true },
{ at: 1.26, 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: 1.38, text: ' [ask] Data type: User Content > Gameplay Content: (you decide) -> untick "Gameplay Content" @ the Data Collection dialog (Edit next to Data Types): the data type list' },
{ at: 1.56, text: 'Never press: "Publish", "Submit", "Create", "Create app", "Send", "Release", "Definitions and Examples", "?", "More information"' },
{ at: 1.72, text: "Final button (the owner's): Publish", final: true },
];
/** Steps and answer-sheet rows in the whole plan (the numbered and the [ok] / [ask] lines of the real output). */
export const CONSOLE_STEPS = 40, CONSOLE_ANSWERS = 92;
export const CONSOLES_PILL = { at: 1.95, text: `*${CONSOLE_STEPS}* steps · *${CONSOLE_ANSWERS}* answers · stops before *Publish*` };
/** The terminal gives way to the app's plan panel (DESK.consoles) over this window, seconds into the scene. */
export const CONSOLES_SWAP = [2.45, 0.5];
// ---------- 8 rejections: three findings with a plain reason each, then fixed ----------
export const SCAN_CMD = 'storebound scan';
export const RISKS_LINE = 'Rejection risks: 2 blocker, 3 high, 0 medium, 0 low';
/** Three of the five findings (real text); why = a plain-English annotation, not CLI output. */
export const FINDINGS = [
{ at: 0.8, sev: 'BLOCKER', guideline: '2.1(a)', title: 'Sign-in is required but there is no demo account for App Review', where: 'storebound.config.ts:12',
why: 'Apple rejects sign-in apps without a demo account.' },
{ at: 1.3, sev: 'BLOCKER', guideline: '5.1.1(v)', title: 'Accounts can be created but no in-app account deletion was found', where: 'Planner/LoginView.swift:11',
why: 'Apps that create accounts must let people delete them.' },
{ at: 1.8, sev: 'HIGH', guideline: '5.1.2(i)', title: 'Tracking-capable code without App Tracking Transparency (Google AdMob, IDFA)', where: 'Planner/Ads.swift:7',
why: 'Ads that track people must ask permission first.' },
];
/** Each finding turns green (fixed), then the re-scan of the fixed copy (tests/fixtures/scan/teaser-fixed). */
export const FIX_AT = [2.7, 2.95, 3.2];
export const RESCAN_AT = 3.6;
export const RESCAN_LINE = 'Rejection risks: 0 blocker, 0 high, 0 medium, 0 low';
// ---------- 7, 9-13 desktop: the app as the one console for both stores ----------
/** Real frames of the desktop app in fixture mode (desktop.ts), one entry per scene. groups: what the window shows, in
* order; a group is one frame, or a frame plus a hover sweep (the pointer glides across a chart, the crosshair and
* tooltip follow it day by day: one captured frame per hovered day, pointer positions in boxes.json). 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. callouts: on boxes captured from the app (boxes.json), below the box (above: true, above it).
* show / hide: the window fades in / out ([at, seconds]); pointer: the mouse pointer glides onto a captured box and
* rests there (it never clicks). */
export const DESK = {
// the console plan panel (App info > Console steps > App Privacy > Plan) with the real plan, at its final button
consoles: {
groups: [{ frame: 'console-plan' }],
show: CONSOLES_SWAP,
cam: { from: { f: [0.5, 0.56], z: 1.18 }, to: { f: [0.5, 0.56], z: 1.18 } },
callouts: [{ at: 3.15, until: 5.3, target: 'cpFinal', text: 'Publish stays yours: it stops there', green: true }],
},
portfolio: {
groups: [{ frame: 'portfolio-apps' }],
cam: { from: { f: [0.5, 0.42], z: 1.0 }, to: { f: [0.6, 0.6], z: 1.14 } },
callouts: [{ at: 1.1, until: 5, target: 'prismRow', text: 'Both stores per app: rollout, crash rate' }],
},
charts: {
groups: [{ frame: 'growth', sweep: { seq: 'growth', at: 0.55, d: 1.75 } }, { frame: 'health', sweep: { seq: 'health', at: 2.85, d: 1.35 } }],
swap: [2.35, 2.75],
cam: { from: { f: [0.55, 0.5], z: 1.0 }, to: { f: [0.55, 0.6], z: 1.1 } },
callouts: [{ at: 0.7, until: 2.4, target: 'growthPlot', text: "Every day, the stores' own numbers" }],
},
catalog: {
groups: [{ frame: 'prices' }],
cam: { from: { f: [0.55, 0.4], z: 1.0 }, to: { f: [0.72, 0.42], z: 1.14 } },
callouts: [{ at: 1.2, until: 5, target: 'priceGap', text: 'Price gap between the stores, per country' }],
},
// Releases > Send for review, back from App Review (UNRESOLVED_ISSUES): the rejected item, build 52 picked, Resubmit
resubmit: {
groups: [{ frame: 'resubmit' }],
cam: { from: { f: [0.45, 0.45], z: 1.0 }, to: { f: [0.3, 0.62], z: 1.18 } },
callouts: [
{ at: 0.6, until: 2.5, target: 'unresolvedItem', text: 'Back from App Review: what was rejected' },
{ at: 2.75, until: 5.3, target: 'resubmitBtn', text: 'Marks it resolved and resubmits' },
],
pointer: { at: 2.2, d: 0.8, target: 'resubmitBtn' },
},
// Submit for review: the dry-run plan with "Continue" (the in-app step before the system dialog), then the new
// submission waiting for review; the window then gives way to the end card
submit: {
groups: [{ frame: 'submit-plan' }, { frame: 'submit-waiting' }],
swap: [1.55, 1.95],
hide: [3.0, 0.5],
cam: { from: { f: [0.5, 0.45], z: 1.0 }, to: { f: [0.5, 0.48], z: 1.1 } },
callouts: [
{ at: 0.35, until: 1.5, target: 'oaPlan', text: 'Dry run first: nothing sent yet', above: true },
{ at: 2.05, until: 3.0, target: 'waitingItem', text: 'Waiting for review', green: true },
],
pointer: { at: 0.3, d: 0.85, target: 'oaContinue', until: 1.55 },
},
};
export const DESK_SCENES = Object.keys(DESK);
/** Every captured frame a desktop scene names (sweep frames are listed in boxes.json by desktop.ts). */
export const DESK_FRAMES = [...new Set(Object.values(DESK).flatMap(d => d.groups.map(g => g.frame)))];
export const DESK_SWEEPS = Object.values(DESK).flatMap(d => d.groups.filter(g => g.sweep).map(g => g.sweep.seq));
// ---------- 13 submit: end card ----------
/** The end card comes in at logoAt (seconds into the submit scene), as the app window fades out (DESK.submit.hide). */
export const SUBMIT = { logoAt: 3.0, logoIn: 0.6 };
export const URL = 'storebound.dev';
export const TAGLINE = 'Open source · CLI · Desktop app';
// ---------- sound cues (music.js) ----------
/** Soft accents: one on every scene change. */
export const CUTS = SCENES.slice(1).map(s => ({ t: s.start, id: s.id }));
/** Gentle ticks: list items, files, plan lines, products, form rows, findings (low), fixes (bright). */
export function ticks() {
const at = id => scene(id).start;
return [
...TODO.map(x => ({ t: at('hook') + x.at, kind: 'soft' })),
{ t: at('hook') + MAIL.at, kind: 'mail' },
...INIT_LINES.map((_, i) => ({ t: at('init') + INIT_OUT_AT + 0.2 + i * INIT_STEP, kind: 'tiny' })),
...PLAN_LINES.map((_, i) => ({ t: at('plan') + PLAN_AT + i * PLAN_STEP, kind: 'tiny' })),
...COPY.map((_, i) => ({ t: at('store') + COPY_AT + i * COPY_STEP, kind: 'soft' })),
...PRODUCT_LINES.filter(l => !l.cmd).map(l => ({ t: at('products') + l.at, kind: 'tiny' })),
{ t: at('products') + PRODUCTS_PILL.at, kind: 'good', step: 2 },
...DECL_LINES.map((_, i) => ({ t: at('forms') + DECL_AT + i * DECL_STEP, kind: 'tiny' })),
{ t: at('forms') + ASK_AT, kind: 'ask' },
...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: l.final ? 'ask' : 'tiny' })),
...FINDINGS.map(f => ({ t: at('rejections') + f.at, kind: 'low' })),
...FIX_AT.map((t, i) => ({ t: at('rejections') + t, kind: 'good', step: i })),
...DESK_SCENES.flatMap(id => DESK[id].callouts.map(c => (id === 'submit' && c.green ? { t: at(id) + c.at, kind: 'good', step: 2 } : { t: at(id) + c.at, kind: 'soft' }))),
];
}
/** Seconds at which each typed character lands (soft key clicks). */
export function keyTimes() {
const out = [];
for (let i = 0; i < INIT_CMD.length; i++) out.push(scene('init').start + INIT_TYPE_AT + i / INIT_CPS);
for (let i = 0; i < PROMPT.length; i++) out.push(scene('plan').start + PROMPT_AT + i / PROMPT_CPS);
for (let i = 0; i < CONSOLE_CMD.length; i += 3) out.push(scene('consoles').start + CONSOLE_TYPE_AT + i / CONSOLE_CPS);
return out;
}