storebound/media/teaser/script.js
CRT b142cf6d21 Teaser video source: scripted terminal timeline, procedural music, render script (media/teaser)
HTML/CSS/JS timeline rendered frame by frame in headless Chrome, original music synthesized with OfflineAudioContext, ffmpeg encode + loudnorm. Outputs go to media/teaser/out (gitignored); poster.jpg is the end card.
2026-10-11 01:02:17 +08:00

142 lines
6.3 KiB
JavaScript

// Teaser timeline: the single source of truth for the picture (teaser.js) and the music (music.js).
// Everything is a pure function of time, so a frame at t always looks the same and the hits land on the cuts.
// The terminal text is scripted, modelled on real storebound output for the demo game (examples/demo-game,
// placeholder ids, dry runs). Nothing here talks to a store.
export const BPM = 128;
export const BEAT = 60 / BPM; // 0.46875 s
export const BAR = BEAT * 4; // 1.875 s
export const FPS = 30;
export const BARS = 20;
export const DURATION = BARS * BAR; // 37.5 s
/** Scenes in bars; start/end in seconds are derived below. */
const SCENE_BARS = [
['prompt', 2],
['config', 2],
['prices', 2],
['languages', 2],
['questionnaires', 2],
['shots', 3],
['validate', 2],
['submit', 2],
['brand', 3],
];
let acc = 0;
export const SCENES = SCENE_BARS.map(([id, bars]) => {
const s = { id, start: acc * BAR, end: (acc + bars) * BAR, bars };
acc += bars;
return s;
});
export const scene = id => SCENES.find(s => s.id === id);
export const PROMPT = 'ship Prism Puddle to Google Play and the App Store';
export const PROMPT_AT = 0.35; // seconds
export const PROMPT_CPS = 21; // the user types
export const CMD_CPS = 62; // the agent types
export const LANGS = ['en-US', 'tr-TR', 'de-DE', 'fr-FR', 'es-ES', 'es-419', 'it-IT', 'pt-BR', 'ja-JP', 'ko-KR', 'zh-CN', 'ru-RU', 'ar', 'hi-IN'];
// Price rows for the flicker panel: illustrative local price points for a 2.99 / 0.99 USD pair (Play converts from
// USD per region; TR is a config override, as in templates/storebound.config.ts).
export const PRICES = [
['US', 'USD', '2.99', '0.99'],
['TR', 'TRY', '99.99', '34.99'],
['DE', 'EUR', '2.99', '0.99'],
['GB', 'GBP', '2.79', '0.89'],
['JP', 'JPY', '450', '150'],
['BR', 'BRL', '14.90', '4.90'],
['IN', 'INR', '249', '79'],
['KR', 'KRW', '4,100', '1,300'],
['MX', 'MXN', '59.00', '19.00'],
['ID', 'IDR', '45,000', '15,000'],
['SA', 'SAR', '10.99', '3.69'],
['FR', 'EUR', '2.99', '0.99'],
['CA', 'CAD', '3.99', '1.39'],
['AU', 'AUD', '4.49', '1.49'],
];
export const BLOCKERS = [
['apple', 'build 12 is PROCESSING'],
['apple', 'ja: missing keywords'],
['apple', 'remove_ads: MISSING_METADATA'],
['apple', 'no app price schedule'],
['play', 'tr-TR: no listing'],
['play', 'drops_500: not on Play'],
['play', 'no release on any track'],
];
export const BLOCKERS_AT = 0.45; // first blocker line, relative to the validate scene
export const BLOCKER_GAP = 0.05;
export const FIX_AT = 1.05; // first flip to ok
export const FIX_GAP = 0.26;
/**
* Terminal lines per scene. at = seconds after the scene start.
* kind: cmd (typed by the agent), out (printed), note (agent comment), ok / bad (coloured output).
*/
const T = {
prompt: [
{ at: 2.85, kind: 'note', text: 'agent using storebound · dry runs first' },
],
config: [
{ at: 0.0, kind: 'cmd', text: 'bunx storebound init' },
{ at: 0.45, kind: 'out', text: '[init] create storebound.config.ts' },
{ at: 0.55, kind: 'out', text: '[init] create store/listing.md' },
{ at: 0.65, kind: 'out', text: '[init] create store/appstore.md' },
{ at: 0.75, kind: 'out', text: '[init] create store/declarations.md' },
{ at: 0.85, kind: 'out', text: '[init] create store/app-privacy.md' },
{ at: 0.95, kind: 'out', text: '[init] create store/privacy-policy.html' },
{ at: 1.875, kind: 'cmd', text: 'bunx storebound check' },
{ at: 2.3, kind: 'out', text: '[check] config ok: storebound.config.ts' },
{ at: 2.42, kind: 'out', text: '[check] play listing: 14 languages' },
{ at: 2.54, kind: 'out', text: '[check] appstore listing: 14 locales' },
{ at: 2.66, kind: 'out', text: '[check] data safety: 764 CSV rows' },
{ at: 2.78, kind: 'out', text: '[check] shots copy: 14 languages x 4 scenes' },
],
prices: [
{ at: 0.0, kind: 'cmd', text: 'bunx storebound play products --dry' },
{ at: 0.62, kind: 'out', text: '[play] 1/2 remove_ads: create · 173 regions (dry)' },
{ at: 0.9, kind: 'out', text: '[play] 2/2 drops_500: create · 173 regions (dry)' },
{ at: 1.4, kind: 'cmd', text: 'bunx storebound apple iap --dry' },
{ at: 1.95, kind: 'out', text: '[apple] 1/2 remove_ads: missing (dry)' },
{ at: 2.07, kind: 'out', text: '[apple] 2/2 drops_500: missing (dry)' },
],
languages: [
{ at: 0.0, kind: 'cmd', text: 'bunx storebound play listing --dry' },
{ at: 0.6, kind: 'out', text: `[play] 14 listings: ${LANGS.slice(0, 4).join(', ')} ...` },
...LANGS.map((l, i) => ({ at: 0.8 + i * 0.13, kind: 'out', text: `[play] ${String(i + 1).padStart(2)}/14 ${l}` })),
],
questionnaires: [
{ at: 0.0, kind: 'cmd', text: 'bunx storebound play data-safety --dry' },
{ at: 0.62, kind: 'out', text: '[play] data safety: 6 data types declared' },
{ at: 0.95, kind: 'cmd', text: 'bunx storebound apple appinfo --dry' },
{ at: 1.55, kind: 'out', text: '[apple] categories: GAMES / GAMES_CASUAL (dry)' },
{ at: 1.67, kind: 'out', text: '[apple] age rating declaration: all NONE (dry)' },
{ at: 1.85, kind: 'note', text: '[apple] appinfo done (App Privacy is UI-only: store-declarations skill)' },
],
shots: [
{ at: 0.0, kind: 'cmd', text: 'bunx storebound shots render' },
...['play', 'tab7', 'tab10', 'ios69', 'ios65', 'ipad13', 'feature'].flatMap((d, k) =>
['en', 'tr', 'ja', 'ar'].map((l, j) => ({ at: 0.5 + (k * 4 + j) * 0.14, kind: 'out',
text: `[shots] store/renders/${l}/${d}/${d === 'feature' ? '1024x500' : '01'}.png` }))),
],
validate: [
{ at: 0.0, kind: 'cmd', text: 'bunx storebound validate' },
// the blocker lines are drawn by teaser.js from BLOCKERS (they change state)
],
};
export const TERMINAL = T;
/** Seconds at which each typed character lands (for key clicks in the music). */
export function keyTimes() {
const out = [];
for (let i = 0; i < PROMPT.length; i++) out.push(PROMPT_AT + i / PROMPT_CPS);
for (const s of SCENES) for (const l of T[s.id] ?? []) {
if (l.kind !== 'cmd') continue;
for (let i = 0; i < l.text.length; i++) out.push(s.start + l.at + i / CMD_CPS);
}
return out;
}
/** Seconds of the scene cuts that get a hit (every scene after the first). */
export const CUTS = SCENES.slice(1).map(s => s.start);