Portfolio (every app, both stores, rollout %, crash sparklines), the Growth and Health charts with a real hover sweep (one captured frame per hovered day, the pointer follows the recorded positions), the Prices matrix (both stores per country, gaps marked), then v5's release timeline and 0 blockers. All frames are real fixture-mode captures (desktop.ts). Tests: scenes 4-5 s, 50-60 s total, captions >= 3 s, every desktop frame captured by desktop.ts and present.
279 lines
17 KiB
JavaScript
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 = 764;
|
|
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;
|
|
}
|