storebound/media/teaser/teaser.js
CRT b142cf6d21 Teaser video source: scripted terminal timeline, procedural music, render script (media/teaser)
HTML/CSS/JS timeline rendered frame by frame in headless Chrome, original music synthesized with OfflineAudioContext, ffmpeg encode + loudnorm. Outputs go to media/teaser/out (gitignored); poster.jpg is the end card.
2026-10-11 01:02:17 +08:00

378 lines
20 KiB
JavaScript

// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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'
? '<span class="line"><span class="w big hl" data-count>7</span></span>'
: `<span class="line">${text.split(' ').map(w => w.startsWith('*') ? `<span class="w hl">${esc(w.replace(/\*/g, ''))}</span>` : `<span class="w">${esc(w)}</span>`).join(' ')}</span>`).join('')
+ (c.sub ? `<span class="sub">${esc(c.sub[0])}</span>` : '');
// 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 = '<span class="cur"></span>';
const fmtOut = text => {
const m = text.match(/^\[(\w+)\](.*)$/s);
if (!m) return esc(text);
let rest = esc(m[2]).replace(/\(dry\)/g, '<span class="dim">(dry)</span>');
return `<span class="t-${m[1]}">[${m[1]}]</span>${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 = `<span class="gt">&gt;</span> ${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(`<div class="l cmd"><span class="ps">$</span> ${esc(l.text.slice(0, k))}${busy ? cursor : ''}</div>`);
} else if (l.kind === 'note') out.push(`<div class="l note">${fmtOut(l.text)}</div>`);
else out.push(`<div class="l">${fmtOut(l.text)}</div>`);
}
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(`<div class="l"><span class="dim">${String(i + 1).padStart(2)}.</span> ${ok ? '<span class="okk">ok</span> ' : '<span class="bad">BLOCKER</span>'} <span class="t-${store === 'play' ? 'play' : 'apple'}">[${store}]</span> <span class="${ok ? 'fixed' : ''}">${esc(msg)}</span></div>`);
});
const winAt = FIX_AT + BLOCKERS.length * FIX_GAP + 0.05;
if (r >= winAt) out.push('<div class="l"><span class="t-validate">[validate]</span> <span class="win">no blockers</span></div>');
if (r >= winAt + 0.2) out.push('<div class="l dim">(UI-only steps are not visible to the API: App Privacy, Play App content)</div>');
}
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') ? `<div class="l">${cursor}</div>` : '');
// 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 => `<span class="chip">${l}</span>`).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: '<svg viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="#86efac" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
ui: '<svg viewBox="0 0 24 24"><path d="M5 12h12M12.5 6.5 18 12l-5.5 5.5" fill="none" stroke="#fcd34d" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>',
you: '<svg viewBox="0 0 24 24"><circle cx="12" cy="8.5" r="3.6" fill="none" stroke="#f9a8d4" stroke-width="2.6"/><path d="M5 19.5c1.2-3.6 4-5 7-5s5.8 1.4 7 5" fill="none" stroke="#f9a8d4" stroke-width="2.6" stroke-linecap="round"/></svg>',
};
const BG = { api: 'var(--api-bg)', ui: 'var(--ui-bg)', you: 'var(--you-bg)' };
questEl.querySelector('.rows').innerHTML = QUEST.map(([nm, ds, k]) =>
`<div class="row"><span class="ico" style="background:${BG[k]}">${ICON[k]}</span><div><div class="nm">${esc(nm)}</div><div class="ds">${esc(ds)}</div></div><span class="pill p-${k}">${k.toUpperCase()}</span></div>`).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) =>
`<tr class="${(flick && i === 0) || (!flick && reg === 'TR') ? 'hot' : ''}"><td>${reg}${reg === 'TR' ? '<span class="ov">OVERRIDE</span>' : ''}</td><td>${cur}</td><td>${a}</td><td>${b}</td></tr>`).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 => `<div class="card fan"><img src="out/assets/${f}.jpg" alt=""></div>`).join('')
+ `<div class="card big">${BIG.map(([f]) => `<img src="out/assets/${f}.jpg" alt="" style="position:absolute;inset:0">`).join('')}</div>`
+ '<div class="lang"></div>';
sizesEl.innerHTML = SIZES.map(s => `<span class="sz">${esc(s)}</span>`).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();
});