Nine scenes of 6-7 s, one slow camera move each, soft dissolves, one plain-English caption per scene at the top (on screen at least 4 s): the stores' to-do list and one rejection mail, `init` in an empty folder, the agent prompt and `release plan` (25 steps), store text and renders in 7 sizes x 4 languages, `play products --dry` and `catalog diff`, the scan's declarations proposal with the ask-owner line, three findings with plain reasons turning green, the desktop release timeline and Store readiness "No blockers", Submit and the end card. Every CLI line is real output: tests/teaser.test.ts runs each command (store answers from tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml, never the network) and checks scene lengths, caption holds, the 55-65 s total and 16:9 only. The vertical output is gone; the web version targets 4.5 MB. Calmer music (D major pads, soft pluck, bells on scene changes). desktop.ts adds the Store readiness frame.
397 lines
23 KiB
JavaScript
397 lines
23 KiB
JavaScript
// Teaser picture (v4): 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, COPY, COPY_AT, COPY_FILE, COPY_STEP,
|
|
DATA_SAFETY_ROWS, DECL_ASK, DECL_AT, DECL_LINES, DECL_STEP, DECL_TOTAL, DESKTOP, 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);
|
|
const ess = p => p * p * (3 - 2 * p); // smoothstep: the slow camera moves
|
|
const lerp = (a, b, p) => a + (b - a) * p;
|
|
const esc = s => String(s).replace(/&/g, '&').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* -> <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 slow 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 = ess(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.1, 0.5, 20);
|
|
appear($('#todo'), r, 0.35, 0.45, 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"> </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, 1.0);
|
|
html(formsPill, `<b>${Math.round(DECL_TOTAL * eoc(np))}</b> ${esc(FORMS_PILL.text)}`);
|
|
pillAt(formsPill, r, FORMS_PILL.at);
|
|
}
|
|
|
|
// ---------- 7 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.25, 0.25, 4);
|
|
appear(scanLns[1], r, 0.6, 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);
|
|
}
|
|
|
|
// ---------- 8 desktop: real frames in a window, the camera pushes from the timeline to the readiness card ----------
|
|
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 deskCam = $('#s-desktop > .cam');
|
|
deskCam.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">${DESKTOP.frames.map((f, i) => `<img src="out/desktop/${f}.jpg" alt="" data-i="${i}">`).join('')}</div></div></div>
|
|
${DESKTOP.callouts.map(c => `<div class="ring${c.green ? ' green' : ''}"></div><div class="callout${c.green ? ' green' : ''}">${esc(c.text)}</div>`).join('')}`;
|
|
const cam2 = $('#s-desktop .cam2'), frameEls = [...cam2.querySelectorAll('img')];
|
|
const rings = [...deskCam.querySelectorAll('.ring')], callouts = [...deskCam.querySelectorAll('.callout')];
|
|
function deskView(r) {
|
|
const s = scene('desktop'), len = s.end - s.start;
|
|
const p = ess(clamp((r + DISSOLVE / 2) / (len + DISSOLVE)));
|
|
const { from, to } = DESKTOP.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 });
|
|
function desktop(t) {
|
|
const s = scene('desktop'); if (t < s.start - 1 || t > s.end + 1) return;
|
|
const r = t - s.start;
|
|
const c = deskView(r);
|
|
set(cam2, 'transform', `translate(${c.ox.toFixed(2)}px, ${c.oy.toFixed(2)}px) scale(${c.S.toFixed(5)})`);
|
|
const sw = eio(prog(r, DESKTOP.swap[0], DESKTOP.swap[1] - DESKTOP.swap[0]));
|
|
set(frameEls[1], 'opacity', String(sw));
|
|
DESKTOP.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 = rings[i], call = 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 = 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)})`);
|
|
});
|
|
}
|
|
|
|
// ---------- 9 submit + end card ----------
|
|
const sbtn = $('#sbtn'), shov = $('#sbtn .hov'), brand = $('#brand'), cursorEl = $('#cursor');
|
|
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) { set(cursorEl, 'opacity', '0'); return; }
|
|
const hov = prog(r, SUBMIT.hover, 0.25);
|
|
const out = prog(r, SUBMIT.logoAt, 0.6);
|
|
set(sbtn, 'opacity', String(eoc(prog(r, 0.0, 0.4)) * (1 - eio(out))));
|
|
set(sbtn, 'transform', `translate(-50%, 0) scale(${(lerp(0.96, 1, eoc(prog(r, 0, 0.5))) * (1 + 0.02 * hov)).toFixed(4)})`);
|
|
set(shov, 'opacity', String(hov));
|
|
// the cursor glides in and rests on the button: it never clicks
|
|
const btn = sbtn.getBoundingClientRect();
|
|
const tx = btn.left + btn.width * 0.64, ty = btn.top + btn.height * 0.66;
|
|
const p = eio(prog(r, 0.3, SUBMIT.hover - 0.3));
|
|
set(cursorEl, 'opacity', String(prog(r, 0.3, 0.3) * (1 - eio(out))));
|
|
set(cursorEl, 'transform', `translate(${(lerp(W * 0.86, tx, p) - 10).toFixed(1)}px, ${(lerp(H * 0.95, ty, p) - 10).toFixed(1)}px)`);
|
|
[...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);
|
|
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, '') }));
|
|
await Promise.all([...imgs, ...bgs].map(im => im.decode().catch(() => { throw new Error(`image failed: ${im.src}`); })));
|
|
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();
|
|
});
|