Nine scenes of 6-7 s, one slow camera move each, soft dissolves, one plain-English caption per scene at the top (on screen at least 4 s): the stores' to-do list and one rejection mail, `init` in an empty folder, the agent prompt and `release plan` (25 steps), store text and renders in 7 sizes x 4 languages, `play products --dry` and `catalog diff`, the scan's declarations proposal with the ask-owner line, three findings with plain reasons turning green, the desktop release timeline and Store readiness "No blockers", Submit and the end card. Every CLI line is real output: tests/teaser.test.ts runs each command (store answers from tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml, never the network) and checks scene lengths, caption holds, the 55-65 s total and 16:9 only. The vertical output is gone; the web version targets 4.5 MB. Calmer music (D major pads, soft pluck, bells on scene changes). desktop.ts adds the Store readiness frame.
253 lines
15 KiB
JavaScript
253 lines
15 KiB
JavaScript
// Teaser timeline (v4): the single source of truth for the picture (teaser.js) and the sound (music.js).
|
|
// One idea per scene, 6-7 s each, one slow camera move per scene, soft dissolves between scenes (no whips, no rapid
|
|
// cuts). Every scene has one plain-English caption at the top that is fully on screen for at least 4 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 };
|
|
|
|
/** Scenes in seconds. Each holds 6-7 s; the next one dissolves in over DISSOLVE s centred on the cut. */
|
|
export const SCENES = [
|
|
{ id: 'hook', start: 0, end: 6 },
|
|
{ id: 'init', start: 6, end: 12 },
|
|
{ id: 'plan', start: 12, end: 19 },
|
|
{ id: 'store', start: 19, end: 26 },
|
|
{ id: 'products', start: 26, end: 33 },
|
|
{ id: 'forms', start: 33, end: 40 },
|
|
{ id: 'rejections', start: 40, end: 47 },
|
|
{ id: 'desktop', start: 47, end: 54 },
|
|
{ id: 'submit', start: 54, end: 60 },
|
|
];
|
|
export const DURATION = SCENES.at(-1).end;
|
|
export const DISSOLVE = 0.6;
|
|
export const scene = id => SCENES.find(s => s.id === id);
|
|
|
|
/**
|
|
* One slow camera move per scene, over the whole scene: from -> to, eased in and out.
|
|
* 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 } },
|
|
desktop: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } }, // the push happens inside the app window
|
|
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: '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.15, CAP_IN = 0.4, CAP_OUT = 0.4, CAP_END = 0.1;
|
|
/** When each caption is fully on screen (tests: at least 4 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.6, text: '2 store consoles' },
|
|
{ at: 1.25, text: 'dozens of forms' },
|
|
{ at: 1.9, text: '7 screenshot sizes × 14 languages' },
|
|
{ at: 2.6, 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.5;
|
|
export const MAIL = { at: 3.3, 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.5, INIT_CPS = 13, INIT_OUT_AT = 2.5, INIT_STEP = 0.22;
|
|
/** 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.3, PROMPT_CPS = 26;
|
|
export const PLAN_CMD = 'storebound release plan 1.0.0';
|
|
export const PLAN_CMD_AT = 1.9, PLAN_AT = 2.3, PLAN_STEP = 0.2;
|
|
/** 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.5, COPY_STEP = 0.5;
|
|
/** 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.9, SHOTS_STEP = 0.38;
|
|
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.4, text: '$ storebound play products --dry', cmd: true },
|
|
{ at: 0.9, text: '[play] 1/2 com.example.app.no_ads: would create · 174 regions · 2 listings (dry)' },
|
|
{ at: 1.2, text: '[play] 2/2 com.example.app.gems_100: would create · 174 regions · 2 listings (dry)' },
|
|
{ at: 1.5, text: '[play] products checked (dry run, nothing written)' },
|
|
{ at: 2.2, text: '$ storebound catalog diff', cmd: true },
|
|
{ at: 2.7, text: 'ok [apple] com.example.app.gems_100: consumable, 175 region prices, READY_TO_SUBMIT' },
|
|
{ at: 3.0, text: 'ok [play] com.example.app.gems_100: consumable, 174 region prices, ACTIVE' },
|
|
{ at: 3.3, text: 'ok [apple] com.example.app.no_ads: non_consumable, 175 region prices, READY_TO_SUBMIT' },
|
|
{ at: 3.6, text: 'ok [play] com.example.app.no_ads: non_consumable, 174 region prices, ACTIVE' },
|
|
{ at: 4.0, text: '[catalog] 2 catalog items: 0 mismatch(es), 0 warning(s), 0 price difference(s) between the stores' },
|
|
];
|
|
export const PRODUCTS_PILL = { at: 4.5, 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.5, DECL_STEP = 0.4, ASK_AT = 2.3;
|
|
/** 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 = 2.9, FORM_STEP = 0.2;
|
|
/** Data safety CSV rows `storebound check` builds from the config. */
|
|
export const DECL_TOTAL = 764;
|
|
export const FORMS_PILL = { at: 4.5, 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: 1.1, 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.9, 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: 2.7, 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 = [4.0, 4.35, 4.7];
|
|
export const RESCAN_AT = 5.2;
|
|
export const RESCAN_LINE = 'Rejection risks: 0 blocker, 0 high, 0 medium, 0 low';
|
|
|
|
// ---------- 8 desktop: the release timeline (both stores), then Store readiness: no blockers ----------
|
|
/** Real frames (desktop.ts). The camera pushes from the timeline to the readiness card; the frames dissolve between. */
|
|
export const DESKTOP = {
|
|
frames: ['releases', 'ready'],
|
|
swap: [3.1, 3.8],
|
|
cam: { from: { f: [0.56, 0.42], z: 1.0 }, to: { f: [0.8, 0.4], z: 1.22 } },
|
|
callouts: [
|
|
{ at: 1.1, until: 3.0, target: 'timeline', text: 'App Store + Google Play, one track' },
|
|
{ at: 4.3, until: 7, target: 'readyEmpty', text: 'Validate: 0 blockers on both', green: true },
|
|
],
|
|
};
|
|
|
|
// ---------- 9 submit + end card ----------
|
|
export const SUBMIT = { hover: 1.5, logoAt: 3.0, logoIn: 0.8 };
|
|
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 })),
|
|
...DESKTOP.callouts.map(c => ({ t: at('desktop') + 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;
|
|
}
|