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.
142 lines
6.3 KiB
JavaScript
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);
|