storebound/media/teaser/teaser.js

479 lines
28 KiB
JavaScript

// 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';
const q = new URLSearchParams(location.search);
const W = FORMAT.w, H = FORMAT.h;
const $ = s => document.querySelector(s);
const stage = $('#stage');
stage.style.width = `${W}px`; stage.style.height = `${H}px`;
// ---------- helpers ----------
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. */
const ecam = p => 1 - Math.pow(1 - clamp(p / 0.8), 4);
const lerp = (a, b, p) => a + (b - a) * p;
const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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* -> <b>word</b> (pills) */
const bold = s => esc(s).replace(/\*([^*]+)\*/g, '<b>$1</b>');
/** A gentle appear: opacity and a small rise. */
function appear(el, t, at, d = 0.35, rise = 14) {
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)`);
}
// ---------- scenes: dissolves and 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;
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))));
const c = CAMERA[s.id];
const p = ecam(clamp((t - (s.start - DISSOLVE / 2)) / (s.end - s.start + DISSOLVE)));
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];
// ---------- background: a slow drift ----------
const g1 = $('#bg .g1'), g2 = $('#bg .g2'), grid = $('#bg .grid');
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)`);
}
// ---------- caption ----------
const capEl = $('#cap');
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 => `<span class="line">${esc(l).replace(/\*([^*]+)\*/g, '<span class="hl">$1</span>')}</span>`).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 => `<div class="row"><span class="box"></span>${esc(x.text)}</div>`).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… <b>${esc(MAIL.guideline)}</b>`;
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)`);
}
// ---------- 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 `<div class="f"><span class="ic"></span><span>${k >= 0 ? `<span class="d">${esc(f.path.slice(0, k + 1))}</span>` : ''}${esc(f.path.slice(k + 1))}</span></div>`;
}).join('');
const fileEls = [...filesEl.querySelectorAll('.f')];
const tagged = l => esc(l).replace(/^(\[\w+\])/, '<span class="tag">$1</span>');
initBody.innerHTML = `<span class="ln" data-k="p"></span>${INIT_LINES.map(l => `<span class="ln">${tagged(l).replace(/ (create) /, ' <span class="okk">$1</span> ')}</span>`).join('')}`;
const initLns = [...initBody.querySelectorAll('.ln')];
function init(t) {
const s = scene('init'); if (t < s.start - 1 || 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], `<span class="tag">~/${esc(FOLDER)}</span> <span class="ps">$</span> <span class="cmd">${esc(INIT_CMD.slice(0, n))}</span>${caret ? '<span class="caret"></span>' : ''}`);
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);
set(el, 'opacity', String(eoc(p)));
set(el, 'transform', `translateX(${((1 - eoc(p)) * -16).toFixed(1)}px)`);
});
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 `<span class="num">${esc(l)}</span>`;
if (/^ (both stores|App Store|Google Play)/.test(l)) return `<span class="num">${esc(l)}</span>`;
return esc(l).replace(/^(\s+\d+\.) (CONFIG |STORE |BLOCKER)/, (m, a, b) => `<span class="tag">${a}</span> <span class="${b.startsWith('BLOCKER') ? 'bad' : b.startsWith('STORE') ? 'warnk' : 'okk'}">${b}</span>`);
};
planBody.innerHTML = `<span class="ln" data-k="c"></span>${PLAN_LINES.map(l => `<span class="ln">${planLine(l)}</span>`).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 ? '<span class="caret"></span>' : ''}`);
appear(planTerm, r, PLAN_CMD_AT - 0.35, 0.4, 16);
html(planLns[0], `<span class="ps">$</span> <span class="cmd">${esc(PLAN_CMD)}</span>`);
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 => `<div class="row"><span class="lg">${esc(c.lang)}</span><span class="tt${c.rtl ? ' rtl' : ''}">${esc(c.title.join(' '))}</span></div>`).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. */
const DIMS = { ios69: [1320, 2868], ios65: [1284, 2778], ipad13: [2064, 2752], play: [1080, 1920], tab7: [1080, 1920], tab10: [1440, 2560], feature: [1024, 500] };
const shotTiles = [], shotLabels = [], langLabels = [];
{
const LAB = 50, LANGW = 52, GAP = 14, AREA_W = 1020, AREA_H = 700;
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 total = LANGW + widths.reduce((a, b) => a + b, 0) + GAP * SHOT_SIZES.length;
let x = (AREA_W - total) / 2 + LANGW + GAP;
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` });
shotsEl.appendChild(el); langLabels.push(el);
});
SHOT_SIZES.forEach((sz, i) => {
const w = widths[i], h = sz.id === 'feature' ? FH : RH;
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%)' });
shotsEl.appendChild(lab); shotLabels.push({ el: lab, col: i });
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' });
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;
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))); });
for (const { el, col, row } of shotTiles) {
const p = prog(r, SHOTS_AT + col * SHOTS_STEP + row * 0.06, 0.4);
set(el, 'opacity', String(eoc(p)));
set(el, 'transform', `scale(${lerp(0.9, 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 ? `<span class="ps">$</span> <span class="cmd">${esc(l.text.replace(/^\$ /, ''))}</span>`
: tagged(l.text).replace(/^ok {4}/, '<span class="okk">ok</span> ').replace(/(\d+ regions?|\d+ region prices)/, '<span class="num">$1</span>')
.replace(/(0 mismatch\(es\))/, '<span class="okk">$1</span>'));
prodBody.innerHTML = PRODUCT_LINES.map((l, i) => `${i && l.cmd ? '<span class="ln" style="opacity:1">&nbsp;</span>' : ''}<span class="ln">${prodLine(l)}</span>`).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');
propBody.innerHTML = DECL_LINES.map(d => `<span class="ln"><span class="v1"><span class="k">detected</span> <span class="w">${esc(d.what)}</span>:<span class="loc">(${esc(d.where)})</span></span><span class="v2">${esc(d.value)}</span></span>`).join('')
+ `<span class="ln ask"><span class="glow"></span><span class="k">ask owner</span> <span class="w">${esc(DECL_ASK.replace(/^ask owner /, '').split(':')[0])}</span>:${esc(DECL_ASK.replace(/^ask owner [^:]+:/, ''))}</span>`
+ '<span class="askn">Only you know this one: storebound asks.</span>';
const propLns = [...propBody.querySelectorAll('.ln')], askGlow = $('#prop .ask .glow'), askNote = $('#prop .askn');
formBody.innerHTML = DATA_SAFETY_ROWS.map(r => `<div class="row"><span class="box"><span class="fill"></span><span class="pi i-check"></span></span>${esc(r)}<span class="tg">detected</span></div>`).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);
formRows.forEach((el, i) => {
const p = prog(r, FORM_AT + i * FORM_STEP, 0.25);
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, `<b>${Math.round(DECL_TOTAL * eoc(np))}</b> ${esc(FORMS_PILL.text)}`);
pillAt(formsPill, r, FORMS_PILL.at);
}
// ---------- 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');
const cpLine = l => {
if (l.final) return `<span class="glow"></span><span class="k">${esc(l.text.replace(/: Publish$/, ':'))}</span> <span class="w">Publish</span>`;
if (l.head) return `<span class="num">${esc(l.text)}</span>`;
if (/^Never press:/.test(l.text)) return esc(l.text).replace(/^(Never press:)/, '<span class="bad">$1</span>');
return esc(l.text).replace(/^(\s+\d+\.)/, '<span class="tag">$1</span>').replace(/^(\s+)\[ok\]/, '$1<span class="okk">[ok]</span>')
.replace(/^(\s+)\[ask\]/, '$1<span class="warnk">[ask]</span>').replace(/(\[click: [^\]]+\])/, '<span class="loc">$1</span>');
};
cpBody.innerHTML = `<span class="ln" data-k="c"></span>${CONSOLE_LINES.map(l => `<span class="ln${l.final ? ' fin' : ''}">${cpLine(l)}</span>`).join('')}`;
const cpLns = [...cpBody.querySelectorAll('.ln')], cpGlow = $('#cpterm .fin .glow');
cpPill.innerHTML = bold(CONSOLES_PILL.text);
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], `<span class="ps">$</span> <span class="cmd">${esc(CONSOLE_CMD.slice(0, n))}</span>${n < CONSOLE_CMD.length ? '<span class="caret"></span>' : ''}`);
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));
}
// ---------- 8 rejections ----------
const scanBody = $('#scanterm .body');
scanBody.innerHTML = `<span class="ln"><span class="ps">$</span> <span class="cmd">${esc(SCAN_CMD)}</span></span>`
+ `<span class="ln num risk">${esc(RISKS_LINE).replace(/(2 blocker, 3 high)/, '<span class="bad">$1</span>')}</span>`
+ FINDINGS.map((f, i) => `<div class="fd"><span class="tint b"></span><span class="tint g"></span>
<div class="l1"><span class="tag">${i + 1}.</span><span class="sev${f.sev === 'HIGH' ? ' high' : ''}">${f.sev}</span><span class="gl">${esc(f.guideline)}</span><span class="ttl">${esc(f.title)}</span></div>
<div class="why"><span class="pi i-stop"></span>${esc(f.why)}<span class="loc at">at ${esc(f.where)}</span><span class="fixed">FIXED</span></div></div>`).join('')
+ `<span class="ln" id="rescan-c" style="margin-top:14px"><span class="ps">$</span> <span class="cmd">${esc(SCAN_CMD)}</span> <span class="tag">(after the fixes)</span></span>`
+ `<span class="ln okk" id="rescan-r">${esc(RESCAN_LINE)}</span>`;
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;
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);
fdEls.forEach((el, i) => {
appear(el, r, FINDINGS[i].at, 0.4, 10);
const f = prog(r, FIX_AT[i], 0.45);
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)');
});
appear(scanLns[2], r, RESCAN_AT, 0.3, 6);
appear(scanLns[3], r, RESCAN_AT + 0.35, 0.3, 6);
}
// ---------- 7, 9-13 desktop: real frames in a window, one push per scene, callouts, hover sweeps, a resting 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 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 host = cam.querySelector('.dhost') ?? cam.appendChild(Object.assign(document.createElement('div'), { className: 'dhost' }));
host.innerHTML = `<div class="win" style="left:${WIN.x}px;top:${WIN.y}px;width:${WIN.w}px;height:${WIN.h}px">
<div class="bar"><i></i><i></i><i></i><span class="tt">storebound</span></div>
<div class="view"><div class="cam2">${groups.map((x, gi) => `<div class="grp" data-g="${gi}"><img src="out/desktop/${x.g.frame}.jpg" alt="">${(x.sweep ?? []).map(f => `<img src="out/desktop/${f.file}.jpg" alt="" style="opacity:0">`).join('')}</div>`).join('')}</div></div></div>
${d.callouts.map(c => `<div class="ring${c.green ? ' green' : ''}"></div><div class="callout${c.green ? ' green' : ''}">${esc(c.text)}</div>`).join('')}
${groups.some(x => x.sweep) || d.pointer ? '<span class="dcur" aria-hidden="true"><span class="pi i-cursor"></span></span>' : ''}`;
if (d.pointer && !BOXES[d.pointer.target]) throw new Error(`boxes.json: no "${d.pointer.target}" (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') };
});
function deskView(dk, r) {
const s = scene(dk.id), len = s.end - s.start;
const p = ecam(clamp((r + DISSOLVE / 2) / (len + DISSOLVE)));
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);
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 };
}
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);
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) {
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 };
}
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);
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 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) ----------
const brand = $('#brand');
brand.querySelector('.url').textContent = URL;
brand.querySelector('.plat').textContent = TAGLINE;
function submit(t) {
const s = scene('submit');
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)`);
});
}
function seek(t) {
scenes(t);
background(t);
hook(t);
init(t);
plan(t);
store(t);
products(t);
forms(t);
consoles(t);
rejections(t);
desktop(t);
submit(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 all = [...imgs, ...bgs];
const decode = async im => {
for (let i = 0; ; i++) {
try { await im.decode(); return; } catch (e) {
if (i >= 3) throw new Error(`image failed: ${im.src} (${e})`);
await new Promise(r => setTimeout(r, 200 * (i + 1)));
}
}
};
for (let i = 0; i < all.length; i += 8) await Promise.all(all.slice(i, i + 8).map(decode));
seek(0);
})();
window.teaser = { ready, seek, DURATION, FPS, W, H, captionTimes };
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();
});