storebound/media/teaser/script.js

279 lines
17 KiB
JavaScript

// Teaser timeline (v6): the single source of truth for the picture (teaser.js) and the sound (music.js).
// One idea per scene, 4-5 s each (about 55 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`
// 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). 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], ['rejections', 5], ['portfolio', 4.5], ['charts', 4.5], ['catalog', 4.5], ['desktop', 4.5], ['submit', 4]];
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 } },
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 } },
desktop: { 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.05, 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: '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: 'desktop', lines: ['Both stores, one view. *Ready for review.*'] },
{ 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 = 25;
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 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';
// ---------- 8-11 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: one per scene, on a box captured from the app (boxes.json). */
export const DESK = {
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' }],
},
// v5's desktop scene, unchanged: the release timeline, then Store readiness (validate): no blockers
desktop: {
groups: [{ frame: 'releases' }, { frame: 'ready' }],
swap: [2.1, 2.6],
cam: { from: { f: [0.56, 0.42], z: 1.0 }, to: { f: [0.8, 0.4], z: 1.22 } },
callouts: [
{ at: 0.7, until: 2.0, target: 'timeline', text: 'App Store + Google Play, one track' },
{ at: 2.9, until: 5, target: 'readyEmpty', text: 'Validate: 0 blockers on both', green: true },
],
},
};
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));
// ---------- 9 submit + end card ----------
export const SUBMIT = { hover: 1.0, logoAt: 2.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' })),
...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 => ({ 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);
return out;
}