// Teaser picture: every element is set from the time t alone (window.teaser.seek(t)), so frames render // deterministically, in any order. URL: ?w=1080&h=1920 (or 1920x1080) · &t=12.5 to hold a frame · &play=1 to preview. import { BAR, BEAT, BLOCKERS, BLOCKERS_AT, BLOCKER_GAP, CMD_CPS, CUTS, DURATION, FIX_AT, FIX_GAP, FPS, LANGS, PRICES, PROMPT, PROMPT_AT, PROMPT_CPS, SCENES, TERMINAL, scene } from './script.js'; const q = new URLSearchParams(location.search); const W = Number(q.get('w') ?? 1080), H = Number(q.get('h') ?? 1920); const V = H > W; const $ = s => document.querySelector(s); const stage = $('#stage'); stage.style.width = `${W}px`; stage.style.height = `${H}px`; document.body.classList.add(V ? 'v' : 'h'); // ---------- easing ---------- const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x)); const prog = (t, a, d) => clamp((t - a) / d); const eoc = p => 1 - Math.pow(1 - p, 3); const eic = p => p * p * p; const eob = p => { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(p - 1, 3) + c1 * Math.pow(p - 1, 2); }; const lerp = (a, b, p) => a + (b - a) * p; const esc = s => s.replace(/&/g, '&').replace(//g, '>'); const sceneAt = t => SCENES.find(s => t >= s.start && t < s.end) ?? SCENES[SCENES.length - 1]; // ---------- background ---------- const g1 = $('#bg .g1'), g2 = $('#bg .g2'), g3 = $('#bg .g3'), grid = $('#bg .grid'), flash = $('#flash'); function background(t) { let pulse = 0; for (const c of CUTS) if (t >= c) pulse = Math.max(pulse, 1 - prog(t, c, 0.6)); const beat = 1 - prog((t % BEAT), 0, BEAT * 0.6); g1.style.transform = `translate(${W * 0.15 + Math.sin(t * 0.31) * 140 - 450}px, ${H * 0.28 + Math.cos(t * 0.23) * 120 - 450}px) scale(${1 + pulse * 0.18})`; g2.style.transform = `translate(${W * 0.85 + Math.cos(t * 0.27) * 160 - 380}px, ${H * 0.72 + Math.sin(t * 0.35) * 140 - 380}px)`; g3.style.transform = `translate(${W * 0.7 + Math.sin(t * 0.19) * 200 - 320}px, ${H * 0.1 + Math.cos(t * 0.29) * 100 - 320}px)`; g1.style.opacity = String(0.8 + 0.2 * beat * (t > SCENES[1].start ? 1 : 0.3)); grid.style.transform = `translate(${(t * 18) % 64}px, ${(t * 26) % 64}px)`; let f = 0; for (const c of CUTS) if (t >= c && t < c + 0.14) f = Math.max(f, (c === scene('brand').start || c === scene('submit').start ? 0.45 : 0.2) * (1 - (t - c) / 0.14)); flash.style.opacity = String(f); } // ---------- captions ---------- // lines: array of [text, at] ; words wrapped in *...* are highlighted. at = seconds after the caption start. const CAPS = [ { id: 'prompt', lines: [['One prompt.', 0.15], ['Two *stores.*', 1.35]] }, { id: 'config', lines: [['One *config.*', 0.05]], sub: ['storebound.config.ts + store/*.md', 0.55] }, { id: 'prices', lines: [['IAP *prices.*', 0.05], ['Every region.', 0.55]] }, { id: 'languages', lines: [['Listings in', 0.05], ['*14* languages.', 0.45]] }, { id: 'questionnaires', lines: [['Every', 0.05], ['*questionnaire.*', 0.3]], sub: ['Data safety · age rating · App Privacy', 0.9] }, { id: 'shots', lines: [['Store art,', 0.05], ['*rendered.*', 0.4]], sub: ['real renders · Prism Puddle demo', 1.0] }, { id: 'validate', lines: [['Blockers', 0.05], ['#count', 0.3]], sub: ['read-only check · in fix order', 0.8] }, ]; const capEl = $('#caption'); let capId = null, capWords = [], capSub = null, capCount = null; function buildCaption(c) { const base = V ? 112 : 108; capEl.style.fontSize = `${base}px`; capEl.innerHTML = c.lines.map(([text]) => text === '#count' ? '7' : `${text.split(' ').map(w => w.startsWith('*') ? `${esc(w.replace(/\*/g, ''))}` : `${esc(w)}`).join(' ')}`).join('') + (c.sub ? `${esc(c.sub[0])}` : ''); // fit: the widest line must fit the caption box const avail = capEl.clientWidth; const widest = Math.max(...[...capEl.querySelectorAll('.line')].map(l => l.scrollWidth)); if (widest > avail) capEl.style.fontSize = `${Math.floor(base * avail / widest)}px`; capWords = []; [...capEl.querySelectorAll('.line')].forEach((l, i) => { [...l.querySelectorAll('.w')].forEach((w, j) => capWords.push({ el: w, at: c.lines[i][1] + j * 0.07 })); }); capSub = c.sub ? { el: capEl.querySelector('.sub'), at: c.sub[1] } : null; capCount = capEl.querySelector('[data-count]'); } function captions(t) { const s = sceneAt(t); const c = CAPS.find(x => x.id === s.id); if (!c) { capEl.style.opacity = '0'; return; } if (capId !== c.id) { capId = c.id; buildCaption(c); } const r = t - s.start; const out = prog(t, s.end - 0.16, 0.16); capEl.style.opacity = String(1 - out); capEl.style.transform = `translateY(${-50 * eic(out)}px)`; for (const w of capWords) { const p = prog(r, w.at, 0.3); w.el.style.opacity = String(clamp(p * 4)); w.el.style.transform = `translateY(${(1 - eob(p)) * 0.45}em) scale(${1 + 0.3 * (1 - eoc(p))})`; } if (capSub) { const p = prog(r, capSub.at, 0.3); capSub.el.style.opacity = String(p); capSub.el.style.transform = `translateY(${(1 - eoc(p)) * 20}px)`; } if (capCount) { const n = BLOCKERS.length - fixedCount(r); capCount.textContent = n === 0 ? '0' : String(n); capCount.classList.toggle('g', n === 0); const last = n < BLOCKERS.length ? FIX_AT + (BLOCKERS.length - n - 1) * FIX_GAP : -9; const pop = 1 - prog(r, last, 0.18); const base = prog(r, 0.3, 0.3); capCount.style.transform = `translateY(${(1 - eob(base)) * 0.45}em) scale(${(1 + 0.3 * (1 - eoc(base))) * (1 + (n === 0 ? 0.35 : 0.18) * pop)})`; capCount.style.opacity = String(clamp(base * 4)); capCount.style.color = n === 0 ? 'var(--green)' : '#f87171'; } } const fixedCount = r => r < FIX_AT ? 0 : Math.min(BLOCKERS.length, Math.floor((r - FIX_AT) / FIX_GAP) + 1); // ---------- terminal ---------- const term = $('#term'), pin = $('#term .pin'), linesEl = $('#term .lines'); const TERM_SCENES = ['prompt', 'config', 'prices', 'languages', 'questionnaires', 'shots', 'validate']; const PANEL_FOR = { prices: '#prices', languages: '#langs', questionnaires: '#quest' }; const cursor = ''; const fmtOut = text => { const m = text.match(/^\[(\w+)\](.*)$/s); if (!m) return esc(text); let rest = esc(m[2]).replace(/\(dry\)/g, '(dry)'); return `[${m[1]}]${rest}`; }; let lastLines = '', lastPin = '', panelTopCache = null; function terminal(t) { const s = sceneAt(t); const on = TERM_SCENES.includes(s.id); if (!on) { term.style.opacity = '0'; return; } const r = t - s.start; // enter + punch on every cut const enter = s.id === 'prompt' ? eoc(prog(t, 0, 0.35)) : 1; const punch = s.id === 'prompt' ? 0 : 1 - eoc(prog(r, 0, 0.28)); const dim = s.id === 'shots' ? 0.72 * eoc(prog(r, 0.35, 0.35)) : 0; term.style.opacity = String(enter * (1 - dim)); term.style.transform = `translateY(${(1 - enter) * 60}px) scale(${(0.94 + 0.06 * enter) * (1 + 0.035 * punch)})`; // pinned user prompt const n = clamp(Math.floor((t - PROMPT_AT) * PROMPT_CPS), 0, PROMPT.length); const typing = n < PROMPT.length; const blink = Math.floor(t * 2.6) % 2 === 0; const pinHtml = `> ${esc(PROMPT.slice(0, n))}${s.id === 'prompt' && (typing || blink) ? cursor : ''}`; if (pinHtml !== lastPin) { pin.innerHTML = pinHtml; lastPin = pinHtml; } // scene lines const out = []; let busy = false; for (const l of TERMINAL[s.id] ?? []) { if (r < l.at) break; if (l.kind === 'cmd') { const k = Math.min(l.text.length, Math.floor((r - l.at) * CMD_CPS) + 1); busy = k < l.text.length; out.push(`
$ ${esc(l.text.slice(0, k))}${busy ? cursor : ''}
`); } else if (l.kind === 'note') out.push(`
${fmtOut(l.text)}
`); else out.push(`
${fmtOut(l.text)}
`); } if (s.id === 'validate') { const fixed = fixedCount(r); BLOCKERS.forEach(([store, msg], i) => { if (r < BLOCKERS_AT + i * BLOCKER_GAP) return; const ok = i < fixed; out.push(`
${String(i + 1).padStart(2)}. ${ok ? 'ok ' : 'BLOCKER'} [${store}] ${esc(msg)}
`); }); const winAt = FIX_AT + BLOCKERS.length * FIX_GAP + 0.05; if (r >= winAt) out.push('
[validate] no blockers
'); if (r >= winAt + 0.2) out.push('
(UI-only steps are not visible to the API: App Privacy, Play App content)
'); } const last = out.length ? out[out.length - 1] : ''; const idle = !busy && s.id !== 'prompt' && (blink || r < 0.05); const html = out.join('') + (idle && !last.includes('cur') ? `
${cursor}
` : ''); // visible height: up to the panel when one is shown const panel = PANEL_FOR[s.id]; const body = linesEl.parentElement; const top = term.offsetTop + body.offsetTop + linesEl.offsetTop; const bottom = panel && r > 0.4 ? panelTop(panel) - 14 : term.offsetTop + term.offsetHeight - 22; const lh = parseFloat(getComputedStyle(linesEl).lineHeight) || 40; linesEl.style.height = `${Math.max(lh, Math.floor((bottom - top) / lh) * lh)}px`; if (html !== lastLines) { linesEl.innerHTML = html; lastLines = html; } linesEl.scrollTop = linesEl.scrollHeight; } function panelTop(sel) { panelTopCache ??= {}; if (panelTopCache[sel] === undefined) { const el = $(sel); panelTopCache[sel] = parseFloat(getComputedStyle(el).top); } return panelTopCache[sel]; } // ---------- panels ---------- const pricesEl = $('#prices'), langsEl = $('#langs'), questEl = $('#quest'); langsEl.querySelector('.chips').innerHTML = LANGS.map(l => `${l}`).join(''); const chips = [...langsEl.querySelectorAll('.chip')]; const QUEST = [ ['Play Data safety', 'the whole form, from the config', 'api'], ['App Store age rating', 'categories, content rights too', 'api'], ['Export compliance', 'your answer, set once in the config', 'api'], ['Play content rating (IARC)', 'the skill walks your agent through it', 'ui'], ['App Privacy labels + Publish', 'the skill walks your agent through it', 'ui'], ['The answers themselves', 'your statements · the agent asks', 'you'], ]; const ICON = { api: '', ui: '', you: '', }; const BG = { api: 'var(--api-bg)', ui: 'var(--ui-bg)', you: 'var(--you-bg)' }; questEl.querySelector('.rows').innerHTML = QUEST.map(([nm, ds, k]) => `
${ICON[k]}
${esc(nm)}
${esc(ds)}
${k.toUpperCase()}
`).join(''); const qrows = [...questEl.querySelectorAll('.row')]; const tbody = pricesEl.querySelector('tbody'), psum = pricesEl.querySelector('.sum'); let lastPrices = ''; function panelIn(el, r, at) { const p = prog(r, at, 0.32); el.style.opacity = String(clamp(p * 2)); el.style.transform = `translateY(${(1 - eob(p)) * 90}px) scale(${0.96 + 0.04 * eoc(p)})`; } function panels(t) { const s = sceneAt(t), r = t - s.start; for (const el of [pricesEl, langsEl, questEl]) el.style.opacity = '0'; if (s.id === 'prices') { panelIn(pricesEl, r, 0.45); const flick = r < 3.05; const k = flick ? Math.max(0, Math.floor((r - 0.7) / 0.085)) : 0; const rows = Array.from({ length: 5 }, (_, i) => PRICES[(k + i) % PRICES.length]); const html = rows.map(([reg, cur, a, b], i) => `${reg}${reg === 'TR' ? 'OVERRIDE' : ''}${cur}${a}${b}`).join(''); if (html !== lastPrices) { tbody.innerHTML = html; lastPrices = html; } psum.textContent = flick ? `${String(Math.min(173, Math.max(1, Math.floor((r - 0.7) * 72)))).padStart(3)} / 173 regions` : 'Play: 173 regions · App Store: equalized from the USA'; } else if (s.id === 'languages') { panelIn(langsEl, r, 0.45); let n = 0; chips.forEach((c, i) => { const at = 0.8 + i * 0.13; const on = r >= at; if (on) n++; c.classList.toggle('on', on); const p = prog(r, at, 0.2); c.style.transform = `scale(${on ? 1 + 0.25 * (1 - eoc(p)) : 1})`; }); langsEl.querySelector('.count').textContent = `${n}/14`; } else if (s.id === 'questionnaires') { panelIn(questEl, r, 0.3); qrows.forEach((row, i) => { const p = prog(r, 0.55 + i * 0.36, 0.28); row.style.opacity = String(p); row.style.transform = `translateX(${(1 - eob(p)) * 60}px)`; const ico = row.querySelector('.ico'); ico.style.transform = `scale(${p >= 1 ? 1 + 0.3 * (1 - eoc(prog(r, 0.55 + i * 0.36 + 0.28, 0.2))) : eob(p)})`; }); } } // ---------- store art ---------- const shotsEl = $('#shots'), sizesEl = $('#sizes'); const FAN = ['en-01', 'en-02', 'en-03', 'en-04']; const BIG = [['en-01', 'English'], ['tr-01', 'Türkçe'], ['ja-01', '日本語'], ['ar-01', 'العربية']]; const SIZES = ['Play phone', '7" tablet', '10" tablet', 'iPhone 6.9"', 'iPhone 6.5"', 'iPad 13"', 'feature graphic']; const L = V ? { cx: 540, cy: 1040, w: 300, off: [-330, -110, 110, 330], rot: [-9, -3, 3, 9], dy: [36, 0, 0, 36], bw: 390, bcy: 1000, ly: 1318 } : { cx: 1360, cy: 540, w: 290, off: [-330, -110, 110, 330], rot: [-9, -3, 3, 9], dy: [30, 0, 0, 30], bw: 330, bcy: 515, ly: 790 }; const RATIO = 1920 / 1080; shotsEl.innerHTML = FAN.map(f => `
`).join('') + `
${BIG.map(([f]) => ``).join('')}
` + '
'; sizesEl.innerHTML = SIZES.map(s => `${esc(s)}`).join(''); const fanEls = [...shotsEl.querySelectorAll('.card.fan')], bigEl = shotsEl.querySelector('.card.big'), bigImgs = [...bigEl.querySelectorAll('img')]; const langEl = shotsEl.querySelector('.lang'), sizeEls = [...sizesEl.querySelectorAll('.sz')]; for (const c of fanEls) { c.style.width = `${L.w}px`; c.style.height = `${L.w * RATIO}px`; } bigEl.style.width = `${L.bw}px`; bigEl.style.height = `${L.bw * RATIO}px`; function place(el, cx, cy, w, h, rot, sc, op) { el.style.transform = `translate(${cx - w / 2}px, ${cy - h / 2}px) rotate(${rot}deg) scale(${sc})`; el.style.opacity = String(op); } function shots(t) { const s = sceneAt(t), r = t - s.start; const on = s.id === 'shots'; shotsEl.style.display = on ? '' : 'none'; sizesEl.style.display = on ? '' : 'none'; if (!on) return; const h = L.w * RATIO; const spread = eoc(prog(r, 1.95, 0.35)); fanEls.forEach((el, i) => { const p = prog(r, 0.3 + i * 0.2, 0.5); const e = eob(p); const dir = i < 2 ? -1 : 1; const x0 = L.cx + dir * (V ? 700 : 900), y0 = L.cy + (V ? 1100 : 800); const fx = L.cx + L.off[i] + dir * 70 * spread, fy = L.cy + L.dy[i] + Math.sin(t * 2 + i) * 6; place(el, lerp(x0, fx, e), lerp(y0, fy, e), L.w, h, lerp(dir * 40, L.rot[i] + dir * 4 * spread, e), 1 - 0.12 * spread, clamp(p * 3) * (1 - 0.45 * spread)); }); // big card cycling the hero screenshot through four languages const bp = prog(r, 1.95, 0.42); const bh = L.bw * RATIO; const idx = r < 2.5 ? 0 : r < 2.95 ? 1 : r < 3.4 ? 2 : 3; const sw = [2.5, 2.95, 3.4].map(a => 1 - Math.min(1, Math.abs(r - a) / 0.09)).reduce((a, b) => Math.max(a, b), 0); place(bigEl, L.cx, L.bcy + (1 - eob(bp)) * 500 + Math.sin(t * 1.6) * 5, L.bw, bh, (1 - eoc(bp)) * 12, (0.7 + 0.3 * eob(bp)), clamp(bp * 3)); bigEl.style.transform += ` scaleX(${1 - 0.18 * sw})`; bigImgs.forEach((im, i) => { im.style.opacity = i === idx ? '1' : '0'; }); langEl.textContent = BIG[idx][1]; const lp = prog(r, 2.2, 0.25); langEl.style.opacity = String(lp); langEl.style.left = `${L.cx}px`; langEl.style.top = `${L.ly}px`; langEl.style.transform = `translateX(-50%) scale(${(1 + 0.2 * sw) * (0.6 + 0.4 * eob(lp))})`; // sizes sizeEls.forEach((el, i) => { const at = 3.75 + i * 0.16; const p = prog(r, at, 0.22); el.style.opacity = String(clamp(p * 3)); el.style.transform = `translateY(${(1 - eob(p)) * 30}px) scale(${1 + 0.2 * (1 - eoc(p))})`; el.classList.toggle('on', r >= at); }); } // ---------- end cards ---------- const submitEl = $('#submit'), btn = $('#submit .btn'), ring = $('#submit .ring'), kick = $('#submit .kick'), yours = $('#submit .yours'), fine = $('#submit .fine'), cur = $('#cursor'); const brandEl = $('#brand'); const brandParts = [...brandEl.children]; function pop(el, r, at, dist = 50, d = 0.35) { const p = prog(r, at, d); el.style.opacity = String(clamp(p * 2.5)); el.style.transform = `translateY(${(1 - eob(p)) * dist}px) scale(${0.85 + 0.15 * eob(p)})`; } function endCards(t) { const s = sceneAt(t), r = t - s.start; submitEl.style.opacity = s.id === 'submit' ? '1' : '0'; brandEl.style.opacity = s.id === 'brand' ? '1' : '0'; cur.style.opacity = '0'; if (s.id === 'submit') { pop(kick, r, 0.0, 30); const bp = prog(r, 0.08, 0.42); btn.style.opacity = String(clamp(bp * 3)); const hover = eoc(prog(r, 1.25, 0.2)); btn.style.transform = `scale(${(0.4 + 0.6 * eob(bp)) * (1 + 0.04 * hover)})`; btn.style.filter = `brightness(${1 + 0.18 * hover})`; const rp = ((r - 0.5) % BEAT) / BEAT; ring.style.opacity = r > 0.5 ? String(0.7 * (1 - rp)) : '0'; ring.style.transform = `scale(${1 + 0.12 * eoc(rp)})`; pop(yours, r, 0.95, 70, 0.4); pop(fine, r, 1.55, 30); // the cursor comes close and waits: the click stays yours const b = btn.getBoundingClientRect(); const tx = b.left + b.width * 0.78, ty = b.top + b.height * 0.72; const cp = eoc(prog(r, 0.55, 0.8)); const wob = Math.sin(r * 5) * 4 * prog(r, 1.35, 0.3); cur.style.opacity = String(clamp((r - 0.55) * 4)); cur.style.transform = `translate(${lerp(tx + 380, tx, cp) + wob}px, ${lerp(ty + 420, ty, cp) + wob * 0.6}px)`; } else if (s.id === 'brand') { const [mark, word, tag, url, meta, inst] = brandParts; const mp = prog(r, 0.0, 0.45); mark.style.opacity = String(clamp(mp * 3)); mark.style.transform = `scale(${eob(mp)}) rotate(${(1 - eoc(mp)) * -25}deg)`; pop(word, r, 0.15, 60); pop(tag, r, 0.5, 40); pop(url, r, 0.85, 40); pop(meta, r, 1.05, 30); pop(inst, r, 1.3, 30); const beat = 1 - prog(((r + 1e-6) % BEAT), 0, BEAT * 0.5); url.style.textShadow = `0 0 ${20 + 30 * beat * (r > 0.85 ? 1 : 0)}px rgba(129,140,248,${0.35 + 0.35 * beat})`; } } // ---------- seek ---------- function seek(t) { t = clamp(t, 0, DURATION - 1e-6); background(t); captions(t); terminal(t); panels(t); shots(t); endCards(t); } const ready = (async () => { await document.fonts.ready; await Promise.all([...document.images].map(im => im.decode().catch(() => { throw new Error(`image failed: ${im.src}`); }))); seek(0); })(); window.teaser = { ready, seek, DURATION, FPS, BAR, W, H }; if (q.has('t')) ready.then(() => seek(Number(q.get('t')))); if (q.has('play')) ready.then(() => { const t0 = performance.now(); const loop = () => { seek(((performance.now() - t0) / 1000) % DURATION); requestAnimationFrame(loop); }; loop(); });