teaser v9 animatic: 41 s story cut tight (hard cuts on the beat, 2-4 s shots, frame 0 moving, every shot opens on its subject, captions <= 6 words held >= 2 s, end card with npx storebound init held 4 s); render.ts --animatic (960x540 draft with sound, contact sheet, shot list, end-card layout checks); tests for the v9 timing rules

This commit is contained in:
CRTkafa 2026-10-14 00:21:09 +08:00
parent 7ac028421a
commit c608b9f63e
6 changed files with 326 additions and 207 deletions

View file

@ -1,18 +1,18 @@
// Teaser timeline (v8, a story): the single source of truth for the picture (teaser.js) and the sound (music.js).
// 58 s, 16:9. Little text: one short line per scene at most, big and calm; the music carries the arc
// (calm -> tension -> near silence -> a driving build -> stop -> resolve). Every cut lands on a half-second beat.
// Teaser timeline (v9, the story cut tight): the single source of truth for the picture (teaser.js) and the sound
// (music.js). 41 s, 16:9, 120 BPM: every cut lands on a beat (a whole half second), every shot does one thing in
// 2-4 s and cuts when that thing completes. Frame 0 already moves; nothing holds still longer than 0.8 s; the last
// 4 s are the end card with `npx storebound init`.
//
// game 0-5 the demo game's real screens, a gentle drift "The game is done."
// pile 5-11 real store work piles up: App Privacy answers, Data safety CSV, "Then come the stores."
// screenshot sizes, price sheets
// reject 11-15 one real scan finding (5.1.1(v)) grows to fill the frame, it darkens "One missing line, and you start over."
// ask 15-19 near silence: one request typed to the AI agent "So I handed it all to my AI."
// builds..consoles 19-37 the montage, one word at the edge: Builds. / Screenshots, every size. / Products, 175
// countries. / Forms. / Consoles.
// scan 37-42 `storebound scan`: each finding turns green, the re-scan is clean "Nothing left for a reviewer to catch."
// button 42-48 the desktop app's Send for review, the pointer resting on Submit; the music stops "One button is yours."
// waiting 48-53 the click, then "Waiting for review" (the app's own state); the music resolves
// end 53-58 the logo, "From an empty folder to Submit.", "Open source · storebound.dev"
// game 0-3 cold open: the demo game's real screens rush past, a push in "Your game is done."
// pile 3-7 a wall of real store work lands, one card per beat "Then come the stores."
// reject 7-9.5 the real scan finding 5.1.1(v) slams in, the picture darkens "One line. Rejected."
// ask 9.5-12 one request typed to the AI agent, sent on the beat "So my AI did it."
// builds..consoles 12-27 the montage, 3 s each, one word at the edge, each result lands: Builds. /
// Screenshots, every size. / Products, 175 countries. / Forms. / Consoles.
// scan 27-31 `storebound scan`: one finding turns green per beat, the clean re-scan "Nothing left to reject."
// button 31-33 the desktop app's Send for review, the camera pushes onto Submit "You press Submit."
// waiting 33-36 the click, then the app's own "Waiting for review" (same line)
// end 36-41 the logo, `npx storebound init` typed out, storebound.dev (4 s hold)
//
// Every CLI line shown is real output; tests/teaser.test.ts runs each command and checks every line:
// pile `storebound console plan apple-app-privacy` (answer sheet, steps), `storebound play data-safety --dry`
@ -31,74 +31,91 @@ 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: id, length (s), the transition into it (s, centred on the cut; short = a cut on the beat). */
/** Shots: id, length (s), the transition into it (s; 0 = a hard cut on the beat). */
const LENGTHS = [
['game', 5, 0], ['pile', 6, 0.6], ['reject', 4, 0.5], ['ask', 4, 0.8],
['builds', 4, 0.1], ['shots', 4, 0.1], ['products', 3.5, 0.1], ['forms', 3.5, 0.1], ['consoles', 3, 0.1],
['scan', 5, 0.1], ['button', 6, 0.5], ['waiting', 5, 0.1], ['end', 5, 0.8],
['game', 3, 0], ['pile', 4, 0], ['reject', 2.5, 0], ['ask', 2.5, 0],
['builds', 3, 0], ['shots', 3, 0], ['products', 3, 0], ['forms', 3, 0], ['consoles', 3, 0],
['scan', 4, 0], ['button', 2, 0], ['waiting', 3, 0], ['end', 5, 0],
];
export const SCENES = LENGTHS.reduce((a, [id, len, x]) => { const start = a.at(-1)?.end ?? 0; return [...a, { id, start, end: start + len, x }]; }, []);
export const DURATION = SCENES.at(-1).end;
export const scene = id => SCENES.find(s => s.id === id);
/** The montage: one word or short phrase at the edge, cuts on the beat. */
export const MONTAGE = ['builds', 'shots', 'products', 'forms', 'consoles'];
/** One camera move per scene: from -> to (z = zoom, x / y = pan in px), a slow ease (story) or a quick push (montage). */
export const CAMERA = {
game: { from: { z: 1.0, x: 40, y: 10 }, to: { z: 1.07, x: -40, y: -6 } },
pile: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.12, x: 0, y: 10 } },
reject: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
ask: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.04, x: 0, y: 0 } },
builds: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.06, x: -20, y: -10 } },
shots: { from: { z: 1.04, x: 30, y: 0 }, to: { z: 1.0, x: -30, y: 0 } },
products: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.06, x: 20, y: -10 } },
forms: { from: { z: 1.04, x: -20, y: 0 }, to: { z: 1.0, x: 20, y: 0 } },
consoles: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
scan: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: -10 } },
button: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
waiting: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
end: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.03, x: 0, y: 0 } },
/** Why each shot cuts where it does (the shot list, render.ts --animatic). */
export const SHOT_NOTES = {
game: 'the demo game\'s four real screens rush past, push in; cut as the push lands',
pile: 'App Privacy, Data safety CSV, screenshot sizes, prices: one real card per beat; cut when the wall is full',
reject: 'the real BLOCKER 5.1.1(v) finding slams in, push onto it, the picture darkens; cut when it fills the frame',
ask: 'one request typed to the AI agent; cut on the send',
builds: '`apple build --dry`, `play build --dry`: build 52 / versionCode 52; cut on "build planned"',
shots: 'the demo game\'s renders: 7 store sizes x 4 languages fill in column by column; cut when the grid is full',
products: '`play products --dry`, `catalog diff`: 175 region prices, 0 mismatches; cut on the 0',
forms: 'scan\'s declarations proposal ticks the Data safety rows, the count runs to 768; cut when it lands',
consoles: '`console plan apple-app-privacy`, then the app\'s plan panel, the owner\'s Publish ringed; cut on the ring',
scan: '`storebound scan`: 5 findings turn green one per beat, the clean re-scan; cut on 0 blockers',
button: 'the desktop app\'s Send for review, the camera pushes onto Submit, the pointer arrives; cut on arrival',
waiting: 'the click, then the app\'s own "Waiting for review", ringed; cut when it settles',
end: 'logo, `npx storebound init` typed out big, storebound.dev; holds 4 s (caret blink, slow push)',
};
/** Captions: at most one short line per scene. top = the story line, big and centred at the top; edge = the montage
* word, bottom left. at / until: seconds into the scene (fade in from `at`, gone at `until`). *word* = highlighted. */
/** One camera move per shot: from -> to (z = zoom, x / y = pan in px), a quick push that settles at 80 % of the shot. */
export const CAMERA = {
game: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.14, x: 0, y: 30 } },
pile: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 0, y: 10 } },
reject: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
ask: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.12, x: -60, y: 0 } },
builds: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 40, y: -40 } },
shots: { from: { z: 0.98, x: 0, y: 0 }, to: { z: 1.08, x: 0, y: 0 } },
products: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.1, x: 60, y: -50 } },
forms: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.07, x: 30, y: 0 } },
consoles: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
scan: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.08, x: 0, y: -20 } },
button: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
waiting: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.0, x: 0, y: 0 } },
end: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.05, x: 0, y: 0 } },
};
/** Captions: at most six words, each fully on screen for at least 2 s. top = the story line, big and centred at the
* top; edge = the montage word, bottom left. at / until: absolute seconds. *word* = highlighted. */
export const CAPTIONS = [
{ id: 'game', pos: 'top', text: 'The game is *done.*', at: 0.6, until: 4.85 },
{ id: 'pile', pos: 'top', text: 'Then come *the stores.*', at: 0.5, until: 5.9 },
{ id: 'reject', pos: 'top', text: 'One missing line, and you *start over.*', at: 0.35, until: 3.95, hl: 'red' },
{ id: 'ask', pos: 'top', text: 'So I handed it all *to my AI.*', at: 0.35, until: 3.95 },
{ id: 'builds', pos: 'edge', text: 'Builds.', at: 0.05, until: 3.95 },
{ id: 'shots', pos: 'edge', text: 'Screenshots, every size.', at: 0.05, until: 3.95 },
{ id: 'products', pos: 'edge', text: 'Products, 175 countries.', at: 0.05, until: 3.45 },
{ id: 'forms', pos: 'edge', text: 'Forms.', at: 0.05, until: 3.45 },
{ id: 'consoles', pos: 'edge', text: 'Consoles.', at: 0.05, until: 2.95 },
{ id: 'scan', pos: 'top', text: 'Nothing left for a reviewer *to catch.*', at: 1.5, until: 4.95, hl: 'green' },
{ id: 'button', pos: 'top', text: 'One button is *yours.*', at: 0.9, until: 5.9 },
{ id: 'game', pos: 'top', text: 'Your game is *done.*', at: 0.1, until: 2.95 },
{ id: 'pile', pos: 'top', text: 'Then come *the stores.*', at: 3.05, until: 6.95 },
{ id: 'reject', pos: 'top', text: 'One line. *Rejected.*', at: 7.05, until: 9.45, hl: 'red' },
{ id: 'ask', pos: 'top', text: 'So my AI *did it.*', at: 9.55, until: 11.95 },
{ id: 'builds', pos: 'edge', text: 'Builds.', at: 12.05, until: 14.95 },
{ id: 'shots', pos: 'edge', text: 'Screenshots, every size.', at: 15.05, until: 17.95 },
{ id: 'products', pos: 'edge', text: 'Products, 175 countries.', at: 18.05, until: 20.95 },
{ id: 'forms', pos: 'edge', text: 'Forms.', at: 21.05, until: 23.95 },
{ id: 'consoles', pos: 'edge', text: 'Consoles.', at: 24.05, until: 26.95 },
{ id: 'scan', pos: 'top', text: 'Nothing left to *reject.*', at: 28.6, until: 30.95, hl: 'green' },
{ id: 'button', pos: 'top', text: 'You press *Submit.*', at: 31.1, until: 35.95 },
];
/** Fades (s): the story lines slow and soft, the montage words quick. */
export const CAP_FADE = { top: [0.5, 0.4], edge: [0.15, 0.15] };
/** When each caption is fully on screen (tests: at least 2.5 s, inside its scene). */
/** Fades (s): quick, a cut is a cut. */
export const CAP_FADE = { top: [0.12, 0.08], edge: [0.1, 0.08] };
/** When each caption is fully on screen. */
export function captionTimes() {
return CAPTIONS.map(c => {
const s = scene(c.id), [fin, fout] = CAP_FADE[c.pos];
const at = s.start + c.at, from = at + fin, to = s.start + c.until - fout, gone = s.start + c.until;
return { id: c.id, text: c.text.replace(/\*/g, ''), pos: c.pos, at, from, to, gone, hold: to - from };
const [fin, fout] = CAP_FADE[c.pos];
const from = c.at + fin, to = c.until - fout;
return { id: c.id, text: c.text.replace(/\*/g, ''), pos: c.pos, at: c.at, from, to, gone: c.until, hold: to - from };
});
}
/** The dark veil over the picture (0-1) at time t: it falls over the rejection, holds through the request, lifts on
* the first beat of the montage. */
/** The dark veil over the picture (0-1) at time t: it falls with the rejection, holds through the request, gone on the
* first beat of the montage. */
export function veil(t) {
const r = scene('reject'), a = scene('ask');
if (t < r.start + 0.4) return 0;
if (t < r.end) return 0.72 * Math.min(1, (t - r.start - 0.4) / 3.2);
if (t < a.end - 0.05) return 0.72 - 0.12 * Math.min(1, (t - a.start) / 1.5);
if (t < r.start + 0.15) return 0;
if (t < r.end) return 0.7 * Math.min(1, (t - r.start - 0.15) / 1.6);
if (t < a.end) return 0.6;
return 0;
}
// ---------- 1 game: the demo game's real screens ----------
/** examples/demo-game/store/shots/frames/en/phone/<id>.png (the game's own captures), left to right. */
export const GAME_SCREENS = ['play', 'combo', 'map', 'stars'];
export const GAME_AT = 0.15, GAME_STEP = 0.22;
/** The cold open: the screens rush past (px/s) while the camera pushes in; already moving on frame 0. */
export const GAME_AT = 0, GAME_STEP = 0, GAME_SPEED = 520;
// ---------- 2 pile: the store work piles up ----------
/** Real lines (see the header). */
@ -157,16 +174,15 @@ export const PRICE_CMD = 'prices ppp --offline --dry --store apple --only com.ex
/** The cards that drop onto the pile, faster and faster. at = seconds into the scene; x / y = the card's centre
* (px), w = width, rot = degrees. kind: what it shows. */
export const PILE = [
{ at: 0.25, kind: 'privacy', x: 700, y: 560, w: 1000, rot: -3 },
{ at: 1.25, kind: 'csv', x: 1220, y: 600, w: 980, rot: 2.5 },
{ at: 2.15, kind: 'shots', x: 860, y: 640, w: 900, rot: -1.5 },
{ at: 2.95, kind: 'prices', x: 1180, y: 560, w: 760, rot: 4 },
{ at: 3.6, kind: 'steps', x: 760, y: 690, w: 1040, rot: 2 },
{ at: 4.15, kind: 'csv', x: 1060, y: 610, w: 1000, rot: -4.5, from: 7 },
{ at: 4.6, kind: 'shots', x: 940, y: 560, w: 940, rot: 3.5, lang: 'ja' },
{ at: 5.0, kind: 'prices', x: 820, y: 640, w: 800, rot: -2.5, from: 11 },
{ at: 5.35, kind: 'privacy', x: 1040, y: 600, w: 1060, rot: 1, from: 6 },
];
{ at: 0.0, kind: 'privacy', x: 640, y: 540, w: 1000, rot: -3 },
{ at: 0.5, kind: 'csv', x: 1260, y: 600, w: 980, rot: 2.5 },
{ at: 1.0, kind: 'shots', x: 820, y: 680, w: 900, rot: -1.5 },
{ at: 1.5, kind: 'prices', x: 1240, y: 520, w: 760, rot: 4 },
{ at: 2.0, kind: 'steps', x: 760, y: 700, w: 1040, rot: 2 },
{ at: 2.5, kind: 'csv', x: 1100, y: 620, w: 1000, rot: -4.5, from: 7 },
{ at: 3.0, kind: 'shots', x: 940, y: 560, w: 940, rot: 3.5, lang: 'ja' },
{ at: 3.5, kind: 'prices', x: 980, y: 640, w: 820, rot: -2.5, from: 11 },
]
/** The card headers (what each card is; no claims). */
export const PILE_TITLES = { privacy: 'App Privacy · answer sheet', steps: 'App Privacy · console steps', csv: `Data safety · ${DECL_TOTAL} rows`, shots: 'Screenshots', prices: 'Prices · every region' };
@ -176,13 +192,14 @@ export const REJECT = {
head: 'BLOCKER 5.1.1(v) · App Store · Accounts can be created but no in-app account deletion was found',
at: 'at Planner/LoginView.swift:11 Button("Create account") { Task { try await AccountApi.signUp() } }',
};
export const REJECT_GROW = [0.6, 1.12];
/** The slam (scale from -> 1 in `slam` s), then a push onto the rule number. */
export const REJECT_GROW = [1.0, 1.28], REJECT_SLAM = { from: 1.7, d: 0.16 };
// ---------- 4 ask: the request to the agent ----------
export const PROMPT = 'Get this game ready for review on both stores.';
export const PROMPT_AT = 0.7, PROMPT_CPS = 24;
export const PROMPT_AT = 0.1, PROMPT_CPS = 31;
/** Enter: the request is sent (a soft accent), seconds into the scene. */
export const PROMPT_SENT = 3.05;
export const PROMPT_SENT = 2.0;
// ---------- 5 montage ----------
/** builds: real lines of `apple build --dry` and `play build --dry` (clock prefix and "(0s)" left out). */
@ -202,18 +219,18 @@ export const SHOT_SIZES = [
{ id: 'play', label: 'Play phone' }, { id: 'tab7', label: 'Play 7″' }, { id: 'tab10', label: 'Play 10″' }, { id: 'feature', label: 'Feature graphic' },
];
export const SHOT_LANGS = ['en', 'tr', 'ja', 'ar'];
export const SHOTS_AT = 0.1, SHOTS_STEP = 0.2;
export const SHOTS_AT = 0.05, SHOTS_STEP = 0.3, SHOTS_ROW = 0.1;
/** products: real output (clock prefix and the "(0s)" timer left out). */
export const PRODUCT_LINES = [
{ at: 0.05, text: '$ storebound play products --dry', cmd: true },
{ at: 0.3, text: '[play] 1/2 com.example.app.no_ads: would create · 174 regions · 2 listings (dry)' },
{ at: 0.48, text: '[play] 2/2 com.example.app.gems_100: would create · 174 regions · 2 listings (dry)' },
{ at: 0.8, text: '$ storebound catalog diff', cmd: true },
{ at: 1.05, text: 'ok [apple] com.example.app.gems_100: consumable, 175 region prices, READY_TO_SUBMIT' },
{ at: 1.2, text: 'ok [play] com.example.app.gems_100: consumable, 174 region prices, ACTIVE' },
{ at: 1.35, text: 'ok [apple] com.example.app.no_ads: non_consumable, 175 region prices, READY_TO_SUBMIT' },
{ at: 1.5, text: 'ok [play] com.example.app.no_ads: non_consumable, 174 region prices, ACTIVE' },
{ at: 1.75, text: '[catalog] 2 catalog items: 0 mismatch(es), 0 warning(s), 0 price difference(s) between the stores' },
{ at: 0.07, text: '$ storebound play products --dry', cmd: true },
{ at: 0.41, text: '[play] 1/2 com.example.app.no_ads: would create · 174 regions · 2 listings (dry)' },
{ at: 0.65, text: '[play] 2/2 com.example.app.gems_100: would create · 174 regions · 2 listings (dry)' },
{ at: 1.08, text: '$ storebound catalog diff', cmd: true },
{ at: 1.42, text: 'ok [apple] com.example.app.gems_100: consumable, 175 region prices, READY_TO_SUBMIT' },
{ at: 1.62, text: 'ok [play] com.example.app.gems_100: consumable, 174 region prices, ACTIVE' },
{ at: 1.82, text: 'ok [apple] com.example.app.no_ads: non_consumable, 175 region prices, READY_TO_SUBMIT' },
{ at: 2.03, text: 'ok [play] com.example.app.no_ads: non_consumable, 174 region prices, ACTIVE' },
{ at: 2.36, text: '[catalog] 2 catalog items: 0 mismatch(es), 0 warning(s), 0 price difference(s) between the stores' },
];
/** forms: the declarations proposal (scan of the teaser fixture), then the Data safety rows it fills. */
export const DECL_LINES = [
@ -253,10 +270,10 @@ export const FINDINGS = [
'HIGH 3.1.1 · App Store · Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found',
'HIGH 5.1.2(i) · App Store · Tracking-capable code without App Tracking Transparency (Google AdMob, IDFA)',
];
export const FINDINGS_AT = 0.4, FINDINGS_STEP = 0.12;
export const FINDINGS_AT = 0.2, FINDINGS_STEP = 0.06;
/** Each finding turns green (fixed) on a beat, then the re-scan of the fixed copy (tests/fixtures/scan/teaser-fixed). */
export const FIX_AT = [1.0, 1.5, 2.0, 2.5, 3.0];
export const RESCAN_AT = 3.45;
export const FIX_AT = [0.5, 1.0, 1.5, 2.0, 2.5];
export const RESCAN_AT = 3.0;
export const RESCAN_LINE = 'Rejection risks: 0 blocker, 0 high, 0 medium, 0 low';
// ---------- desktop: real frames of the app in fixture mode (desktop.ts) ----------
@ -269,21 +286,21 @@ export const DESK = {
groups: [{ frame: 'console-plan' }],
show: CONSOLES_SWAP,
cam: { from: { f: [0.5, 0.56], z: 1.12 }, to: { f: [0.5, 0.58], z: 1.22 } },
rings: [{ at: 1.6, until: 3.2, target: 'cpFinal', green: true }],
rings: [{ at: 1.5, until: 3.4, target: 'cpFinal', green: true }],
},
// Releases > Send for review: build 52 picked, the one button left; the camera settles on it, the pointer rests
button: {
groups: [{ frame: 'submit-ready' }],
cam: { from: { f: [0.45, 0.5], z: 1.0 }, to: { f: [0.3, 0.68], z: 1.55 } },
rings: [{ at: 3.2, until: 6.3, target: 'submitBtn' }],
pointer: { at: 1.9, d: 1.1, target: 'submitBtn' },
rings: [{ at: 1.3, until: 2.6, target: 'submitBtn' }],
pointer: { at: 0.4, d: 0.9, target: 'submitBtn' },
},
// the press, then the app's own "Waiting for review" state
waiting: {
groups: [{ frame: 'submit-ready' }, { frame: 'submit-waiting' }],
swap: [0.45, 0.9],
cam: { from: { f: [0.3, 0.68], z: 1.55 }, to: { f: [0.4, 0.4], z: 1.3 } },
rings: [{ at: 1.2, until: 5.3, target: 'waitingItem', green: true }],
rings: [{ at: 1.0, until: 3.4, target: 'waitingItem', green: true }],
pointer: { at: -1, d: 0.01, target: 'submitBtn', click: 0.2, until: 0.6 },
},
};
@ -293,11 +310,13 @@ export const DESK_FRAMES = [...new Set(Object.values(DESK).flatMap(d => d.groups
/** The click on Submit (absolute seconds): the music's resolve starts here. */
export const CLICK_T = scene('waiting').start + DESK.waiting.pointer.click;
// ---------- 9 end card ----------
/** Seconds into the end scene at which each line comes in. */
export const END = { markAt: 0.25, wordAt: 0.5, lineAt: 0.9, urlAt: 1.25, d: 0.6 };
export const END_LINE = 'From an empty folder to Submit.';
export const END_URL = 'Open source · storebound.dev';
// ---------- 9 end card: the last thing on screen ----------
/** Seconds into the end shot: the logo and word slam in, the install line is typed, then the site. Everything is on
* screen by `full`; the rest (at least 4 s) holds with a blinking caret and a slow push. */
export const END_CMD = 'npx storebound init';
export const END_URL = 'storebound.dev';
export const END = { markAt: 0, wordAt: 0.06, cmdAt: 0.15, cps: 40, urlAt: 0.7, d: 0.2 };
END.full = Math.max(END.cmdAt + END_CMD.length / END.cps, END.urlAt + END.d);
// ---------- sound cues (music.js) ----------
/** Cuts with a sound accent (every scene change but the seamless button -> waiting one). */
@ -321,5 +340,6 @@ export function ticks() {
export function keyTimes() {
const out = [];
for (let i = 0; i < PROMPT.length; i++) if (PROMPT[i] !== ' ' || i % 2) out.push(scene('ask').start + PROMPT_AT + i / PROMPT_CPS);
for (let i = 0; i < END_CMD.length; i++) out.push(scene('end').start + END.cmdAt + i / END.cps);
return out;
}