+
+
+
storebound
+
-
+
+
diff --git a/media/teaser/music.js b/media/teaser/music.js
index 0105c09..138885d 100644
--- a/media/teaser/music.js
+++ b/media/teaser/music.js
@@ -1,150 +1,288 @@
-// Original teaser music and sound design (v4, calm), synthesized from nothing with the Web Audio API
-// (OfflineAudioContext): no samples, no downloaded audio. D major, one chord per scene: warm pads that cross-fade on the
-// scene changes, a slow, soft plucked arpeggio, a quiet sub, and only soft accents on the scene changes (a bell and a
-// short airy swell into each one). On top, quiet UI sounds from script.js: key clicks for typed text, ticks for lines
-// and rows, a low tone per finding, rising chimes for the fixes, a warm swell under the end card. Deterministic.
-// renderWav() returns a 32-bit float stereo WAV (48 kHz) as base64; render.ts masters it with ffmpeg loudnorm.
-import { CUTS, DURATION, keyTimes, SCENES, scene, SUBMIT, ticks } from './script.js';
+// Original teaser music and sound design (v8, a story arc), synthesized from nothing with the Web Audio API
+// (OfflineAudioContext): no samples, no downloaded audio. 120 BPM (a beat every half second; every cut lands on one).
+// calm game a warm D major pad, a slow soft pluck
+// tension pile B minor, a low pulse in eighths that grows, a ticking clock, a riser; a thud per card
+// dark reject a low boom, a dark drone that sinks to almost nothing
+// silence ask room tone, the key clicks of the request, one soft note when it is sent; a swell into the drop
+// drive montage+scan kick, bass, arpeggio, hats, claps: one layer more per montage beat (I-vi-IV-V, a chord per bar);
+// a chime per fix, rising
+// stop button the music stops on the cut; only a quiet low pulse, like holding your breath
+// resolve waiting+end the click, then a wide D major add9 swell, bells, a slow arpeggio under the end card
+// Deterministic. renderWav() returns a 32-bit float stereo WAV (48 kHz) as base64; render.ts masters it with ffmpeg.
+import { CLICK_T, CUTS, DURATION, keyTimes, MONTAGE, scene, ticks } from './script.js';
const SR = 48000;
+/** One beat (s): 120 BPM. Every scene starts on a whole number of beats (tests/teaser.test.ts). */
+export const STEP = 0.5;
+const BAR = STEP * 4;
const midi = m => 440 * Math.pow(2, (m - 69) / 12);
function rng(seed) { // mulberry32
let a = seed >>> 0;
return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
-// chords (root midi in octave 3, intervals): one per scene, in scene order
-const CH = {
- Bm: [47, [0, 3, 7, 14]], G: [43, [0, 4, 7, 14]], D: [50, [0, 4, 7, 11]], A: [45, [0, 4, 7, 14]], Em: [52, [0, 3, 7, 10]], Dadd9: [50, [0, 4, 7, 14]],
-};
-const SCENE_CHORD = { hook: 'Bm', init: 'G', plan: 'D', store: 'A', products: 'Bm', forms: 'G', consoles: 'A', rejections: 'Em', portfolio: 'G', charts: 'D', catalog: 'Bm', resubmit: 'G', submit: 'D' };
-const ARP = [0, 1, 2, 3, 2, 1, 2, 3];
-/** One soft note every half second: every scene is a whole number of half seconds long (tests/teaser.test.ts), so each
- * cut lands on a note of the arpeggio. */
-export const STEP = 0.5;
+/** The arc: section boundaries in seconds, from the scenes. */
+export function sections() {
+ const s = id => scene(id).start, e = id => scene(id).end;
+ return { calm: [0, s('pile')], tension: [s('pile'), s('reject')], dark: [s('reject'), s('ask')], silence: [s('ask'), s('builds')],
+ drive: [s('builds'), s('button')], stop: [s('button'), CLICK_T], resolve: [CLICK_T, e('end')] };
+}
+// chords (root midi, intervals)
+const CH = { D: [50, [0, 4, 7, 14]], Bm: [47, [0, 3, 7, 14]], G: [43, [0, 4, 7, 14]], A: [45, [0, 4, 7, 14]], Dmaj9: [50, [0, 4, 7, 11, 14]] };
+const PROG = ['D', 'Bm', 'G', 'A'];
export async function renderMusic() {
const ctx = new OfflineAudioContext({ numberOfChannels: 2, length: Math.ceil(DURATION * SR) + SR * 2, sampleRate: SR });
- const R = rng(20261012);
+ const R = rng(20261013);
+ const S = sections();
- const noise = ctx.createBuffer(2, SR * 3, SR);
+ const noise = ctx.createBuffer(2, SR * 6, SR);
for (let c = 0; c < 2; c++) { const d = noise.getChannelData(c); for (let i = 0; i < d.length; i++) d[i] = R() * 2 - 1; }
- const ir = ctx.createBuffer(2, Math.floor(SR * 3.2), SR);
- for (let c = 0; c < 2; c++) { const d = ir.getChannelData(c); for (let i = 0; i < d.length; i++) d[i] = (R() * 2 - 1) * Math.pow(1 - i / d.length, 3.6); }
+ const ir = ctx.createBuffer(2, Math.floor(SR * 3.4), SR);
+ for (let c = 0; c < 2; c++) { const d = ir.getChannelData(c); for (let i = 0; i < d.length; i++) d[i] = (R() * 2 - 1) * Math.pow(1 - i / d.length, 3.4); }
// buses: a gentle compressor, a long room
const comp = ctx.createDynamicsCompressor();
- comp.threshold.value = -18; comp.knee.value = 10; comp.ratio.value = 2.5; comp.attack.value = 0.02; comp.release.value = 0.4;
+ comp.threshold.value = -16; comp.knee.value = 8; comp.ratio.value = 3; comp.attack.value = 0.008; comp.release.value = 0.25;
const master = ctx.createGain(); master.gain.value = 0.7;
master.connect(comp); comp.connect(ctx.destination);
const verb = ctx.createConvolver(); verb.buffer = ir;
- const verbIn = ctx.createGain(); verbIn.gain.value = 0.55; verbIn.connect(verb); verb.connect(master);
- const sfx = ctx.createGain(); sfx.gain.value = 0.7; sfx.connect(master);
+ const verbIn = ctx.createGain(); verbIn.gain.value = 0.5; verbIn.connect(verb); verb.connect(master);
+ const sfx = ctx.createGain(); sfx.gain.value = 0.75; sfx.connect(master);
const sfxVerb = ctx.createGain(); sfxVerb.gain.value = 0.3; sfxVerb.connect(verbIn);
+ // the drums and bass: their own bus, so the stop can cut them all at once
+ const drive = ctx.createGain(); drive.connect(master);
+ drive.gain.setValueAtTime(1, 0); drive.gain.setValueAtTime(1, S.stop[0] - 0.01); drive.gain.linearRampToValueAtTime(0, S.stop[0] + 0.03);
const env = (g, t, a, peak, d, end = 0.0003) => {
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(peak, t + a); g.gain.exponentialRampToValueAtTime(end, t + a + d);
};
const noiseSrc = (t, dur, off = R() * 1.5) => { const s = ctx.createBufferSource(); s.buffer = noise; s.start(t, off, dur); return s; };
- /** A warm pad: detuned saws through a soft low-pass, slow attack and release (cross-fades with the next one). */
- function pad(t0, t1, notes, g = 0.035, cutoff = 1100) {
+ /** A warm pad: detuned saws through a soft low-pass, slow attack and release. */
+ function pad(t0, t1, notes, g = 0.035, cutoff = 1100, attack = 1.2, release = 1.0, to = master, cutoffTo = null) {
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = cutoff; lp.Q.value = 0.5;
+ if (cutoffTo) { lp.frequency.setValueAtTime(cutoff, t0); lp.frequency.exponentialRampToValueAtTime(cutoffTo, t1); }
const v = ctx.createGain();
- v.gain.setValueAtTime(0, t0); v.gain.linearRampToValueAtTime(g, t0 + 1.2); v.gain.setValueAtTime(g, t1 - 0.2); v.gain.linearRampToValueAtTime(0, t1 + 1.0);
- lp.connect(v); v.connect(master);
+ v.gain.setValueAtTime(0, t0); v.gain.linearRampToValueAtTime(g, t0 + attack); v.gain.setValueAtTime(g, Math.max(t0 + attack, t1 - 0.05)); v.gain.linearRampToValueAtTime(0, t1 + release);
+ lp.connect(v); v.connect(to);
const s = ctx.createGain(); s.gain.value = 0.7; v.connect(s); s.connect(verbIn);
for (const m of notes) for (const det of [-7, 7]) {
const o = ctx.createOscillator(); o.type = 'sawtooth'; o.frequency.value = midi(m); o.detune.value = det;
const p = ctx.createStereoPanner(); p.pan.value = det < 0 ? -0.35 : 0.35;
- o.connect(p); p.connect(lp); o.start(t0); o.stop(t1 + 1.1);
+ o.connect(p); p.connect(lp); o.start(t0); o.stop(t1 + release + 0.1);
}
+ return v;
}
- /** A soft pluck (sine + a little triangle), the arpeggio voice. */
- function pluck(t, m, g = 0.05, pan = 0) {
- const v = ctx.createGain(); env(v, t, 0.006, g, 1.1);
+ /** A soft pluck (sine + a little triangle). */
+ function pluck(t, m, g = 0.05, pan = 0, len = 1.1, to = master) {
+ const v = ctx.createGain(); env(v, t, 0.006, g, len);
const p = ctx.createStereoPanner(); p.pan.value = pan;
- v.connect(p); p.connect(master);
- const s = ctx.createGain(); s.gain.value = 0.6; p.connect(s); s.connect(verbIn);
+ v.connect(p); p.connect(to);
+ const s = ctx.createGain(); s.gain.value = 0.5; p.connect(s); s.connect(verbIn);
for (const [type, k, mul] of [['sine', 1, 1], ['triangle', 2, 0.18]]) {
const o = ctx.createOscillator(); o.type = type; o.frequency.value = midi(m) * k;
- const og = ctx.createGain(); og.gain.value = mul; o.connect(og); og.connect(v); o.start(t); o.stop(t + 1.3);
+ const og = ctx.createGain(); og.gain.value = mul; o.connect(og); og.connect(v); o.start(t); o.stop(t + len + 0.2);
}
}
- /** A quiet sub under each scene. */
- function sub(t0, t1, m, g = 0.07) {
- const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = midi(m - 12);
- const v = ctx.createGain(); v.gain.setValueAtTime(0, t0); v.gain.linearRampToValueAtTime(g, t0 + 0.8); v.gain.setValueAtTime(g, t1 - 0.3); v.gain.linearRampToValueAtTime(0, t1 + 0.4);
- o.connect(v); v.connect(master); o.start(t0); o.stop(t1 + 0.5);
- }
- /** The soft accent on a scene change: a bell an octave or two up, with a long tail. */
function bell(t, m, g = 0.05, pan = 0) {
const p = ctx.createStereoPanner(); p.pan.value = pan; p.connect(sfx); p.connect(sfxVerb);
- for (const [k, mul, d] of [[1, 1, 2.2], [2.01, 0.25, 1.2], [3.0, 0.08, 0.7]]) {
+ for (const [k, mul, d] of [[1, 1, 2.4], [2.01, 0.25, 1.3], [3.0, 0.08, 0.7]]) {
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = midi(m) * k;
const v = ctx.createGain(); env(v, t, 0.004, g * mul, d); o.connect(v); v.connect(p); o.start(t); o.stop(t + d + 0.1);
}
}
- /** A short airy swell into a scene change (very quiet). */
- function swell(t, dur = 0.9, g = 0.03) {
- const n = noiseSrc(t - dur, dur + 0.1); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 0.8;
- bp.frequency.setValueAtTime(600, t - dur); bp.frequency.exponentialRampToValueAtTime(3200, t);
- const v = ctx.createGain(); v.gain.setValueAtTime(0.0001, t - dur); v.gain.exponentialRampToValueAtTime(g, t - 0.05); v.gain.linearRampToValueAtTime(0, t + 0.1);
- n.connect(bp); bp.connect(v); v.connect(sfx); v.connect(sfxVerb);
- }
function tone(t, m, g, len = 0.25, type = 'sine', pan = 0) {
const o = ctx.createOscillator(); o.type = type; o.frequency.value = midi(m);
const p = ctx.createStereoPanner(); p.pan.value = pan;
const v = ctx.createGain(); env(v, t, 0.004, g, len); o.connect(v); v.connect(p); p.connect(sfx); p.connect(sfxVerb);
o.start(t); o.stop(t + len + 0.05);
}
- function tick(t, g = 0.03, f0 = 3000) {
- const n = noiseSrc(t, 0.015); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = f0; bp.Q.value = 3;
- const v = ctx.createGain(); env(v, t, 0.0006, g, 0.014); n.connect(bp); bp.connect(v); v.connect(sfx);
+ /** Filtered noise: a swell / riser into time t (dur long), band-pass sweeping up. */
+ function riser(t, dur, g = 0.04, f0 = 400, f1 = 5000, to = sfx) {
+ const n = noiseSrc(t - dur, Math.min(dur + 0.1, 4.4)); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 0.9;
+ bp.frequency.setValueAtTime(f0, t - dur); bp.frequency.exponentialRampToValueAtTime(f1, t);
+ const v = ctx.createGain(); v.gain.setValueAtTime(0.0001, t - dur); v.gain.exponentialRampToValueAtTime(g, t - 0.03); v.gain.linearRampToValueAtTime(0, t + 0.08);
+ n.connect(bp); bp.connect(v); v.connect(to); v.connect(sfxVerb);
+ }
+ function tick(t, g = 0.03, f0 = 3000, to = sfx) {
+ const n = noiseSrc(t, 0.02); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = f0; bp.Q.value = 3;
+ const v = ctx.createGain(); env(v, t, 0.0006, g, 0.016); n.connect(bp); bp.connect(v); v.connect(to);
}
function key(t) {
const n = noiseSrc(t, 0.02); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 2400 + R() * 1800; bp.Q.value = 2.5;
const p = ctx.createStereoPanner(); p.pan.value = R() * 0.4 - 0.2;
- const v = ctx.createGain(); env(v, t, 0.0008, 0.05 * (0.7 + R() * 0.6), 0.016); n.connect(bp); bp.connect(v); v.connect(p); p.connect(sfx);
+ const v = ctx.createGain(); env(v, t, 0.0008, 0.06 * (0.7 + R() * 0.6), 0.018); n.connect(bp); bp.connect(v); v.connect(p); p.connect(sfx);
}
-
- // ---- the bed: one chord per scene ----
- SCENES.forEach((s, i) => {
- const [root, iv] = CH[SCENE_CHORD[s.id]];
- const last = i === SCENES.length - 1;
- const t0 = Math.max(0, s.start - 0.4), t1 = last ? DURATION + 0.6 : s.end;
- const notes = [root, root + iv[1], root + iv[2], root + 12];
- pad(t0, last ? scene('submit').start + SUBMIT.logoAt : t1, notes, s.id === 'hook' ? 0.03 : 0.034, s.id === 'hook' ? 800 : 1100);
- sub(t0, t1, root, s.id === 'hook' ? 0.05 : 0.065);
- // the arpeggio: quiet under the hook, then a steady, soft pulse
- const tones = iv.map(x => root + 12 + x).concat(iv.map(x => root + 24 + x));
- for (let t = Math.max(0.5, s.start), k = 0; t < (last ? s.start + SUBMIT.logoAt : s.end) - 0.05; t += STEP, k++) {
- const m = tones[ARP[k % ARP.length] + (k % 16 >= 8 ? 1 : 0)] ?? tones[0];
- pluck(t, m, s.id === 'hook' ? 0.028 : 0.04, (k % 2 ? 0.25 : -0.25));
+ /** A soft synth kick: a sine with a fast pitch drop. */
+ function kick(t, g = 0.5, to = drive) {
+ const o = ctx.createOscillator(); o.type = 'sine';
+ o.frequency.setValueAtTime(130, t); o.frequency.exponentialRampToValueAtTime(44, t + 0.12);
+ const v = ctx.createGain(); env(v, t, 0.002, g, 0.32); o.connect(v); v.connect(to); o.start(t); o.stop(t + 0.4);
+ tick(t, g * 0.06, 1800, to);
+ }
+ /** A clap / snare: noise through a band-pass, a short tail. */
+ function clap(t, g = 0.12) {
+ const n = noiseSrc(t, 0.25); const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1500; bp.Q.value = 0.8;
+ const v = ctx.createGain(); env(v, t, 0.002, g, 0.16); n.connect(bp); bp.connect(v); v.connect(drive);
+ const s = ctx.createGain(); s.gain.value = 0.4; v.connect(s); s.connect(verbIn);
+ }
+ function hat(t, g = 0.03, open = false) {
+ const n = noiseSrc(t, open ? 0.2 : 0.05); const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 7500;
+ const p = ctx.createStereoPanner(); p.pan.value = 0.2;
+ const v = ctx.createGain(); env(v, t, 0.001, g, open ? 0.14 : 0.035); n.connect(hp); hp.connect(v); v.connect(p); p.connect(drive);
+ }
+ /** The bass: a filtered saw + sine, one note. */
+ function bass(t, m, len, g = 0.11, cutoff = 520) {
+ const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.setValueAtTime(cutoff * 2.2, t); lp.frequency.exponentialRampToValueAtTime(cutoff, t + len * 0.8); lp.Q.value = 2;
+ const v = ctx.createGain(); v.gain.setValueAtTime(0, t); v.gain.linearRampToValueAtTime(g, t + 0.008); v.gain.setValueAtTime(g * 0.8, t + len * 0.7); v.gain.linearRampToValueAtTime(0, t + len);
+ lp.connect(v); v.connect(drive);
+ for (const [type, mul] of [['sawtooth', 0.6], ['sine', 1]]) {
+ const o = ctx.createOscillator(); o.type = type; o.frequency.value = midi(m);
+ const og = ctx.createGain(); og.gain.value = mul; o.connect(og); og.connect(lp); o.start(t); o.stop(t + len + 0.05);
}
- });
- // the end card: a warm, open chord swell and one bell
- const end = scene('submit').start + SUBMIT.logoAt;
- const [er, eiv] = CH.Dadd9;
- pad(end - 0.3, DURATION + 0.6, [er - 12, er, er + eiv[1], er + eiv[2], er + eiv[3]], 0.045, 1500);
- bell(end + 0.2, er + 24, 0.06);
- bell(end + 0.55, er + 24 + 7, 0.04, 0.2);
-
- // ---- soft accents on the scene changes ----
- for (const c of CUTS) {
- const [root, iv] = CH[SCENE_CHORD[c.id]];
- swell(c.t, 0.9, 0.025);
- bell(c.t + 0.02, root + 24 + iv[2], 0.045, (c.t * 7) % 2 - 1 > 0 ? 0.15 : -0.15);
+ }
+ /** A deep, slow boom (the rejection): a sine falling from 70 Hz plus low noise, a long room. */
+ function boom(t, g = 0.5) {
+ const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.setValueAtTime(72, t); o.frequency.exponentialRampToValueAtTime(30, t + 1.6);
+ const v = ctx.createGain(); env(v, t, 0.005, g, 2.2); o.connect(v); v.connect(master);
+ const n = noiseSrc(t, 1.2); const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 240;
+ const nv = ctx.createGain(); env(nv, t, 0.004, g * 0.5, 0.9); n.connect(lp); lp.connect(nv); nv.connect(master); nv.connect(verbIn);
+ }
+ function thud(t, g = 0.18) {
+ const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.setValueAtTime(110, t); o.frequency.exponentialRampToValueAtTime(48, t + 0.15);
+ const v = ctx.createGain(); env(v, t, 0.002, g, 0.28); o.connect(v); v.connect(sfx);
+ const n = noiseSrc(t, 0.12); const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 900;
+ const nv = ctx.createGain(); env(nv, t, 0.002, g * 0.35, 0.09); n.connect(lp); lp.connect(nv); nv.connect(sfx); nv.connect(sfxVerb);
+ }
+ /** The mouse click on Submit. */
+ function click(t) {
+ tick(t, 0.09, 4200); tick(t + 0.055, 0.05, 2800);
}
- // ---- UI sounds ----
+ // ---------- calm: the game is done ----------
+ {
+ const [r, iv] = CH.Dmaj9;
+ pad(0, S.calm[1] + 0.2, [r, r + iv[1], r + iv[2], r + iv[3]], 0.032, 900, 1.6, 1.2);
+ pad(0, S.calm[1] + 0.2, [r - 12], 0.02, 300, 1.6, 1.0);
+ const tones = [62, 66, 69, 73, 74, 73, 69, 66];
+ for (let t = 0.5, k = 0; t < S.calm[1] - 0.1; t += 2 * STEP, k++) pluck(t, tones[k % tones.length], 0.036, k % 2 ? 0.25 : -0.25, 1.6);
+ }
+
+ // ---------- tension: the stores pile up ----------
+ {
+ const [a, b] = S.tension;
+ const [r, iv] = CH.Bm;
+ pad(a - 0.3, b, [r, r + iv[1], r + iv[2], r + 12], 0.03, 700, 1.0, 0.4, master, 2600);
+ // a b9 creeps in over the last two seconds
+ pad(b - 2.2, b, [r + 13], 0.016, 1200, 1.8, 0.3);
+ // the low pulse: eighths on B, louder and brighter towards the rejection
+ for (let t = a, k = 0; t < b - 0.01; t += STEP / 2, k++) {
+ const p = (t - a) / (b - a);
+ bass(t, k % 8 === 7 ? 42 : 35, STEP / 2 * 0.9, 0.05 + 0.07 * p, 260 + 900 * p);
+ }
+ // a clock ticking in sixteenths over the second half
+ for (let t = a + 3, k = 0; t < b - 0.01; t += STEP / 4, k++) tick(t, (k % 4 === 0 ? 0.03 : 0.016) * (0.6 + (t - a) / (b - a) * 0.6), k % 4 === 0 ? 5200 : 6800);
+ riser(b, 3, 0.07, 300, 6000);
+ }
+
+ // ---------- dark: one missing line ----------
+ {
+ const [a, b] = S.dark;
+ boom(a, 0.55);
+ const v = pad(a, b - 1.2, [35, 38, 42, 47], 0.04, 420, 0.1, 1.6, master, 260);
+ void v;
+ pad(a, b - 1.4, [59, 60], 0.008, 1800, 1.0, 1.4); // a faint, uneasy cluster above
+ }
+
+ // ---------- near silence: the request ----------
+ {
+ const [a, b] = S.silence;
+ // room tone: very quiet, dark noise
+ const n = noiseSrc(a, b - a); const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 500;
+ const v = ctx.createGain(); v.gain.setValueAtTime(0, a); v.gain.linearRampToValueAtTime(0.012, a + 0.8); v.gain.setValueAtTime(0.012, b - 0.6); v.gain.linearRampToValueAtTime(0, b);
+ n.connect(lp); lp.connect(v); v.connect(master);
+ pluck(a + 0.5, 66, 0.022, 0, 2.5);
+ // the swell into the drop
+ riser(b, 1.5, 0.08, 250, 7000);
+ pad(b - 1.5, b, [62, 69, 74], 0.02, 600, 1.4, 0.05, master, 3000);
+ }
+
+ // ---------- drive: the montage and the scan ----------
+ {
+ const [a, b] = S.drive;
+ const layer = id => scene(id).start;
+ const L = { hats: layer('shots'), claps: layer('products'), sixteenths: layer('forms'), lead: layer('consoles') };
+ for (let t = a, k = 0; t < b - 0.01; t += STEP, k++) {
+ const bar = Math.floor((t - a) / BAR), beat = k % 4;
+ const [root, iv] = CH[PROG[bar % 4]];
+ kick(t, beat === 0 ? 0.55 : 0.46);
+ if (t >= L.claps && (beat === 1 || beat === 3)) clap(t, 0.11);
+ if (t >= L.hats) hat(t + STEP / 2, 0.028, beat === 3);
+ if (t >= L.sixteenths) { hat(t + STEP / 4, 0.014); hat(t + STEP * 3 / 4, 0.014); }
+ // the bass: root eighths, the fifth on the last one of the bar
+ const bm = root - 12 + (root < 46 ? 12 : 0);
+ bass(t, bm, STEP / 2 * 0.85, 0.1);
+ bass(t + STEP / 2, beat === 3 ? bm + 7 : bm, STEP / 2 * 0.85, 0.085);
+ // the arpeggio: sixteenths over the chord, brighter layer by layer
+ const tones = iv.slice(0, 4).map(x => root + 24 + x);
+ for (let j = 0; j < 4; j++) {
+ const m = tones[(k * 4 + j) % tones.length];
+ pluck(t + j * STEP / 4, m, (t >= L.lead ? 0.034 : 0.026) * (j === 0 ? 1.2 : 1), (j % 2 ? 0.3 : -0.3), 0.35, drive);
+ }
+ if (beat === 0) pad(t, t + BAR - 0.05, [root + 12, root + 12 + iv[1], root + 12 + iv[2]], t >= L.lead ? 0.022 : 0.016, t >= L.lead ? 2600 : 1600, 0.05, 0.12, drive);
+ if (beat === 0 && t >= L.lead) pluck(t, root + 36 + iv[2], 0.03, 0, 1.4, drive);
+ }
+ // a cymbal-like swell into each montage cut, a big one into the drop and into the stop
+ for (const id of MONTAGE.slice(1).concat(['scan'])) riser(scene(id).start, 0.5, 0.03, 2000, 9000, drive);
+ riser(b, 2, 0.06, 500, 8000, drive);
+ }
+
+ // ---------- stop: one button is yours ----------
+ {
+ const [a, b] = S.stop;
+ // the last hit rings out into the room, then only a quiet low pulse, slower than the beat
+ bell(a, 74 + 12, 0.035);
+ for (let t = a + 1.0; t < b - 0.3; t += 1.0) {
+ const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = midi(38);
+ const v = ctx.createGain(); env(v, t, 0.02, 0.025 * Math.min(1, (t - a) / 3), 0.5); o.connect(v); v.connect(master); o.start(t); o.stop(t + 0.6);
+ }
+ }
+
+ // ---------- resolve: the click, waiting for review, the end card ----------
+ {
+ const [a, b] = S.resolve;
+ click(a);
+ const [r, iv] = CH.Dmaj9;
+ pad(a + 0.08, b + 0.6, [r - 12, r, r + iv[1], r + iv[2], r + iv[3], r + iv[4]], 0.048, 1600, 0.9, 1.0);
+ pad(a + 0.08, b + 0.6, [r - 24], 0.03, 200, 1.2, 1.0);
+ bell(a + 0.3, r + 24, 0.06);
+ bell(a + 0.65, r + 24 + 7, 0.045, 0.2);
+ bell(a + 1.0, r + 24 + 14, 0.035, -0.2);
+ // a slow arpeggio returns (the calm of the opening, now open)
+ const tones = [74, 78, 81, 85, 86, 85, 81, 78];
+ for (let t = a + 1.5, k = 0; t < b - 1.2; t += STEP, k++) pluck(t, tones[k % tones.length], 0.03, k % 2 ? 0.25 : -0.25, 1.4);
+ // the end card: one more bell as the logo lands
+ const e = scene('end').start;
+ bell(e + 0.3, r + 24, 0.05);
+ bell(e + 0.55, r + 31, 0.035, 0.2);
+ }
+
+ // ---------- soft accents on the story cuts ----------
+ for (const c of CUTS) {
+ if (MONTAGE.includes(c.id) || c.id === 'scan') continue;
+ if (c.id === 'reject' || c.id === 'builds') continue; // the boom and the drop are the accents
+ bell(c.t + 0.01, c.id === 'pile' ? 71 : 74, 0.03, 0.1);
+ }
+
+ // ---------- UI sounds ----------
for (const x of ticks()) {
- if (x.kind === 'tiny') tick(x.t, 0.022, 2800);
- else if (x.kind === 'soft') { tick(x.t, 0.03, 2400); tone(x.t, 86, 0.012, 0.18); }
- else if (x.kind === 'mail') { tone(x.t, 81, 0.04, 0.2); tone(x.t + 0.09, 88, 0.035, 0.4); }
- else if (x.kind === 'low') { tone(x.t, 50, 0.07, 0.5, 'triangle'); tone(x.t, 57, 0.02, 0.3); }
- else if (x.kind === 'ask') { tone(x.t, 83, 0.035, 0.3); tone(x.t + 0.14, 81, 0.03, 0.45); }
- else if (x.kind === 'good') { const m = [74, 78, 81][x.step] ?? 81; tone(x.t, m, 0.04, 0.6, 'sine', -0.2 + 0.2 * x.step); tone(x.t, m + 12, 0.012, 0.4); }
+ if (x.kind === 'tiny') tick(x.t, 0.02, 2800);
+ else if (x.kind === 'thud') thud(x.t, 0.12 + x.i * 0.015);
+ else if (x.kind === 'low') tone(x.t, 38, 0.05, 0.9, 'triangle');
+ else if (x.kind === 'send') { tone(x.t, 81, 0.03, 0.3); tone(x.t + 0.1, 86, 0.025, 0.6); }
+ else if (x.kind === 'good') { const m = [74, 76, 78, 81, 83][x.step] ?? 86; tone(x.t, m + 12, 0.045, 0.5, 'sine', -0.3 + 0.15 * x.step); tone(x.t, m + 24, 0.012, 0.3); }
+ else if (x.kind === 'clean') { tone(x.t, 86, 0.04, 0.8); tone(x.t + 0.08, 90, 0.03, 0.9); tone(x.t + 0.16, 93, 0.025, 1.0); }
}
for (const t of keyTimes()) key(t);
diff --git a/media/teaser/render.ts b/media/teaser/render.ts
index b783ee7..d827994 100644
--- a/media/teaser/render.ts
+++ b/media/teaser/render.ts
@@ -4,6 +4,7 @@
// -16 LUFS. Frames are captured at 60 fps and blended in pairs down to 30 fps (a 180° shutter): slow moves stay smooth.
// bun media/teaser/render.ts master 1920x1080, sound, poster, 1280x720 web version -> media/teaser/out/
// bun media/teaser/render.ts --stills 1,9.5,30 PNG stills only (out/stills/), quick layout check
+// bun media/teaser/render.ts --sound the music and sound only (out/music.wav)
// bun media/teaser/render.ts --desktop capture the desktop app frames again first (desktop.ts)
// bun media/teaser/render.ts --serve preview server: open /index.html?play=1 (or ?t=12.5)
// Needs ffmpeg and Playwright (devDependency; Playwright's Chromium or an installed Google Chrome), and the demo game's
@@ -21,7 +22,7 @@ const args = process.argv.slice(2);
const opt = (k: string): string | undefined => { const i = args.indexOf(`--${k}`); return i >= 0 ? args[i + 1] : undefined; };
/** 16:9 only. */
const FORMAT = { w: 1920, h: 1080 };
-/** The web version: 1280x720 H.264, two-pass to about this many bytes (about 770 kbps video for 62 s, as v6 had for 55.5 s). */
+/** The web version: 1280x720 H.264, two-pass to about this many bytes (about 830 kbps video for 58 s). */
const WEB_BYTES = 6.7e6;
const CAPTURE_FPS = 60;
const log = (m: string): void => console.log(`${new Date().toISOString().slice(11, 19)} [teaser] ${m}`);
@@ -33,7 +34,8 @@ function sh(cmd: string[], input?: Uint8Array): string {
}
const ff = (...a: string[]): string => sh(['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', ...a]);
-/** The screenshot grid: the demo game's real store renders, the first image of every size in every language. */
+/** The screenshot tiles: the demo game's real store renders, the first image of every size in every language; and the
+ * game's own screens. */
function prepAssets(): void {
const dir = join(ASSETS, 'shots');
rmSync(dir, { recursive: true, force: true });
@@ -47,8 +49,18 @@ function prepAssets(): void {
ff('-i', src, '-vf', size === 'feature' ? 'scale=600:-2:flags=lanczos' : 'scale=-2:360:flags=lanczos', '-q:v', '3', join(dir, `${lang}-${size}.jpg`));
n++;
}
+ // the game's own screens (its store captures, before any framing): the opening scene
+ const game = join(ASSETS, 'game');
+ rmSync(game, { recursive: true, force: true });
+ mkdirSync(game, { recursive: true });
+ for (const id of ['play', 'combo', 'map', 'stars']) {
+ const src = join(ROOT, 'examples/demo-game/store/shots/frames/en/phone', `${id}.png`);
+ if (!existsSync(src)) { missing.push(`frames/en/phone/${id}`); continue; }
+ ff('-i', src, '-vf', 'scale=-2:1100:flags=lanczos', '-q:v', '2', join(game, `${id}.jpg`));
+ n++;
+ }
if (missing.length) throw new Error(`demo game renders missing (${missing.slice(0, 4).join(', ')}${missing.length > 4 ? ', ...' : ''}): run its shots capture, fonts and render first (examples/demo-game/README.md)`);
- log(`assets: ${n} demo game renders (examples/demo-game/store/renders)`);
+ log(`assets: ${n} demo game renders and screens (examples/demo-game/store)`);
}
function serve(): ReturnType
{
@@ -75,7 +87,8 @@ async function main(): Promise {
const browser = await launch(ROOT, { headless: true });
try {
const stills = opt('stills');
- {
+ const soundOnly = args.includes('--sound');
+ if (!soundOnly) {
const f = FORMAT;
const page = await browser.newPage({ viewport: { width: f.w, height: f.h }, deviceScaleFactor: 1 });
const errors: string[] = [];
@@ -131,17 +144,21 @@ async function main(): Promise {
const raw = join(OUT, 'music-raw.wav'), music = join(OUT, 'music.wav');
await Bun.write(raw, Buffer.from(wav.base64, 'base64'));
const dur = wav.seconds;
- // two-pass loudnorm to -16 LUFS / -1.5 dBTP (calm music), then a short fade out
+ // loudness: measured with loudnorm (EBU R128), then ONE gain to about -16 LUFS that keeps the true peak at or below
+ // -1.5 dBTP, and a short fade out. No dynamic normalization: the near silence and the stop are part of the story
const m = Bun.spawnSync(['ffmpeg', '-hide_banner', '-i', raw, '-af', 'loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json', '-f', 'null', '-'], { stderr: 'pipe' });
const j = JSON.parse(m.stderr.toString().match(/\{[\s\S]*?\}/)![0]) as Record;
- ff('-i', raw, '-af', `loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=${j.input_i}:measured_TP=${j.input_tp}:measured_LRA=${j.input_lra}:measured_thresh=${j.input_thresh}:offset=${j.target_offset}:linear=true,afade=t=out:st=${(dur - 1.5).toFixed(2)}:d=1.5,aresample=48000`,
+ const gain = Math.min(-16 - Number(j.input_i), -1.5 - Number(j.input_tp));
+ log(`sound: ${j.input_i} LUFS, LRA ${j.input_lra} LU, true peak ${j.input_tp} dBTP; gain ${gain.toFixed(2)} dB -> ${(Number(j.input_i) + gain).toFixed(1)} LUFS`);
+ ff('-i', raw, '-af', `volume=${gain.toFixed(2)}dB,afade=t=out:st=${(dur - 1.5).toFixed(2)}:d=1.5,aresample=48000`,
'-c:a', 'pcm_s16le', '-ar', '48000', '-ac', '2', music);
- {
+ if (!soundOnly) {
const silent = join(OUT, `teaser-${FORMAT.w}x${FORMAT.h}-silent.mp4`), out = join(OUT, `teaser-${FORMAT.w}x${FORMAT.h}.mp4`);
if (!existsSync(silent)) throw new Error(`${silent} missing`);
ff('-i', silent, '-i', music, '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2', '-shortest', '-movflags', '+faststart', out);
log(`${out} (${(statSync(out).size / 1e6).toFixed(1)} MB)`);
}
+ if (soundOnly) return;
// web version: 1280x720 H.264, about 6 MB (two-pass), plus its poster
const h = join(OUT, 'teaser-1920x1080.mp4');
diff --git a/media/teaser/script.js b/media/teaser/script.js
index ae1e5ec..e3494cd 100644
--- a/media/teaser/script.js
+++ b/media/teaser/script.js
@@ -1,182 +1,221 @@
-// Teaser timeline (v7): the single source of truth for the picture (teaser.js) and the sound (music.js).
-// One idea per scene, 4-5 s each (62 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.
+// 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.
//
-// 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`
-// consoles `storebound console plan apple-app-privacy` in the init folder (offline)
-// 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; its console plan panel shows the real plan of
-// `console plan apple-app-privacy --json`). The plain reasons under the findings and the
-// callouts are annotations, set apart from the terminal text.
+// 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"
+//
+// 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`
+// (the CSV; its row count from `storebound check`), `storebound prices ppp --offline --dry` (the price sheet),
+// all in a fresh `storebound init` folder with two products
+// reject `storebound scan` of tests/fixtures/scan/teaser
+// builds `storebound apple build --dry` and `storebound play build --dry` in a Capacitor project
+// products `storebound play products --dry` and `storebound catalog diff` (the teaser store double)
+// forms the declarations proposal of `storebound scan` and the Data safety rows
+// consoles `storebound console plan apple-app-privacy`, then the desktop app's plan panel with that real plan
+// scan `storebound scan` of tests/fixtures/scan/teaser, then of teaser-fixed
+// The game screens are examples/demo-game's own captures (store/shots/frames), the screenshot tiles its real renders,
+// the desktop frames the app in fixture mode (desktop.ts).
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], ['consoles', 5], ['rejections', 5], ['portfolio', 4.5], ['charts', 4.5], ['catalog', 4.5], ['resubmit', 5], ['submit', 5]];
-export const SCENES = LENGTHS.reduce((a, [id, len]) => { const start = a.at(-1)?.end ?? 0; return [...a, { id, start, end: start + len }]; }, []);
+/** Scenes: id, length (s), the transition into it (s, centred on the cut; short = a 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],
+];
+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 DISSOLVE = 0.4;
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, 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).
- */
+/** 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 = {
- 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 } },
- // the terminal, then the app's plan panel dissolving in over it: one slow push over both
- consoles: { from: { z: 1.0, x: 0, y: 0 }, to: { z: 1.03, x: 0, y: 4 } },
- 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 } },
- resubmit: { 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.0, x: 0, y: 0 } },
+ 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 } },
};
-/** Captions: one per scene, at the top. *word* = highlighted. Lines are set as written. */
+/** 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. */
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: 'consoles', lines: ['No API? *Your AI fills the console.*', 'It stops at Publish.'] },
- { 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: 'resubmit', lines: ['Rejected? Fix it, *resubmit in one click.*'] },
- { id: 'submit', lines: ['You press *Submit.* That\'s it.'] },
+ { 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 },
];
-/** 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). */
+/** 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). */
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 };
+ 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 };
});
}
+/** 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. */
+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);
+ return 0;
+}
-// ---------- 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' };
+// ---------- 1 game: the demo game's real screens ----------
+/** examples/demo-game/store/shots/frames/en/phone/.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;
-// ---------- 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/',
+// ---------- 2 pile: the store work piles up ----------
+/** Real lines (see the header). */
+export const PRIVACY_ANSWERS = [
+ ' [ok] Do you or your third-party partners collect data from this app?: Yes -> pick "Yes, we collect data from this app" @ the Data Collection question (Get Started)',
+ ' [ok] Data type: Contact Info > Name: No -> untick "Name" @ the Data Collection dialog (Edit next to Data Types): the data type list',
+ ' [ok] Data type: Contact Info > Email Address: Yes -> tick "Email Address" @ the Data Collection dialog (Edit next to Data Types): the data type list',
+ ' [ok] Data type: Contact Info > Phone Number: No -> untick "Phone Number" @ the Data Collection dialog (Edit next to Data Types): the data type list',
+ ' [ok] Data type: Contact Info > Physical Address: No -> untick "Physical Address" @ the Data Collection dialog (Edit next to Data Types): the data type list',
+ ' [ok] Data type: Contact Info > Other User Contact Info: No -> untick "Other User Contact Info" @ the Data Collection dialog (Edit next to Data Types): the data type list',
+ ' [ok] Data type: Health & Fitness > Health: No -> untick "Health" @ the Data Collection dialog (Edit next to Data Types): the data type list',
+ ' [ok] Data type: Health & Fitness > Fitness: No -> untick "Fitness" @ the Data Collection dialog (Edit next to Data Types): the data type list',
+ ' [ok] Data type: Financial Info > Payment Info: No -> untick "Payment Info" @ the Data Collection dialog (Edit next to Data Types): the data type list',
+ ' [ok] Data type: Financial Info > Credit Info: No -> untick "Credit Info" @ the Data Collection dialog (Edit next to Data Types): the data type list',
+ ' [ok] Data type: Financial Info > Other Financial Info: No -> untick "Other Financial Info" @ the Data Collection dialog (Edit next to Data Types): the data type list',
+ ' [ok] Data type: Location > Precise Location: No -> untick "Precise Location" @ the Data Collection dialog (Edit next to Data Types): the data type list',
];
+export const PRIVACY_STEPS = [
+ ' 1. Open App Store Connect > Apps > the app > App Privacy (sidebar).',
+ ' 2. First time: click "Get Started" and answer the Data Collection question; then Next. [click: "Get Started"]',
+ ' 5. "Email Address" step 1, purposes: tick and untick the 6 checkboxes as answered, then Next. [click: "Next"]',
+ ' 6. "Email Address" step 2, linked to the user: pick the radio, then Next. [click: "Next"]',
+ ' 8. "Email Address" step 5, tracking: pick the radio, then Save (a draft). Never "Definitions and Examples". The dialog closes; the row shows Edit and the summary. [click: "Save"]',
+];
+export const DATA_SAFETY_CSV = [
+ 'PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA,,TRUE,REQUIRED,Does your app collect or share any of the required user data types?',
+ 'PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT,,TRUE,MAYBE_REQUIRED,Is all of the user data collected by your app encrypted in transit?',
+ 'PSL_SUPPORTED_ACCOUNT_CREATION_METHODS,PSL_ACM_USER_ID_PASSWORD,TRUE,MULTIPLE_CHOICE,Which of the following methods of account creation does your app support? Select all that apply / Username and password',
+ 'PSL_ACCOUNT_DELETION_URL,,https://example.com/delete-account,MAYBE_REQUIRED,Add a link that users can use to request that their account and associated data is deleted',
+ 'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_YES,TRUE,SINGLE_CHOICE,Do you provide a way for users to request that their data is deleted? / Yes',
+ 'PSL_SUPPORT_DATA_DELETION_BY_USER,DATA_DELETION_NO,,SINGLE_CHOICE,Do you provide a way for users to request that their data is deleted? / No',
+ 'PSL_DATA_DELETION_URL,,https://example.com/delete-account,MAYBE_REQUIRED,Delete data URL',
+ 'PSL_DATA_TYPES_PERSONAL,PSL_NAME,,MULTIPLE_CHOICE,Personal info / Name',
+ 'PSL_DATA_TYPES_PERSONAL,PSL_EMAIL,TRUE,MULTIPLE_CHOICE,Personal info / Email address',
+ 'PSL_DATA_TYPES_PERSONAL,PSL_USER_ACCOUNT,TRUE,MULTIPLE_CHOICE,Personal info / User IDs',
+ 'PSL_DATA_TYPES_PERSONAL,PSL_ADDRESS,,MULTIPLE_CHOICE,Personal info / Address',
+ 'PSL_DATA_TYPES_PERSONAL,PSL_PHONE,,MULTIPLE_CHOICE,Personal info / Phone number',
+ 'PSL_DATA_TYPES_PERSONAL,PSL_RACE_ETHNICITY,,MULTIPLE_CHOICE,Personal info / Race and ethnicity',
+];
+/** Data safety CSV rows `storebound check` counts for the config. */
+export const DECL_TOTAL = 768;
+export const PRICE_ROWS = [
+ 'com.example.app.no_ads,apple,AE,AED,6.99,,manual', 'com.example.app.no_ads,apple,AF,USD,0.89,,manual',
+ 'com.example.app.no_ads,apple,AG,USD,1.99,,manual', 'com.example.app.no_ads,apple,AL,USD,0.99,,manual',
+ 'com.example.app.no_ads,apple,AM,USD,0.99,,manual', 'com.example.app.no_ads,apple,AO,USD,0.99,,manual',
+ 'com.example.app.no_ads,apple,AR,USD,0.99,,manual', 'com.example.app.no_ads,apple,AT,EUR,1.99,,manual',
+ 'com.example.app.no_ads,apple,AU,AUD,3.99,,manual', 'com.example.app.no_ads,apple,AZ,USD,0.89,,manual',
+ 'com.example.app.no_ads,apple,BA,EUR,0.99,,manual', 'com.example.app.no_ads,apple,BB,USD,2.99,,manual',
+ 'com.example.app.no_ads,apple,BE,EUR,1.99,,manual', 'com.example.app.no_ads,apple,BF,USD,0.99,,manual',
+ 'com.example.app.no_ads,apple,BG,EUR,0.99,,manual', 'com.example.app.no_ads,apple,BH,USD,0.99,,manual',
+ 'com.example.app.no_ads,apple,BJ,USD,0.99,,manual', 'com.example.app.no_ads,apple,BM,USD,3.99,,manual',
+ 'com.example.app.no_ads,apple,BN,USD,0.99,,manual', 'com.example.app.no_ads,apple,BO,USD,0.99,,manual',
+ 'com.example.app.no_ads,apple,BR,BRL,7.99,,manual', 'com.example.app.no_ads,apple,BS,USD,2.99,,manual',
+];
+export const PRICE_CMD = 'prices ppp --offline --dry --store apple --only com.example.app.no_ads';
+/** 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 },
+];
+/** 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' };
-// ---------- 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 = 26;
-export const PLAN_PILL = `*${PLAN_STEPS}* steps · both stores · in fix order`;
+// ---------- 3 reject: one finding grows to fill the frame ----------
+/** The scan's own lines for the account-deletion finding (tests/fixtures/scan/teaser). */
+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];
-// ---------- 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 },
+// ---------- 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;
+/** Enter: the request is sent (a soft accent), seconds into the scene. */
+export const PROMPT_SENT = 3.05;
+
+// ---------- 5 montage ----------
+/** builds: real lines of `apple build --dry` and `play build --dry` (clock prefix and "(0s)" left out). */
+export const BUILD_LINES = [
+ { at: 0.1, text: '$ storebound apple build --dry --build-number 52 --version 1.0.0', cmd: true },
+ { at: 0.45, text: '[build] capacitor: ios/App/App.xcodeproj scheme App (Release), build 52, version 1.0.0, signing automatic' },
+ { at: 0.75, text: '[build] build planned (dry run)' },
+ { at: 1.25, text: '$ storebound play build --dry --version-code 52', cmd: true },
+ { at: 1.6, text: '[build] capacitor: android :app:bundleRelease, versionCode 52' },
+ { at: 1.85, text: "[build] signing: the project's own signingConfig" },
+ { at: 2.1, text: '[build] would run (in android): ./gradlew :app:bundleRelease --no-daemon --console=plain -Pandroid.injected.version.code=52 -PversionCode=52' },
+ { at: 2.4, text: '[build] build planned (dry run)' },
];
-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. */
+/** shots: 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' },
+ { id: 'ios69', label: 'iPhone 6.9″' }, { id: 'ios65', label: 'iPhone 6.5″' }, { id: 'ipad13', label: 'iPad 13″' },
+ { id: 'play', label: 'Play phone' }, { id: 'tab7', label: 'Play 7″' }, { id: 'tab10', label: 'Play 10″' }, { id: 'feature', label: 'Feature 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 SHOT_LANGS = ['en', 'tr', 'ja', 'ar'];
+export const SHOTS_AT = 0.1, SHOTS_STEP = 0.2;
+/** products: real output (clock prefix and the "(0s)" timer left out). */
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' },
+ { 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' },
];
-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). */
+/** forms: the declarations proposal (scan of the teaser fixture), then the Data safety rows it fills. */
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' },
@@ -184,150 +223,103 @@ export const DECL_LINES = [
{ 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 DECL_AT = 0.1, DECL_STEP = 0.18, ASK_AT = 0.9;
+/** Play Data safety rows the detected answers fill (Play's 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 = 768;
-export const FORMS_PILL = { at: 3.0, text: 'Data safety answers' };
-
-// ---------- 7 consoles: the console plan of a form without an API, then the app's plan panel ----------
+export const FORM_AT = 0.5, FORM_STEP = 0.16, FORMS_COUNT_AT = 1.6;
+/** consoles: the plan in the terminal, then the app's plan panel (DESK.consoles). */
export const CONSOLE_CMD = 'storebound console plan apple-app-privacy';
-/** The command is typed (fast), then the output streams. */
-export const CONSOLE_TYPE_AT = 0.05, CONSOLE_CPS = 120;
-/** Real lines of the plan (a fresh `storebound init` folder); the other steps and answers left out. at = seconds into
- * the scene. final = the owner's button, highlighted. */
export const CONSOLE_LINES = [
- { at: 0.45, text: 'App Store Connect: App Privacy', head: true },
- { at: 0.6, text: 'Steps:', head: true },
- { at: 0.72, text: ' 1. Open App Store Connect > Apps > the app > App Privacy (sidebar).' },
- { at: 0.84, text: ' 2. First time: click "Get Started" and answer the Data Collection question; then Next. [click: "Get Started"]' },
- { at: 0.96, text: ' 39. Stop. Do NOT press Publish: tell the owner to review the summary on the App Privacy page and press Publish, then Publish again in the "Publish Your App Privacy Responses?" dialog (it puts the answers on the App Store product page). Afterwards the page shows "Published … ago by …".' },
- { at: 1.14, text: 'Answer sheet:', head: true },
- { at: 1.26, text: ' [ok] Data type: Contact Info > Email Address: Yes -> tick "Email Address" @ the Data Collection dialog (Edit next to Data Types): the data type list' },
- { at: 1.38, text: ' [ask] Data type: User Content > Gameplay Content: (you decide) -> untick "Gameplay Content" @ the Data Collection dialog (Edit next to Data Types): the data type list' },
- { at: 1.56, text: 'Never press: "Publish", "Submit", "Create", "Create app", "Send", "Release", "Definitions and Examples", "?", "More information"' },
- { at: 1.72, text: "Final button (the owner's): Publish", final: true },
+ { at: 0.12, text: 'App Store Connect: App Privacy', head: true },
+ { at: 0.22, text: 'Steps:', head: true },
+ { at: 0.3, text: ' 1. Open App Store Connect > Apps > the app > App Privacy (sidebar).' },
+ { at: 0.38, text: ' 2. First time: click "Get Started" and answer the Data Collection question; then Next. [click: "Get Started"]' },
+ { at: 0.5, text: 'Answer sheet:', head: true },
+ { at: 0.58, text: ' [ok] Data type: Contact Info > Email Address: Yes -> tick "Email Address" @ the Data Collection dialog (Edit next to Data Types): the data type list' },
+ { at: 0.7, text: 'Never press: "Publish", "Submit", "Create", "Create app", "Send", "Release", "Definitions and Examples", "?", "More information"' },
+ { at: 0.82, text: "Final button (the owner's): Publish", final: true },
];
-/** Steps and answer-sheet rows in the whole plan (the numbered and the [ok] / [ask] lines of the real output). */
-export const CONSOLE_STEPS = 40, CONSOLE_ANSWERS = 92;
-export const CONSOLES_PILL = { at: 1.95, text: `*${CONSOLE_STEPS}* steps · *${CONSOLE_ANSWERS}* answers · stops before *Publish*` };
-/** The terminal gives way to the app's plan panel (DESK.consoles) over this window, seconds into the scene. */
-export const CONSOLES_SWAP = [2.45, 0.5];
+/** The terminal gives way to the app's plan panel over this window, seconds into the scene. */
+export const CONSOLES_SWAP = [1.05, 0.35];
-// ---------- 8 rejections: three findings with a plain reason each, then fixed ----------
+// ---------- 6 scan: every finding turns green, the re-scan is clean ----------
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. */
+/** The five findings' own header lines (after the number). */
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.' },
+ 'BLOCKER 2.1(a) · App Store · Sign-in is required but there is no demo account for App Review',
+ 'BLOCKER 5.1.1(v) · App Store · Accounts can be created but no in-app account deletion was found',
+ 'HIGH 4.8 · App Store · Third-party login (Google / Facebook / ...) without Sign in with Apple or another equivalent login option',
+ '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)',
];
-/** 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 FINDINGS_AT = 0.4, FINDINGS_STEP = 0.12;
+/** 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 RESCAN_LINE = 'Rejection risks: 0 blocker, 0 high, 0 medium, 0 low';
-// ---------- 7, 9-13 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: on boxes captured from the app (boxes.json), below the box (above: true, above it).
- * show / hide: the window fades in / out ([at, seconds]); pointer: the mouse pointer glides onto a captured box and
- * rests there (it never clicks). */
+// ---------- desktop: real frames of the app in fixture mode (desktop.ts) ----------
+/** One entry per scene. groups: what the window shows, in order (one captured frame each); 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. rings: a quiet ring on a captured box (boxes.json), no text. pointer: glides onto a box and rests there;
+ * click = the moment it presses (only the owner's button, in the waiting scene). */
export const DESK = {
- // the console plan panel (App info > Console steps > App Privacy > Plan) with the real plan, at its final button
consoles: {
groups: [{ frame: 'console-plan' }],
show: CONSOLES_SWAP,
- cam: { from: { f: [0.5, 0.56], z: 1.18 }, to: { f: [0.5, 0.56], z: 1.18 } },
- callouts: [{ at: 3.15, until: 5.3, target: 'cpFinal', text: 'Publish stays yours: it stops there', green: true }],
+ 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 }],
},
- 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' }],
+ // 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' },
},
- 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' }],
- },
- // Releases > Send for review, back from App Review (UNRESOLVED_ISSUES): the rejected item, build 52 picked, Resubmit
- resubmit: {
- groups: [{ frame: 'resubmit' }],
- cam: { from: { f: [0.45, 0.45], z: 1.0 }, to: { f: [0.3, 0.62], z: 1.18 } },
- callouts: [
- { at: 0.6, until: 2.5, target: 'unresolvedItem', text: 'Back from App Review: what was rejected' },
- { at: 2.75, until: 5.3, target: 'resubmitBtn', text: 'Marks it resolved and resubmits' },
- ],
- pointer: { at: 2.2, d: 0.8, target: 'resubmitBtn' },
- },
- // Submit for review: the dry-run plan with "Continue" (the in-app step before the system dialog), then the new
- // submission waiting for review; the window then gives way to the end card
- submit: {
- groups: [{ frame: 'submit-plan' }, { frame: 'submit-waiting' }],
- swap: [1.55, 1.95],
- hide: [3.0, 0.5],
- cam: { from: { f: [0.5, 0.45], z: 1.0 }, to: { f: [0.5, 0.48], z: 1.1 } },
- callouts: [
- { at: 0.35, until: 1.5, target: 'oaPlan', text: 'Dry run first: nothing sent yet', above: true },
- { at: 2.05, until: 3.0, target: 'waitingItem', text: 'Waiting for review', green: true },
- ],
- pointer: { at: 0.3, d: 0.85, target: 'oaContinue', until: 1.55 },
+ // 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 }],
+ pointer: { at: -1, d: 0.01, target: 'submitBtn', click: 0.2, until: 0.6 },
},
};
export const DESK_SCENES = Object.keys(DESK);
-/** Every captured frame a desktop scene names (sweep frames are listed in boxes.json by desktop.ts). */
+/** Every captured frame a desktop scene names. */
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));
+/** The click on Submit (absolute seconds): the music's resolve starts here. */
+export const CLICK_T = scene('waiting').start + DESK.waiting.pointer.click;
-// ---------- 13 submit: end card ----------
-/** The end card comes in at logoAt (seconds into the submit scene), as the app window fades out (DESK.submit.hide). */
-export const SUBMIT = { logoAt: 3.0, logoIn: 0.6 };
-export const URL = 'storebound.dev';
-export const TAGLINE = 'Open source · CLI · Desktop app';
+// ---------- 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';
// ---------- 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). */
+/** Cuts with a sound accent (every scene change but the seamless button -> waiting one). */
+export const CUTS = SCENES.slice(1).filter(s => s.id !== 'waiting').map(s => ({ t: s.start, id: s.id }));
+/** Ticks: cards landing (thud), lines and rows (tiny), the finding (low), fixes (bright, rising), the send (soft). */
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' })),
+ ...PILE.map((c, i) => ({ t: at('pile') + c.at, kind: 'thud', i })),
+ { t: at('reject') + 0.05, kind: 'low' },
+ { t: at('ask') + PROMPT_SENT, kind: 'send' },
+ ...BUILD_LINES.filter(l => !l.cmd).map(l => ({ t: at('builds') + l.at, kind: 'tiny' })),
...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' })),
- ...CONSOLE_LINES.map(l => ({ t: at('consoles') + l.at, kind: l.final ? 'ask' : '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 => (id === 'submit' && c.green ? { t: at(id) + c.at, kind: 'good', step: 2 } : { t: at(id) + c.at, kind: 'soft' }))),
+ ...CONSOLE_LINES.map(l => ({ t: at('consoles') + l.at, kind: 'tiny' })),
+ ...FIX_AT.map((t, i) => ({ t: at('scan') + t, kind: 'good', step: i })),
+ { t: at('scan') + RESCAN_AT, kind: 'clean' },
];
}
/** 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);
- for (let i = 0; i < CONSOLE_CMD.length; i += 3) out.push(scene('consoles').start + CONSOLE_TYPE_AT + i / CONSOLE_CPS);
+ for (let i = 0; i < PROMPT.length; i++) if (PROMPT[i] !== ' ' || i % 2) out.push(scene('ask').start + PROMPT_AT + i / PROMPT_CPS);
return out;
}
diff --git a/media/teaser/teaser.js b/media/teaser/teaser.js
index ee63dc8..e046364 100644
--- a/media/teaser/teaser.js
+++ b/media/teaser/teaser.js
@@ -1,12 +1,10 @@
-// Teaser picture (v7): every element is set from the time t alone (window.teaser.seek(t)), so frames render
-// deterministically, in any order. 16:9 only (1920x1080). URL: &t=12.5 holds a frame, &play=1 previews in real time.
-import { APP_NAME, ASK_AT, CAMERA, CAP_DELAY, CAP_END, CAP_IN, CAP_OUT, CAPTIONS, captionTimes, CONSOLE_CMD, CONSOLE_CPS, CONSOLE_LINES,
- CONSOLE_TYPE_AT, CONSOLES_PILL, CONSOLES_SWAP, COPY, COPY_AT, COPY_FILE, COPY_STEP,
- DATA_SAFETY_ROWS, DECL_ASK, DECL_AT, DECL_LINES, DECL_STEP, DECL_TOTAL, DESK, DESK_SCENES, DISSOLVE, DURATION, FINDINGS, FIX_AT, FOLDER,
- FORM_AT, FORM_STEP, FORMAT, FORMS_PILL, FPS, INIT_CMD, INIT_CPS, INIT_LINES, INIT_OUT_AT, INIT_STEP, INIT_TYPE_AT,
- MAIL, MAIL_IN, PLAN_AT, PLAN_CMD, PLAN_CMD_AT, PLAN_LINES, PLAN_PILL, PLAN_STEP, PRODUCT_LINES, PRODUCTS_PILL, PROMPT, PROMPT_AT, PROMPT_CPS,
- RESCAN_AT, RESCAN_LINE, RISKS_LINE, SCAN_CMD, SCENES, SHOT_LANGS, SHOT_SIZES, SHOTS_AT, SHOTS_CMD, SHOTS_PILL, SHOTS_STEP,
- SUBMIT, TAGLINE, TODO, URL, scene } from './script.js';
+// Teaser picture (v8, a story): every element is set from the time t alone (window.teaser.seek(t)), so frames render
+// deterministically, in any order. 16:9 only (1920x1080). URL: ?t=12.5 holds a frame, ?play=1 previews in real time.
+import { ASK_AT, BUILD_LINES, CAMERA, CAP_FADE, CAPTIONS, captionTimes, CONSOLE_CMD, CONSOLE_LINES, CONSOLES_SWAP,
+ DATA_SAFETY_CSV, DATA_SAFETY_ROWS, DECL_ASK, DECL_AT, DECL_LINES, DECL_STEP, DECL_TOTAL, DESK, DESK_SCENES, DURATION, END, END_LINE, END_URL,
+ FINDINGS, FINDINGS_AT, FINDINGS_STEP, FIX_AT, FORM_AT, FORM_STEP, FORMAT, FORMS_COUNT_AT, FPS, GAME_AT, GAME_SCREENS, GAME_STEP, MONTAGE,
+ PILE, PILE_TITLES, PRICE_ROWS, PRIVACY_ANSWERS, PRIVACY_STEPS, PRODUCT_LINES, PROMPT, PROMPT_AT, PROMPT_CPS, PROMPT_SENT,
+ REJECT, REJECT_GROW, RESCAN_AT, RESCAN_LINE, RISKS_LINE, SCAN_CMD, SCENES, SHOT_LANGS, SHOT_SIZES, SHOTS_AT, SHOTS_STEP, scene, veil } from './script.js';
const q = new URLSearchParams(location.search);
const W = FORMAT.w, H = FORMAT.h;
@@ -19,174 +17,222 @@ const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
const prog = (t, a, d) => (d <= 0 ? (t >= a ? 1 : 0) : clamp((t - a) / d));
const eoc = p => 1 - Math.pow(1 - p, 3);
const eio = p => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2);
-/** The camera: a quick ease-out (quartic) that settles at 80% of the scene, so each move reads snappy, then rests. */
+/** A quick ease-out (quartic) that settles at 80% of the scene: the montage's push. */
const ecam = p => 1 - Math.pow(1 - clamp(p / 0.8), 4);
+/** A slow sine ease over the whole scene: the story scenes' drift. */
+const esin = p => 0.5 - Math.cos(Math.PI * clamp(p)) / 2;
const lerp = (a, b, p) => a + (b - a) * p;
const esc = s => String(s).replace(/&/g, '&').replace(//g, '>');
const set = (el, k, v) => { if (el.__s?.[k] !== v) { (el.__s ??= {})[k] = v; el.style[k] = v; } };
const html = (el, v) => { if (el.__h !== v) { el.__h = v; el.innerHTML = v; } };
-/** *word* -> word (pills) */
-const bold = s => esc(s).replace(/\*([^*]+)\*/g, '$1');
+const hl = s => esc(s).replace(/\*([^*]+)\*/g, '$1');
/** A gentle appear: opacity and a small rise. */
-function appear(el, t, at, d = 0.35, rise = 14) {
+function appear(el, t, at, d = 0.3, rise = 10) {
const p = prog(t, at, d);
set(el, 'opacity', String(eoc(p)));
set(el, 'transform', `translateY(${((1 - eoc(p)) * rise).toFixed(1)}px)`);
return p;
}
-function pillAt(el, t, at, base = 'translate(-50%, 0)') {
- const p = prog(t, at, 0.4);
- set(el, 'opacity', String(eoc(p)));
- set(el, 'transform', `${base} translateY(${((1 - eoc(p)) * 16).toFixed(1)}px)`);
-}
+const tagged = l => esc(l).replace(/^(\[\w+\])/, '$1');
-// ---------- scenes: dissolves and one camera move each ----------
+// ---------- scenes: transitions centred on the cuts, one camera move each ----------
const sceneEls = Object.fromEntries(SCENES.map(s => [s.id, { el: $(`#s-${s.id}`), cam: $(`#s-${s.id} > .cam`) }]));
function scenes(t) {
SCENES.forEach((s, i) => {
const { el, cam } = sceneEls[s.id];
- const from = i === 0 ? -1 : s.start - DISSOLVE / 2, until = i === SCENES.length - 1 ? Infinity : s.end + DISSOLVE / 2;
+ const next = SCENES[i + 1];
+ const from = i === 0 ? -1 : s.start - s.x / 2, until = next ? next.start + next.x / 2 : Infinity;
if (t < from || t >= until) { set(el, 'visibility', 'hidden'); set(el, 'opacity', '0'); return; }
set(el, 'visibility', 'visible');
- set(el, 'opacity', String(i === 0 ? 1 : eio(prog(t, from, DISSOLVE))));
+ // the first scene comes up from black; the others dissolve in over the previous one
+ set(el, 'opacity', String(i === 0 ? eio(prog(t, 0, 0.9)) : eio(prog(t, from, s.x))));
const c = CAMERA[s.id];
- const p = ecam(clamp((t - (s.start - DISSOLVE / 2)) / (s.end - s.start + DISSOLVE)));
+ const raw = (t - s.start) / (s.end - s.start);
+ const p = MONTAGE.includes(s.id) ? ecam(raw) : esin(raw);
const z = lerp(c.from.z, c.to.z, p), x = lerp(c.from.x, c.to.x, p), y = lerp(c.from.y, c.to.y, p);
set(cam, 'transform', `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) scale(${z.toFixed(5)})`);
});
}
-const active = t => SCENES.find(s => t >= s.start && t < s.end) ?? SCENES[SCENES.length - 1];
+const active = t => SCENES.find(s => t >= s.start && t < s.end) ?? SCENES.at(-1);
-// ---------- background: a slow drift ----------
-const g1 = $('#bg .g1'), g2 = $('#bg .g2'), grid = $('#bg .grid');
+// ---------- background: a slow drift, the veil ----------
+const g1 = $('#bg .g1'), g2 = $('#bg .g2'), grid = $('#bg .grid'), veilEl = $('#veil');
function background(t) {
set(g1, 'transform', `translate(${(W * 0.25 + Math.sin(t * 0.11) * 160 - 700).toFixed(1)}px, ${(H * 0.35 + Math.cos(t * 0.09) * 120 - 700).toFixed(1)}px)`);
set(g2, 'transform', `translate(${(W * 0.8 + Math.cos(t * 0.1) * 160 - 550).toFixed(1)}px, ${(H * 0.75 + Math.sin(t * 0.12) * 110 - 550).toFixed(1)}px)`);
set(grid, 'transform', `translate(${((t * 6) % 96).toFixed(2)}px, ${((t * 8) % 96).toFixed(2)}px)`);
+ set(veilEl, 'opacity', veil(t).toFixed(4));
}
-// ---------- caption ----------
-const capEl = $('#cap');
+// ---------- captions: the story line (top) and the montage word (edge) ----------
+const capEl = $('#cap'), capBand = $('#capband'), edgeEl = $('#edge'), edgeBand = $('#edgeband');
const CT = Object.fromEntries(captionTimes().map(c => [c.id, c]));
let capId = null;
-function buildCaption(c) {
- capEl.className = c.hl ?? '';
- capEl.style.fontSize = '';
- capEl.innerHTML = c.lines.map(l => `${esc(l).replace(/\*([^*]+)\*/g, '$1')}`).join('');
- const avail = capEl.clientWidth, base = parseFloat(getComputedStyle(capEl).fontSize);
- const widest = Math.max(...[...capEl.querySelectorAll('.line')].map(l => l.scrollWidth));
- if (widest > avail) capEl.style.fontSize = `${Math.floor(base * avail / widest)}px`;
-}
function captions(t) {
- const s = active(t), c = CAPTIONS.find(x => x.id === s.id), ct = CT[s.id];
- if (!c) { set(capEl, 'opacity', '0'); return; }
- if (capId !== c.id) { capId = c.id; buildCaption(c); }
- const pin = s === SCENES[0] && t < CAP_DELAY ? 0 : eoc(prog(t, ct.at, CAP_IN));
- const pout = s === SCENES.at(-1) ? 0 : prog(t, ct.to, CAP_OUT);
- set(capEl, 'opacity', String(pin * (1 - pout)));
- set(capEl, 'transform', `translateY(${((1 - pin) * 12 - pout * 8).toFixed(1)}px)`);
-}
-// keep the constants in use (they define captionTimes, imported above for the timing test)
-void CAP_DELAY; void CAP_END;
-
-// ---------- 1 hook ----------
-const todoRows = $('#todo .rows'), mailEl = $('#mail'), appcard = $('#appcard');
-$('#appcard .nm').textContent = APP_NAME;
-todoRows.innerHTML = TODO.map(x => `${esc(x.text)}
`).join('');
-const todoEls = [...todoRows.querySelectorAll('.row')];
-mailEl.querySelector('.from').textContent = MAIL.from;
-mailEl.querySelector('.date').textContent = MAIL.date;
-mailEl.querySelector('.subj').textContent = MAIL.subject;
-mailEl.querySelector('.prev').innerHTML = `Hello, Thank you for submitting your app… ${esc(MAIL.guideline)}`;
-function hook(t) {
- const s = scene('hook'); if (t > s.end + 1) return;
- const r = t - s.start;
- appear(appcard, r, 0.05, 0.4, 20);
- appear($('#todo'), r, 0.2, 0.4, 20);
- todoEls.forEach((el, i) => appear(el, r, TODO[i].at, 0.4, 10));
- const p = prog(r, MAIL.at, MAIL_IN);
- set(mailEl, 'opacity', String(eoc(p)));
- set(mailEl, 'transform', `translateY(${((1 - eoc(p)) * 50).toFixed(1)}px)`);
+ const s = active(t), c = CAPTIONS.find(x => x.id === s.id);
+ let top = 0, edge = 0, rise = 0;
+ if (c) {
+ const ct = CT[c.id], [fin, fout] = CAP_FADE[c.pos];
+ const pin = eoc(prog(t, ct.at, fin)), pout = prog(t, ct.to, fout);
+ const op = pin * (1 - pout);
+ rise = (1 - pin) * 14 - pout * 8;
+ if (capId !== c.id) {
+ capId = c.id;
+ if (c.pos === 'top') { capEl.className = c.hl ?? ''; capEl.innerHTML = hl(c.text); } else edgeEl.innerHTML = hl(c.text);
+ }
+ if (c.pos === 'top') top = op; else edge = op;
+ }
+ set(capEl, 'opacity', String(top)); set(capBand, 'opacity', String(top));
+ set(capEl, 'transform', `translateY(${rise.toFixed(1)}px)`);
+ set(edgeEl, 'opacity', String(edge)); set(edgeBand, 'opacity', String(Math.min(1, edge * 1.2)));
+ set(edgeEl, 'transform', `translateX(${(edge < 1 ? (1 - edge) * -18 : 0).toFixed(1)}px)`);
}
-// ---------- 2 init ----------
-const folderEl = $('#folder'), filesEl = $('#folder .files'), emptyEl = $('#folder .empty'), initBody = $('#initterm .body');
-$('#folder .fname').textContent = `${FOLDER}/`;
-/** The folder list: one entry per file `init` creates (from the real output lines). */
-const FILES = INIT_LINES.map((l, i) => ({ i, m: l.match(/^\[init\] create (.+)$/) })).filter(x => x.m).map(x => ({ i: x.i, path: x.m[1] }));
-filesEl.innerHTML = FILES.map(f => {
- const k = f.path.lastIndexOf('/');
- return `${k >= 0 ? `${esc(f.path.slice(0, k + 1))}` : ''}${esc(f.path.slice(k + 1))}
`;
-}).join('');
-const fileEls = [...filesEl.querySelectorAll('.f')];
-const tagged = l => esc(l).replace(/^(\[\w+\])/, '$1');
-initBody.innerHTML = `${INIT_LINES.map(l => `${tagged(l).replace(/ (create) /, ' $1 ')}`).join('')}`;
-const initLns = [...initBody.querySelectorAll('.ln')];
-function init(t) {
- const s = scene('init'); if (t < s.start - 1 || t > s.end + 1) return;
+// ---------- 1 game: the game's own screens ----------
+const gameEl = $('#game');
+const phones = GAME_SCREENS.map((id, i) => {
+ const el = document.createElement('div');
+ el.className = 'phone';
+ Object.assign(el.style, { left: `${195 + i * 400}px`, backgroundImage: `url(out/assets/game/${id}.jpg)` });
+ gameEl.appendChild(el);
+ return el;
+});
+function game(t) {
+ const s = scene('game'); if (t > s.end + 1) return;
const r = t - s.start;
- const n = clamp(Math.floor((r - INIT_TYPE_AT) * INIT_CPS), 0, INIT_CMD.length);
- const caret = r < INIT_OUT_AT && (n < INIT_CMD.length || Math.floor(r * 2.2) % 2 === 0);
- html(initLns[0], `~/${esc(FOLDER)} $ ${esc(INIT_CMD.slice(0, n))}${caret ? '' : ''}`);
- set(initLns[0], 'opacity', '1');
- initLns.slice(1).forEach((el, i) => appear(el, r, INIT_OUT_AT + 0.2 + i * INIT_STEP, 0.25, 6));
- fileEls.forEach((el, k) => {
- const p = prog(r, INIT_OUT_AT + 0.2 + FILES[k].i * INIT_STEP, 0.3);
+ phones.forEach((el, i) => {
+ const p = prog(r, GAME_AT + i * GAME_STEP, 0.9);
set(el, 'opacity', String(eoc(p)));
- set(el, 'transform', `translateX(${((1 - eoc(p)) * -16).toFixed(1)}px)`);
+ // a slow float, each phone on its own phase
+ const fy = Math.sin(r * 0.9 + i * 1.3) * 6;
+ set(el, 'transform', `translateY(${((1 - eoc(p)) * 40 + fy).toFixed(1)}px) rotate(${((i - 1.5) * 1.2).toFixed(2)}deg)`);
});
- set(emptyEl, 'opacity', String(1 - prog(r, INIT_OUT_AT + 0.1, 0.3)));
- void folderEl;
}
-// ---------- 3 plan ----------
-const promptEl = $('#prompt'), promptTxt = $('#prompt .txt'), planTerm = $('#planterm'), planBody = $('#planterm .body'), planPill = $('#plan-pill');
-const planLine = l => {
- if (/^\S/.test(l)) return `${esc(l)}`;
- if (/^ (both stores|App Store|Google Play)/.test(l)) return `${esc(l)}`;
- return esc(l).replace(/^(\s+\d+\.) (CONFIG |STORE |BLOCKER)/, (m, a, b) => `${a} ${b}`);
-};
-planBody.innerHTML = `${PLAN_LINES.map(l => `${planLine(l)}`).join('')}`;
-const planLns = [...planBody.querySelectorAll('.ln')];
-planPill.innerHTML = bold(PLAN_PILL);
-function plan(t) {
- const s = scene('plan'); if (t < s.start - 1 || t > s.end + 1) return;
- const r = t - s.start;
- appear(promptEl, r, 0.05, 0.35, 10);
- const n = clamp(Math.floor((r - PROMPT_AT) * PROMPT_CPS), 0, PROMPT.length);
- html(promptTxt, `${esc(PROMPT.slice(0, n))}${n < PROMPT.length || r < PLAN_CMD_AT ? '' : ''}`);
- appear(planTerm, r, PLAN_CMD_AT - 0.35, 0.4, 16);
- html(planLns[0], `$ ${esc(PLAN_CMD)}`);
- appear(planLns[0], r, PLAN_CMD_AT, 0.25, 4);
- planLns.slice(1).forEach((el, i) => appear(el, r, PLAN_AT + i * PLAN_STEP, 0.25, 6));
- pillAt(planPill, r, PLAN_AT + PLAN_LINES.length * PLAN_STEP + 0.15);
-}
-
-// ---------- 4 store ----------
-const copyRows = $('#copy .rows'), shotsEl = $('#shots'), storePill = $('#store-pill'), shotCmd = $('#shotcmd');
-$('#copy .fname').textContent = COPY_FILE;
-copyRows.innerHTML = COPY.map(c => `${esc(c.lang)}${esc(c.title.join(' '))}
`).join('');
-const copyEls = [...copyRows.querySelectorAll('.row')];
-storePill.innerHTML = bold(SHOTS_PILL);
-shotCmd.querySelector('.cmd').textContent = SHOTS_CMD;
-/** The grid: one column per size (portrait sizes by aspect, the feature graphic last), one row per language. */
+// ---------- 2 pile: the store work lands, card after card ----------
+const pileEl = $('#pile');
const DIMS = { ios69: [1320, 2868], ios65: [1284, 2778], ipad13: [2064, 2752], play: [1080, 1920], tab7: [1080, 1920], tab10: [1440, 2560], feature: [1024, 500] };
+const ROW_H = 33.6, BODY_H = 330;
+const rowsOf = c => {
+ const from = c.from ?? 0;
+ if (c.kind === 'privacy') return PRIVACY_ANSWERS.slice(from).map(l => esc(l).replace(/\[ok\]/, '[ok]'));
+ if (c.kind === 'steps') return PRIVACY_STEPS.map(l => esc(l).replace(/^(\s+\d+\.)/, '$1').replace(/(\[click: [^\]]+\])/, '$1'));
+ if (c.kind === 'csv') return DATA_SAFETY_CSV.slice(from).map(l => esc(l).replace(/^(PSL_[A-Z_]+)/, '$1').replace(/,TRUE,/, ',TRUE,'));
+ if (c.kind === 'prices') return PRICE_ROWS.slice(from).map(l => esc(l).replace(/,([A-Z]{2}),([A-Z]{3}),([\d.]+),/, ',$1,$2,$3,'));
+ return [];
+};
+const pileCards = PILE.map(c => {
+ const el = document.createElement('div');
+ el.className = 'card pcard';
+ const h = 64 + BODY_H;
+ Object.assign(el.style, { left: `${c.x - c.w / 2}px`, top: `${c.y - h / 2}px`, width: `${c.w}px` });
+ let body;
+ if (c.kind === 'shots') {
+ // two languages of the demo game's renders, every store size, side by side
+ const langs = [c.lang ?? 'en', SHOT_LANGS[(SHOT_LANGS.indexOf(c.lang ?? 'en') + 1) % SHOT_LANGS.length]];
+ const GAP = 10, RH = (BODY_H - 32 - GAP) / 2;
+ const ws = SHOT_SIZES.map(sz => RH * DIMS[sz.id][0] / DIMS[sz.id][1]);
+ const total = ws.reduce((a, b) => a + b, 0) + GAP * (ws.length - 1);
+ let tiles = '';
+ langs.forEach((lang, j) => {
+ let x = (c.w - 44 - total) / 2;
+ SHOT_SIZES.forEach((sz, i) => {
+ tiles += ``;
+ x += ws[i] + GAP;
+ });
+ });
+ body = `${tiles}
`;
+ } else body = `${rowsOf(c).map(r => `
${r}
`).join('')}
`;
+ el.innerHTML = `${esc(PILE_TITLES[c.kind])}
`;
+ pileEl.appendChild(el);
+ const rows = el.querySelector('.rows');
+ const n = rows ? rowsOf(c).length : 0;
+ return { c, el, rows, shade: el.querySelector('.shade'), maxScroll: Math.max(0, n * ROW_H + 28 - BODY_H) };
+});
+function pile(t) {
+ const s = scene('pile'); if (t < s.start - 1 || t > s.end + 1) return;
+ const r = t - s.start;
+ pileCards.forEach(({ c, el, rows, shade, maxScroll }, i) => {
+ const p = prog(r, c.at, 0.32);
+ set(el, 'opacity', String(clamp(p * 2.5)));
+ const e = eoc(p);
+ set(el, 'transform', `translateY(${lerp(-70, 0, e).toFixed(1)}px) rotate(${lerp(c.rot * 2.2, c.rot, e).toFixed(2)}deg) scale(${lerp(1.22, 1, e).toFixed(4)})`);
+ // the rows keep streaming: there is always more
+ if (rows) set(rows, 'transform', `translateY(${(-Math.min(maxScroll, Math.max(0, r - c.at) * 34)).toFixed(1)}px)`);
+ // older cards sink into the shadow of the new ones
+ const above = PILE.slice(i + 1).reduce((k, x) => k + eoc(prog(r, x.at, 0.32)), 0);
+ set(shade, 'opacity', String(Math.min(0.6, above * 0.16).toFixed(3)));
+ });
+}
+
+// ---------- 3 reject: one finding grows to fill the frame ----------
+const findEl = $('#finding');
+{
+ const m = REJECT.head.match(/^(BLOCKER) (\S+) · App Store · (.+)$/);
+ findEl.querySelector('.l1').innerHTML = `${m[1]}${esc(m[2])}· App Store ·${esc(m[3])}`;
+ findEl.querySelector('.l2').innerHTML = esc(REJECT.at).replace(/^(at \S+)/, '$1');
+}
+function reject(t) {
+ const s = scene('reject'); if (t < s.start - 1 || t > s.end + 1) return;
+ const r = t - s.start;
+ set(findEl, 'opacity', String(eoc(prog(r, -0.1, 0.45))));
+ const z = lerp(REJECT_GROW[0], REJECT_GROW[1], esin(prog(r, -0.1, s.end - s.start + 0.3)));
+ set(findEl, 'transform', `translate(-50%, -50%) scale(${z.toFixed(4)})`);
+}
+
+// ---------- 4 ask: the request to the agent ----------
+const promptEl = $('#prompt'), promptTxt = $('#prompt .txt'), sentEl = $('#prompt .sent');
+function ask(t) {
+ const s = scene('ask'); if (t < s.start - 1 || t > s.end + 1) return;
+ const r = t - s.start;
+ appear(promptEl, r, 0.15, 0.6, 16);
+ const n = clamp(Math.floor((r - PROMPT_AT) * PROMPT_CPS), 0, PROMPT.length);
+ const caret = r < PROMPT_SENT && (n < PROMPT.length || Math.floor(r * 2.2) % 2 === 0);
+ html(promptTxt, `${esc(PROMPT.slice(0, n))}${caret ? '' : ''}`);
+ const sp = prog(r, PROMPT_SENT, 0.25);
+ set(sentEl, 'opacity', String(lerp(0.35, 1, eoc(sp))));
+ set(sentEl, 'transform', `scale(${(1 + Math.sin(Math.PI * sp) * 0.18).toFixed(3)})`);
+}
+
+// ---------- 5 montage ----------
+const cmdLine = text => `$ ${esc(text.replace(/^\$ /, ''))}`;
+function termLines(sel, lines, fmt) {
+ const body = $(`${sel} .body`);
+ body.innerHTML = lines.map((l, i) => `${i && l.cmd ? ' ' : ''}${l.cmd ? cmdLine(l.text) : fmt(l.text)}`).join('');
+ return [...body.querySelectorAll('.ln:not(.sp)')];
+}
+const buildLns = termLines('#buildterm', BUILD_LINES, l => tagged(l).replace(/(build 52|versionCode 52)/, '$1').replace(/(build planned \(dry run\))/, '$1'));
+const prodLns = termLines('#prodterm', PRODUCT_LINES, l => tagged(l).replace(/^ok {4}/, 'ok ')
+ .replace(/(\d+ regions?|\d+ region prices)/, '$1').replace(/(0 mismatch\(es\))/, '$1'));
+function montageTerm(id, sel, lns, lines) {
+ return t => {
+ const s = scene(id); if (t < s.start - 1 || t > s.end + 1) return;
+ const r = t - s.start;
+ appear($(sel), r, -0.1, 0.2, 10);
+ lns.forEach((el, i) => appear(el, r, lines[i].at, 0.18, 6));
+ };
+}
+const builds = montageTerm('builds', '#buildterm', buildLns, BUILD_LINES);
+const products = montageTerm('products', '#prodterm', prodLns, PRODUCT_LINES);
+
+// shots: one column per store size, one row per language
+const shotsEl = $('#shots');
const shotTiles = [], shotLabels = [], langLabels = [];
{
- const LAB = 50, LANGW = 52, GAP = 14, AREA_W = 1020, AREA_H = 700;
+ const AREA_W = 1700, AREA_H = 760, LAB = 44, LANGW = 60, GAP = 18;
const RH = (AREA_H - LAB - GAP * (SHOT_LANGS.length - 1)) / SHOT_LANGS.length;
- const FW = 280, FH = FW * DIMS.feature[1] / DIMS.feature[0];
- const widths = SHOT_SIZES.map(sz => (sz.id === 'feature' ? FW : RH * DIMS[sz.id][0] / DIMS[sz.id][1]));
+ const widths = SHOT_SIZES.map(sz => RH * DIMS[sz.id][0] / DIMS[sz.id][1]);
const total = LANGW + widths.reduce((a, b) => a + b, 0) + GAP * SHOT_SIZES.length;
- let x = (AREA_W - total) / 2 + LANGW + GAP;
+ const x0 = (AREA_W - total) / 2;
SHOT_LANGS.forEach((lang, j) => {
const el = document.createElement('div');
el.className = 'lang'; el.textContent = lang;
- Object.assign(el.style, { left: `${(AREA_W - total) / 2}px`, top: `${LAB + j * (RH + GAP) + RH / 2 - 10}px` });
+ Object.assign(el.style, { left: `${x0}px`, top: `${LAB + j * (RH + GAP) + RH / 2 - 11}px` });
shotsEl.appendChild(el); langLabels.push(el);
});
+ let x = x0 + LANGW + GAP;
SHOT_SIZES.forEach((sz, i) => {
- const w = widths[i], h = sz.id === 'feature' ? FH : RH;
+ const w = widths[i];
const lab = document.createElement('div');
lab.className = 'lab'; lab.textContent = sz.label;
Object.assign(lab.style, { left: `${x + w / 2}px`, top: '0px', transform: 'translateX(-50%)' });
@@ -194,269 +240,194 @@ const shotTiles = [], shotLabels = [], langLabels = [];
SHOT_LANGS.forEach((lang, j) => {
const el = document.createElement('div');
el.className = 'tile';
- Object.assign(el.style, { left: `${x}px`, top: `${LAB + j * (RH + GAP) + (RH - h) / 2}px`, width: `${w}px`, height: `${h}px`,
- backgroundImage: `url(out/assets/shots/${lang}-${sz.id}.jpg)`, borderRadius: sz.id === 'feature' ? '6px' : '9px' });
+ Object.assign(el.style, { left: `${x}px`, top: `${LAB + j * (RH + GAP)}px`, width: `${w}px`, height: `${RH}px`,
+ backgroundImage: `url(out/assets/shots/${lang}-${sz.id}.jpg)`, borderRadius: sz.id === 'feature' ? '6px' : '10px' });
shotsEl.appendChild(el);
shotTiles.push({ el, col: i, row: j });
});
x += w + GAP;
});
}
-function store(t) {
- const s = scene('store'); if (t < s.start - 1 || t > s.end + 1) return;
+function shots(t) {
+ const s = scene('shots'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start;
- appear($('#copy'), r, 0.0, 0.4, 14);
- copyEls.forEach((el, i) => appear(el, r, COPY_AT + i * COPY_STEP, 0.4, 10));
- langLabels.forEach((el, j) => appear(el, r, SHOTS_AT - 0.2 + j * 0.05, 0.3, 0));
- shotLabels.forEach(({ el, col }) => { const p = prog(r, SHOTS_AT + col * SHOTS_STEP, 0.35); set(el, 'opacity', String(eoc(p))); });
+ langLabels.forEach(el => appear(el, r, 0, 0.2, 0));
+ shotLabels.forEach(({ el, col }) => set(el, 'opacity', String(eoc(prog(r, SHOTS_AT + col * SHOTS_STEP, 0.25)))));
for (const { el, col, row } of shotTiles) {
- const p = prog(r, SHOTS_AT + col * SHOTS_STEP + row * 0.06, 0.4);
+ const p = prog(r, SHOTS_AT + col * SHOTS_STEP + row * 0.05, 0.28);
set(el, 'opacity', String(eoc(p)));
- set(el, 'transform', `scale(${lerp(0.9, 1, eoc(p)).toFixed(4)})`);
+ set(el, 'transform', `scale(${lerp(0.86, 1, eoc(p)).toFixed(4)})`);
}
- const done = SHOTS_AT + SHOT_SIZES.length * SHOTS_STEP + 0.2;
- pillAt(storePill, r, done);
- pillAt(shotCmd, r, done + 0.2, 'translateX(-50%)');
}
-// ---------- 5 products ----------
-const prodBody = $('#prodterm .body'), prodPill = $('#prod-pill');
-const prodLine = l => (l.cmd ? `$ ${esc(l.text.replace(/^\$ /, ''))}`
- : tagged(l.text).replace(/^ok {4}/, 'ok ').replace(/(\d+ regions?|\d+ region prices)/, '$1')
- .replace(/(0 mismatch\(es\))/, '$1'));
-prodBody.innerHTML = PRODUCT_LINES.map((l, i) => `${i && l.cmd ? ' ' : ''}${prodLine(l)}`).join('');
-const prodLns = [...prodBody.querySelectorAll('.ln')].filter(el => el.style.opacity !== '1');
-prodPill.innerHTML = bold(PRODUCTS_PILL.text);
-function products(t) {
- const s = scene('products'); if (t < s.start - 1 || t > s.end + 1) return;
- const r = t - s.start;
- appear($('#prodterm'), r, 0.0, 0.4, 14);
- prodLns.forEach((el, i) => appear(el, r, PRODUCT_LINES[i].at, 0.3, 6));
- pillAt(prodPill, r, PRODUCTS_PILL.at);
-}
-
-// ---------- 6 forms ----------
-const propBody = $('#prop .body'), formBody = $('#form .body'), formsPill = $('#forms-pill');
+// forms: the declarations proposal, then the Data safety rows it fills
+const propBody = $('#prop .body'), formBody = $('#form .body'), formCount = $('#form .count');
propBody.innerHTML = DECL_LINES.map(d => `detected ${esc(d.what)}:(${esc(d.where)})${esc(d.value)}`).join('')
- + `ask owner ${esc(DECL_ASK.replace(/^ask owner /, '').split(':')[0])}:${esc(DECL_ASK.replace(/^ask owner [^:]+:/, ''))}`
- + 'Only you know this one: storebound asks.';
-const propLns = [...propBody.querySelectorAll('.ln')], askGlow = $('#prop .ask .glow'), askNote = $('#prop .askn');
-formBody.innerHTML = DATA_SAFETY_ROWS.map(r => `${esc(r)}detected
`).join('');
+ + `ask owner ${esc(DECL_ASK.replace(/^ask owner /, '').split(':')[0])}:${esc(DECL_ASK.replace(/^ask owner [^:]+:/, ''))}`;
+const propLns = [...propBody.querySelectorAll('.ln')], askGlow = $('#prop .ask .glow');
+formBody.innerHTML = DATA_SAFETY_ROWS.map(r => `${esc(r)}
`).join('');
const formRows = [...formBody.querySelectorAll('.row')];
function forms(t) {
const s = scene('forms'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start;
- appear($('#prop'), r, 0.0, 0.4, 14);
- appear($('#form'), r, 0.25, 0.4, 14);
- propLns.forEach((el, i) => appear(el, r, i < DECL_LINES.length ? DECL_AT + i * DECL_STEP : ASK_AT, 0.3, 6));
- const g = prog(r, ASK_AT + 0.15, 0.4);
- set(askGlow, 'opacity', String(eoc(g)));
- appear(askNote, r, ASK_AT + 0.4, 0.4, 6);
+ appear($('#prop'), r, -0.1, 0.2, 10);
+ appear($('#form'), r, 0.0, 0.2, 10);
+ propLns.forEach((el, i) => appear(el, r, i < DECL_LINES.length ? DECL_AT + i * DECL_STEP : ASK_AT, 0.2, 6));
+ set(askGlow, 'opacity', String(eoc(prog(r, ASK_AT + 0.1, 0.3))));
formRows.forEach((el, i) => {
- const p = prog(r, FORM_AT + i * FORM_STEP, 0.25);
+ const p = prog(r, FORM_AT + i * FORM_STEP, 0.2);
set(el.querySelector('.fill'), 'opacity', String(eoc(p)));
set(el.querySelector('.pi'), 'opacity', String(eoc(p)));
set(el.querySelector('.box'), 'borderColor', p > 0 ? 'var(--green)' : 'var(--line-2)');
- set(el.querySelector('.tg'), 'opacity', String(p));
set(el, 'color', p > 0.5 ? '#fff' : 'var(--ink-2)');
});
- const np = prog(r, FORMS_PILL.at, 0.8);
- html(formsPill, `${Math.round(DECL_TOTAL * eoc(np))} ${esc(FORMS_PILL.text)}`);
- pillAt(formsPill, r, FORMS_PILL.at);
+ const np = prog(r, FORMS_COUNT_AT, 0.7);
+ html(formCount, `${Math.round(DECL_TOTAL * eoc(np))} answers`);
+ appear(formCount, r, FORMS_COUNT_AT, 0.2, 6);
}
-// ---------- 7 consoles: the console plan in the terminal, then the app's plan panel (DESK.consoles) ----------
-const cpBody = $('#cpterm .body'), cpPill = $('#cp-pill'), cpTerm = $('#cpterm');
+// consoles: the plan in the terminal, then the app's plan panel
+const cpBody = $('#cpterm .body'), cpTerm = $('#cpterm');
const cpLine = l => {
if (l.final) return `${esc(l.text.replace(/: Publish$/, ':'))} Publish`;
if (l.head) return `${esc(l.text)}`;
if (/^Never press:/.test(l.text)) return esc(l.text).replace(/^(Never press:)/, '$1');
- return esc(l.text).replace(/^(\s+\d+\.)/, '$1').replace(/^(\s+)\[ok\]/, '$1[ok]')
- .replace(/^(\s+)\[ask\]/, '$1[ask]').replace(/(\[click: [^\]]+\])/, '$1');
+ return esc(l.text).replace(/^(\s+\d+\.)/, '$1').replace(/^(\s+)\[ok\]/, '$1[ok]').replace(/(\[click: [^\]]+\])/, '$1');
};
-cpBody.innerHTML = `${CONSOLE_LINES.map(l => `${cpLine(l)}`).join('')}`;
-const cpLns = [...cpBody.querySelectorAll('.ln')], cpGlow = $('#cpterm .fin .glow');
-cpPill.innerHTML = bold(CONSOLES_PILL.text);
+cpBody.innerHTML = `${cmdLine(CONSOLE_CMD)}${CONSOLE_LINES.map(l => `${cpLine(l)}`).join('')}`;
+const cpLns = [...cpBody.querySelectorAll('.ln')].slice(1), cpGlow = $('#cpterm .fin .glow');
function consoles(t) {
const s = scene('consoles'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start;
const out = 1 - eio(prog(r, CONSOLES_SWAP[0], CONSOLES_SWAP[1]));
- appear(cpTerm, r, 0.0, 0.35, 14);
- set(cpTerm, 'opacity', String(eoc(prog(r, 0, 0.35)) * out));
- const n = clamp(Math.floor((r - CONSOLE_TYPE_AT) * CONSOLE_CPS), 0, CONSOLE_CMD.length);
- html(cpLns[0], `$ ${esc(CONSOLE_CMD.slice(0, n))}${n < CONSOLE_CMD.length ? '' : ''}`);
- set(cpLns[0], 'opacity', '1');
- cpLns.slice(1).forEach((el, i) => appear(el, r, CONSOLE_LINES[i].at, 0.25, 6));
- set(cpGlow, 'opacity', String(eoc(prog(r, CONSOLE_LINES.at(-1).at + 0.15, 0.4))));
- pillAt(cpPill, r, CONSOLES_PILL.at);
- set(cpPill, 'opacity', String(eoc(prog(r, CONSOLES_PILL.at, 0.4)) * out));
+ set(cpTerm, 'opacity', String(eoc(prog(r, -0.1, 0.2)) * out));
+ cpLns.forEach((el, i) => appear(el, r, CONSOLE_LINES[i].at, 0.15, 5));
+ set(cpGlow, 'opacity', String(eoc(prog(r, CONSOLE_LINES.at(-1).at + 0.08, 0.2))));
}
-// ---------- 8 rejections ----------
+// ---------- 6 scan: every finding turns green, the re-scan is clean ----------
const scanBody = $('#scanterm .body');
-scanBody.innerHTML = `$ ${esc(SCAN_CMD)}`
- + `${esc(RISKS_LINE).replace(/(2 blocker, 3 high)/, '$1')}`
- + FINDINGS.map((f, i) => `
-
${i + 1}.${f.sev}${esc(f.guideline)}${esc(f.title)}
-
${esc(f.why)}at ${esc(f.where)}FIXED
`).join('')
- + `$ ${esc(SCAN_CMD)} (after the fixes)`
- + `${esc(RESCAN_LINE)}`;
+scanBody.innerHTML = `${cmdLine(SCAN_CMD)}`
+ + `${esc(RISKS_LINE).replace(/(2 blocker, 3 high)/, '$1')}`
+ + FINDINGS.map(f => {
+ const m = f.match(/^(\S+)(\s+)(.*)$/);
+ return `${m[1]}${m[2]}${esc(m[3])}
`;
+ }).join('')
+ + `${cmdLine(SCAN_CMD)} (after the fixes)`
+ + `${esc(RESCAN_LINE)}`;
const scanLns = [...scanBody.querySelectorAll(':scope > .ln')], fdEls = [...scanBody.querySelectorAll('.fd')];
-function rejections(t) {
- const s = scene('rejections'); if (t < s.start - 1 || t > s.end + 1) return;
+function scan(t) {
+ const s = scene('scan'); if (t < s.start - 1 || t > s.end + 1) return;
const r = t - s.start;
- appear($('#scanterm'), r, 0.0, 0.4, 14);
- appear(scanLns[0], r, 0.2, 0.25, 4);
- appear(scanLns[1], r, 0.45, 0.3, 6);
+ appear($('#scanterm'), r, -0.1, 0.2, 10);
+ appear(scanLns[0], r, 0.05, 0.15, 4);
+ appear(scanLns[1], r, 0.25, 0.2, 6);
fdEls.forEach((el, i) => {
- appear(el, r, FINDINGS[i].at, 0.4, 10);
- const f = prog(r, FIX_AT[i], 0.45);
+ appear(el, r, FINDINGS_AT + i * FINDINGS_STEP, 0.25, 8);
+ const f = prog(r, FIX_AT[i], 0.3);
set(el.querySelector('.tint.g'), 'opacity', String(eoc(f)));
set(el.querySelector('.tint.b'), 'opacity', String(1 - eoc(f)));
- set(el.querySelector('.fixed'), 'opacity', String(eoc(f)));
- set(el.querySelector('.why .pi'), 'color', f > 0.5 ? 'var(--green)' : 'var(--red)');
+ const ic = el.querySelector('.pi');
+ if (f > 0.4) { ic.className = 'pi i-ok'; set(ic, 'color', 'var(--green)'); } else { ic.className = 'pi i-stop'; set(ic, 'color', 'var(--red)'); }
+ set(el.querySelector('.tx'), 'color', f > 0.4 ? '#d3f9e2' : '#ffd6d7');
});
- appear(scanLns[2], r, RESCAN_AT, 0.3, 6);
- appear(scanLns[3], r, RESCAN_AT + 0.35, 0.3, 6);
+ appear(scanLns[2], r, RESCAN_AT - 0.25, 0.2, 6);
+ appear(scanLns[3], r, RESCAN_AT, 0.25, 6);
}
-// ---------- 7, 9-13 desktop: real frames in a window, one push per scene, callouts, hover sweeps, a resting pointer ----------
+// ---------- desktop: real frames in a window, one push per scene, rings, the pointer ----------
const BOXES = await fetch('out/desktop/boxes.json').then(r => { if (!r.ok) throw new Error('out/desktop/boxes.json missing (bun media/teaser/desktop.ts)'); return r.json(); });
-const WIN = { x: 370, y: 236, w: 1180, bar: 40 };
-const VIEW = { w: WIN.w, h: WIN.w * 800 / 1280 };
-WIN.h = VIEW.h + WIN.bar;
-const S0 = VIEW.w / 1280;
-const sweepOf = g => {
- if (!g.sweep) return null;
- const f = BOXES.sweeps?.[g.sweep.seq];
- if (!f?.length) throw new Error(`boxes.json: no "${g.sweep.seq}" sweep (bun media/teaser/desktop.ts)`);
- return f;
-};
+const WINS = { consoles: { x: 420, y: 80, w: 1080 }, button: { x: 370, y: 250, w: 1180 }, waiting: { x: 370, y: 250, w: 1180 } };
const desks = DESK_SCENES.map(id => {
const d = DESK[id], cam = $(`#s-${id} > .cam`);
- const groups = d.groups.map(g => ({ g, sweep: sweepOf(g) }));
- // the window lives in the scene's .dhost (the consoles scene has its terminal next to it), else in the camera
+ const WIN = { ...WINS[id], bar: 40 };
+ WIN.vh = WIN.w * 800 / 1280; WIN.h = WIN.vh + WIN.bar; WIN.s0 = WIN.w / 1280;
const host = cam.querySelector('.dhost') ?? cam.appendChild(Object.assign(document.createElement('div'), { className: 'dhost' }));
host.innerHTML = `
storebound
-
${groups.map((x, gi) => `

${(x.sweep ?? []).map(f => `

`).join('')}
`).join('')}
- ${d.callouts.map(c => `${esc(c.text)}
`).join('')}
- ${groups.some(x => x.sweep) || d.pointer ? '' : ''}`;
- if (d.pointer && !BOXES[d.pointer.target]) throw new Error(`boxes.json: no "${d.pointer.target}" (bun media/teaser/desktop.ts)`);
+ ${d.groups.map((g, gi) => `
`).join('')}
' : ''}`;
+ for (const k of [...d.rings.map(c => c.target), d.pointer?.target].filter(Boolean)) if (!BOXES[k]) throw new Error(`boxes.json: no "${k}" (bun media/teaser/desktop.ts)`);
const grpEls = [...host.querySelectorAll('.grp')];
- return { id, d, host, groups, cam2: host.querySelector('.cam2'), grpEls, imgs: grpEls.map(g => [...g.querySelectorAll('img')]),
- rings: [...host.querySelectorAll('.ring')], callouts: [...host.querySelectorAll('.callout')], cur: host.querySelector('.dcur') };
+ return { id, d, WIN, host, cam2: host.querySelector('.cam2'), grpEls, rings: [...host.querySelectorAll('.ring')], cur: host.querySelector('.dcur') };
});
function deskView(dk, r) {
- const s = scene(dk.id), len = s.end - s.start;
- const p = ecam(clamp((r + DISSOLVE / 2) / (len + DISSOLVE)));
+ const s = scene(dk.id), len = s.end - s.start, { WIN } = dk;
+ const p = dk.id === 'consoles' ? ecam(clamp(r / len)) : esin(clamp(r / len));
const { from, to } = dk.d.cam;
- const S = S0 * lerp(from.z, to.z, p), fx = lerp(from.f[0], to.f[0], p), fy = lerp(from.f[1], to.f[1], p);
- const ox = clamp(VIEW.w / 2 - fx * 1280 * S, VIEW.w - 1280 * S, 0), oy = clamp(VIEW.h / 2 - fy * 800 * S, VIEW.h - 800 * S, 0);
+ const S = WIN.s0 * lerp(from.z, to.z, p), fx = lerp(from.f[0], to.f[0], p), fy = lerp(from.f[1], to.f[1], p);
+ const ox = clamp(WIN.w / 2 - fx * 1280 * S, WIN.w - 1280 * S, 0), oy = clamp(WIN.vh / 2 - fy * 800 * S, WIN.vh - 800 * S, 0);
return { S, ox, oy };
}
-const stageBox = (b, c) => ({ x: WIN.x + c.ox + b.x * 1280 * c.S, y: WIN.y + WIN.bar + c.oy + b.y * 800 * c.S, w: b.w * 1280 * c.S, h: b.h * 800 * c.S });
-/** A hover sweep at r: the pointer glides from the first hovered day's position to the last one's (ease in-out); the
- * frame is the last day whose position the pointer has reached, as on the real chart. */
-function sweepAt(sw, f, r) {
- const p = eio(prog(r, sw.at, sw.d));
- const px = lerp(f[0].px, f.at(-1).px, p), py = lerp(f[0].py, f.at(-1).py, p);
- let k = -1;
- if (r >= sw.at) { k = 0; while (k + 1 < f.length && f[k + 1].px <= px + 1e-9) k++; }
- return { px, py, k };
-}
+const stageBox = (W0, b, c) => ({ x: W0.x + c.ox + b.x * 1280 * c.S, y: W0.y + W0.bar + c.oy + b.y * 800 * c.S, w: b.w * 1280 * c.S, h: b.h * 800 * c.S });
function deskScene(dk, t) {
const s = scene(dk.id); if (t < s.start - 1 || t > s.end + 1) return;
- const r = t - s.start, d = dk.d;
- // the window fades in (show) and out (hide), with its callouts and pointer
- const shown = (d.show ? eio(prog(r, d.show[0], d.show[1])) : 1) * (d.hide ? 1 - eio(prog(r, d.hide[0], d.hide[1])) : 1);
+ const r = t - s.start, d = dk.d, WIN = dk.WIN;
+ const shown = d.show ? eio(prog(r, d.show[0], d.show[1])) : 1;
set(dk.host, 'opacity', String(shown));
set(dk.host, 'visibility', shown > 0 ? 'visible' : 'hidden');
const c = deskView(dk, r);
set(dk.cam2, 'transform', `translate(${c.ox.toFixed(2)}px, ${c.oy.toFixed(2)}px) scale(${c.S.toFixed(5)})`);
- const sw = d.swap ? eio(prog(r, d.swap[0], d.swap[1] - d.swap[0])) : 0;
- if (dk.grpEls[1]) set(dk.grpEls[1], 'opacity', String(sw));
- // the pointer: fades in just before its group's sweep, rests after it, fades with the dissolve to the next group
- let cur = null;
- dk.groups.forEach((x, gi) => {
- if (!x.sweep) return;
- const st = sweepAt(x.g.sweep, x.sweep, r);
- dk.imgs[gi].forEach((im, i) => set(im, 'opacity', i === 0 || i === st.k + 1 ? '1' : '0'));
- const vis = gi === 0 && d.swap ? 1 - prog(r, d.swap[0] - 0.2, 0.25) : 1;
- const op = prog(r, x.g.sweep.at - 0.35, 0.3) * vis;
- if (op > 0 && (!cur || op > cur.op)) cur = { ...st, op };
- });
- // a resting pointer: glides in from below right onto its target and stays there (it never clicks)
- if (d.pointer) {
+ if (dk.grpEls[1]) set(dk.grpEls[1], 'opacity', String(d.swap ? eio(prog(r, d.swap[0], d.swap[1] - d.swap[0])) : 0));
+ // the pointer: glides in from below right onto its target and rests; a press = a short squeeze
+ if (dk.cur) {
const pt = d.pointer, b = BOXES[pt.target];
const tx = b.x + b.w * 0.62, ty = b.y + b.h * 0.64;
const p = eio(prog(r, pt.at, pt.d));
- const op = prog(r, pt.at - 0.25, 0.25) * (pt.until ? 1 - prog(r, pt.until - 0.1, 0.25) : 1);
- if (op > 0) cur = { px: lerp(Math.min(tx + 0.16, 0.97), tx, p), py: lerp(Math.min(ty + 0.2, 0.97), ty, p), op };
+ const op = prog(r, pt.at - 0.3, 0.3) * (pt.until ? 1 - prog(r, pt.until, 0.3) : 1);
+ const press = pt.click !== undefined ? Math.sin(Math.PI * prog(r, pt.click, 0.16)) : 0;
+ set(dk.cur, 'opacity', String(op));
+ const sb = stageBox(WIN, { x: lerp(Math.min(tx + 0.16, 0.97), tx, p), y: lerp(Math.min(ty + 0.2, 0.97), ty, p), w: 0, h: 0 }, c);
+ set(dk.cur, 'transform', `translate(${(sb.x - 10).toFixed(1)}px, ${(sb.y - 10).toFixed(1)}px) scale(${(1 - press * 0.18).toFixed(3)})`);
}
- if (dk.cur) {
- set(dk.cur, 'opacity', String(cur ? cur.op : 0));
- if (cur) {
- const b = stageBox({ x: cur.px, y: cur.py, w: 0, h: 0 }, c);
- set(dk.cur, 'transform', `translate(${(b.x - 10).toFixed(1)}px, ${(b.y - 10).toFixed(1)}px)`);
- }
- }
- d.callouts.forEach((co, i) => {
- const b = stageBox(BOXES[co.target], c);
+ d.rings.forEach((co, i) => {
+ const b = stageBox(WIN, BOXES[co.target], c);
const op = eoc(prog(r, co.at, 0.4)) * (1 - prog(r, co.until - 0.3, 0.3));
- const ring = dk.rings[i], call = dk.callouts[i];
+ const ring = dk.rings[i];
const x0 = Math.max(b.x - 10, WIN.x + 8), y0 = Math.max(b.y - 10, WIN.y + WIN.bar + 6);
const x1 = Math.min(b.x + b.w + 10, WIN.x + WIN.w - 8), y1 = Math.min(b.y + b.h + 10, WIN.y + WIN.h - 8);
set(ring, 'opacity', String(op));
set(ring, 'left', `${x0.toFixed(1)}px`); set(ring, 'top', `${y0.toFixed(1)}px`);
set(ring, 'width', `${(x1 - x0).toFixed(1)}px`); set(ring, 'height', `${(y1 - y0).toFixed(1)}px`);
- const cw = call.offsetWidth;
- const cx = clamp((x0 + x1) / 2, 60 + cw / 2, W - 60 - cw / 2);
- const below = !co.above && y1 + 90 < WIN.y + WIN.h;
- set(call, 'opacity', String(op));
- set(call, 'left', `${cx.toFixed(1)}px`);
- set(call, 'top', `${(below ? y1 + 24 : y0 - 24).toFixed(1)}px`);
- set(call, 'transform', `translate(-50%, ${below ? '0' : '-100%'}) scale(${lerp(0.92, 1, eoc(prog(r, co.at, 0.4))).toFixed(3)})`);
});
}
const desktop = t => desks.forEach(dk => deskScene(dk, t));
-// ---------- 13 submit: the end card (the app window is DESK.submit; it fades out as the card comes in) ----------
+// ---------- 9 end card ----------
const brand = $('#brand');
-brand.querySelector('.url').textContent = URL;
-brand.querySelector('.plat').textContent = TAGLINE;
-function submit(t) {
- const s = scene('submit');
+brand.querySelector('.line').textContent = END_LINE;
+brand.querySelector('.url').textContent = END_URL;
+const brandEls = [['.mark', END.markAt], ['.word', END.wordAt], ['.line', END.lineAt], ['.url', END.urlAt]].map(([s, at]) => [brand.querySelector(s), at]);
+function end(t) {
+ const s = scene('end'); if (t < s.start - 1) return;
const r = t - s.start;
- if (t < s.start - 1) return;
- [...brand.children].forEach((el, i) => {
- const q2 = prog(r, SUBMIT.logoAt + 0.2 + i * 0.18, SUBMIT.logoIn);
- set(el, 'opacity', String(eoc(q2)));
- set(el, 'transform', `translateY(${((1 - eoc(q2)) * 18).toFixed(1)}px)`);
- });
+ for (const [el, at] of brandEls) appear(el, r, at, END.d, 18);
}
function seek(t) {
scenes(t);
background(t);
- hook(t);
- init(t);
- plan(t);
- store(t);
+ game(t);
+ pile(t);
+ reject(t);
+ ask(t);
+ builds(t);
+ shots(t);
products(t);
forms(t);
consoles(t);
- rejections(t);
+ scan(t);
desktop(t);
- submit(t);
+ end(t);
captions(t);
}
const ready = (async () => {
await document.fonts.ready;
const imgs = [...document.images];
- const bgs = [...document.querySelectorAll('.tile')].map(el => Object.assign(new Image(), { src: el.style.backgroundImage.replace(/^url\("?|"?\)$/g, '') }));
- // a few at a time, each retried: Chrome refuses some of ~90 parallel 2560x1600 decodes under load (EncodingError)
+ const bgs = [...document.querySelectorAll('.tile, .phone')].map(el => Object.assign(new Image(), { src: el.style.backgroundImage.replace(/^url\("?|"?\)$/g, '') }));
+ // a few at a time, each retried: Chrome refuses some of many parallel large decodes under load (EncodingError)
const all = [...imgs, ...bgs];
const decode = async im => {
for (let i = 0; ; i++) {
diff --git a/tests/teaser.test.ts b/tests/teaser.test.ts
index ea76189..b38e708 100644
--- a/tests/teaser.test.ts
+++ b/tests/teaser.test.ts
@@ -1,11 +1,12 @@
-// The teaser video (media/teaser, v7): every CLI line it shows is real output of the command it names, every desktop
-// frame is a real capture of the app (desktop.ts), each scene holds 4-5 s (a whole number of beats), each caption is
-// fully on screen for at least 3 s, the whole cut is 60-65 s, and it is 16:9 only.
+// The teaser video (media/teaser, v8, a story): every CLI line it shows is real output of the command it names, the game
+// screens and screenshot tiles are the demo game's own, every desktop frame is a real capture of the app (desktop.ts),
+// every cut lands on a beat of the music, each caption is short and fully on screen for at least 2.5 s, the whole cut is
+// 56-62 s, and it is 16:9 only.
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { generateKeyPairSync } from 'node:crypto';
-import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
-import { join } from 'node:path';
+import { dirname, join } from 'node:path';
import { ALPHA3 } from '../cli/lib/money';
import { SIZES } from '../cli/shots/lib';
@@ -14,25 +15,25 @@ const CLI = join(ROOT, 'cli', 'index.ts');
const FX = join(import.meta.dir, 'fixtures', 'scan');
const MOCK = join(import.meta.dir, 'helpers', 'teaser-store-mock.ts');
-interface Scene { id: string; start: number; end: number }
+interface Scene { id: string; start: number; end: number; x: number }
+type Line = { at: number; text: string; cmd?: boolean; head?: boolean; final?: boolean };
interface Teaser {
- FORMAT: { w: number; h: number }; DURATION: number; SCENES: Scene[]; DISSOLVE: number;
- CAPTIONS: { id: string; lines: string[] }[];
- captionTimes(): { id: string; text: string; at: number; from: number; to: number; hold: number }[];
- TODO: { text: string }[]; MAIL: { at: number; subject: string; guideline: string }; MAIL_IN: number;
- INIT_CMD: string; INIT_LINES: string[];
- PLAN_CMD: string; PLAN_LINES: string[]; PLAN_STEPS: number; PLAN_PILL: string;
- COPY_FILE: string; COPY: { lang: string; title: string[] }[]; SHOT_SIZES: { id: string }[]; SHOT_LANGS: string[]; SHOTS_PILL: string;
- PRODUCT_LINES: { text: string; cmd?: boolean }[]; PRODUCTS_PILL: { text: string };
- DECL_LINES: { what: string; value: string; where: string }[]; DECL_ASK: string; DECL_TOTAL: number;
- CONSOLE_CMD: string; CONSOLE_LINES: { text: string; head?: boolean; final?: boolean }[]; CONSOLE_STEPS: number; CONSOLE_ANSWERS: number;
- CONSOLES_PILL: { text: string }; CONSOLES_SWAP: number[];
- SCAN_CMD: string; RISKS_LINE: string; RESCAN_LINE: string;
- FINDINGS: { sev: string; guideline: string; title: string; where: string; why: string }[];
- DESK: Record