517 lines
30 KiB
JavaScript
517 lines
30 KiB
JavaScript
// Teaser picture (v9, the story cut tight): 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_CMD, END_URL,
|
|
FINDINGS, FINDINGS_AT, FINDINGS_STEP, FIX_AT, FORM_AT, FORM_STEP, FORMAT, FORMS_COUNT_AT, FPS, GAME_SCREENS, GAME_SPEED, MONTAGE,
|
|
PILE, PILE_TITLES, PRICE_ROWS, PRIVACY_ANSWERS, PRIVACY_STEPS, PRODUCT_LINES, PROMPT, PROMPT_AT, PROMPT_CPS, PROMPT_SENT,
|
|
REJECT_DARK, REJECT_LAND, REJECT_PUSH, REJECT_STEP, REJECTS, STAMP, RESCAN_AT, RESCAN_LINE, RISKS_LINE, SCAN_CMD, SCENES, SHOT_LANGS, SHOT_SIZES, SHOTS_AT, SHOTS_ROW, SHOTS_STEP, scene, veil } 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);
|
|
/** 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, '<').replace(/>/g, '>');
|
|
/** Every shot's first frame already shows its subject: things that open a shot start this much before the cut. */
|
|
const LEAD = 0.15;
|
|
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; } };
|
|
const hl = s => esc(s).replace(/\*([^*]+)\*/g, '<span class="hl">$1</span>');
|
|
/** A gentle appear: opacity and a small rise. */
|
|
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;
|
|
}
|
|
const tagged = l => esc(l).replace(/^(\[\w+\])/, '<span class="tag">$1</span>');
|
|
|
|
// ---------- 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 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');
|
|
// hard cuts on the beat: the first frame is already the product, no fade from black
|
|
set(el, 'opacity', String(i === 0 ? 1 : eio(prog(t, from, s.x))));
|
|
const c = CAMERA[s.id];
|
|
const raw = (t - s.start) / (s.end - s.start);
|
|
const p = ecam(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.at(-1);
|
|
|
|
// ---------- 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));
|
|
}
|
|
|
|
// ---------- 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, edgeId = null;
|
|
function captions(t) {
|
|
let top = 0, edge = 0, rise = 0;
|
|
for (const c of CAPTIONS) {
|
|
if (t < c.at || t >= c.until) continue;
|
|
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);
|
|
if (c.pos === 'top') {
|
|
top = op; rise = (1 - pin) * 14;
|
|
if (capId !== c.id) { capId = c.id; capEl.className = c.hl ?? ''; capEl.innerHTML = hl(c.text); }
|
|
} else {
|
|
edge = op;
|
|
if (edgeId !== c.id) { edgeId = c.id; edgeEl.innerHTML = hl(c.text); }
|
|
}
|
|
}
|
|
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)`);
|
|
}
|
|
|
|
// ---------- 1 game: the game's own screens ----------
|
|
const gameEl = $('#game');
|
|
const PHONE_GAP = 400, PHONES = [...GAME_SCREENS, ...GAME_SCREENS, ...GAME_SCREENS];
|
|
const phones = PHONES.map(id => {
|
|
const el = document.createElement('div');
|
|
el.className = 'phone';
|
|
el.style.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;
|
|
// the screens rush past from frame 0 (no fade-in): a conveyor, each on its own bob
|
|
phones.forEach((el, i) => {
|
|
const x = 160 + i * PHONE_GAP - GAME_SPEED * r;
|
|
const fy = Math.sin(r * 3 + i * 1.3) * 10;
|
|
set(el, 'opacity', '1');
|
|
set(el, 'left', `${x.toFixed(1)}px`);
|
|
set(el, 'transform', `translateY(${fy.toFixed(1)}px) rotate(${(((i % 4) - 1.5) * 1.6).toFixed(2)}deg)`);
|
|
});
|
|
}
|
|
|
|
// ---------- 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\]/, '<span class="okk">[ok]</span>'));
|
|
if (c.kind === 'steps') return PRIVACY_STEPS.map(l => esc(l).replace(/^(\s+\d+\.)/, '<span class="tag">$1</span>').replace(/(\[click: [^\]]+\])/, '<span class="loc">$1</span>'));
|
|
if (c.kind === 'csv') return DATA_SAFETY_CSV.slice(from).map(l => esc(l).replace(/^(PSL_[A-Z_]+)/, '<span class="num">$1</span>').replace(/,TRUE,/, ',<span class="okk">TRUE</span>,'));
|
|
if (c.kind === 'prices') return PRICE_ROWS.slice(from).map(l => esc(l).replace(/,([A-Z]{2}),([A-Z]{3}),([\d.]+),/, ',<span class="num">$1</span>,$2,<span class="loc">$3</span>,'));
|
|
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 += `<div class="tile" style="left:${x.toFixed(1)}px;top:${(j * (RH + GAP)).toFixed(1)}px;width:${ws[i].toFixed(1)}px;height:${RH.toFixed(1)}px;background-image:url(out/assets/shots/${lang}-${sz.id}.jpg)"></div>`;
|
|
x += ws[i] + GAP;
|
|
});
|
|
});
|
|
body = `<div class="grid">${tiles}</div>`;
|
|
} else body = `<div class="rows">${rowsOf(c).map(r => `<div>${r}</div>`).join('')}</div>`;
|
|
el.innerHTML = `<div class="ch"><span class="dots"><i></i><i></i><i></i></span>${esc(PILE_TITLES[c.kind])}</div><div class="body">${body}<div class="shade"></div></div>`;
|
|
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 - LEAD, 0.2);
|
|
set(el, 'opacity', String(clamp(p * 3)));
|
|
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)})`);
|
|
// 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 - LEAD, 0.2)), 0);
|
|
set(shade, 'opacity', String(Math.min(0.6, above * 0.16).toFixed(3)));
|
|
});
|
|
}
|
|
|
|
// ---------- 3 reject: real scan findings land one per beat, then the REJECTED stamp ----------
|
|
const rejEl = $('#rejects'), stampEl = $('#stamp');
|
|
rejEl.innerHTML = REJECTS.map(f => {
|
|
const m = f.head.match(/^(\S+)\s+.*? · App Store · (.+)$/);
|
|
return `<div class="rj"><div class="rule"><span class="sev">${m[1]}</span><span>${esc(f.rule)}</span></div><div class="title">${esc(f.title)}</div><div class="real">${esc(m[2])}</div></div>`;
|
|
}).join('');
|
|
const rjEls = [...rejEl.children];
|
|
function reject(t) {
|
|
const s = scene('reject'); if (t < s.start - 1 || t > s.end + 1) return;
|
|
const r = t - s.start, len = s.end - s.start;
|
|
const jolt = r >= STAMP.at && r < STAMP.at + 1 / 30 ? STAMP.jolt : 0;
|
|
const z = lerp(REJECT_PUSH[0], REJECT_PUSH[1], esin(prog(r, 0, len)));
|
|
set(rejEl, 'transform', `translate(calc(-50% + ${jolt}px), calc(-50% + ${jolt / 2}px)) scale(${z.toFixed(4)})`);
|
|
set(rejEl, 'filter', `brightness(${(1 - 0.45 * eio(prog(r, REJECT_DARK, len - REJECT_DARK))).toFixed(3)})`);
|
|
rjEls.forEach((el, i) => {
|
|
const lp = prog(r, i * REJECT_STEP - (i ? 0 : LEAD), REJECT_LAND.d + (i ? 0 : LEAD));
|
|
set(el, 'opacity', String(clamp(lp * 4)));
|
|
set(el, 'transform', `scale(${lerp(REJECT_LAND.from, 1, eoc(lp)).toFixed(4)})`);
|
|
});
|
|
const sp = prog(r, STAMP.at - STAMP.d, STAMP.d);
|
|
set(stampEl, 'opacity', String(r < STAMP.at - STAMP.d ? 0 : clamp(sp * 3)));
|
|
set(stampEl, 'filter', rejEl.style.filter);
|
|
set(stampEl, 'transform', `translate(calc(-50% + ${jolt}px), -50%) rotate(${STAMP.rot}deg) scale(${lerp(STAMP.from, 1, eoc(sp)).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.05, 0.12, 0);
|
|
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 ? '<span class="caret"></span>' : ''}`);
|
|
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 => `<span class="ps">$</span> <span class="cmd">${esc(text.replace(/^\$ /, ''))}</span>`;
|
|
function termLines(sel, lines, fmt) {
|
|
const body = $(`${sel} .body`);
|
|
body.innerHTML = lines.map((l, i) => `${i && l.cmd ? '<span class="ln sp" style="opacity:1"> </span>' : ''}<span class="ln">${l.cmd ? cmdLine(l.text) : fmt(l.text)}</span>`).join('');
|
|
return [...body.querySelectorAll('.ln:not(.sp)')];
|
|
}
|
|
const buildLns = termLines('#buildterm', BUILD_LINES, l => tagged(l).replace(/(build 52|versionCode 52)/, '<span class="num">$1</span>').replace(/(build planned \(dry run\))/, '<span class="okk">$1</span>'));
|
|
const prodLns = termLines('#prodterm', PRODUCT_LINES, l => tagged(l).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>'));
|
|
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 - LEAD, 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 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 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;
|
|
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: `${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];
|
|
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)}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 shots(t) {
|
|
const s = scene('shots'); if (t < s.start - 1 || t > s.end + 1) return;
|
|
const r = t - s.start;
|
|
langLabels.forEach(el => appear(el, r, -0.05, 0.1, 0));
|
|
shotLabels.forEach(({ el, col }) => set(el, 'opacity', String(eoc(prog(r, SHOTS_AT - LEAD + col * SHOTS_STEP, 0.2)))));
|
|
for (const { el, col, row } of shotTiles) {
|
|
const p = prog(r, SHOTS_AT - LEAD + col * SHOTS_STEP + row * SHOTS_ROW, 0.22);
|
|
set(el, 'opacity', String(eoc(p)));
|
|
set(el, 'transform', `scale(${lerp(0.86, 1, eoc(p)).toFixed(4)})`);
|
|
}
|
|
}
|
|
|
|
// 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 => `<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>`;
|
|
const propLns = [...propBody.querySelectorAll('.ln')], askGlow = $('#prop .ask .glow');
|
|
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)}</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.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 - LEAD + 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.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, 'color', p > 0.5 ? '#fff' : 'var(--ink-2)');
|
|
});
|
|
const np = prog(r, FORMS_COUNT_AT, 0.7);
|
|
html(formCount, `<b>${Math.round(DECL_TOTAL * eoc(np))}</b> answers`);
|
|
appear(formCount, r, FORMS_COUNT_AT, 0.2, 6);
|
|
}
|
|
|
|
// 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 `<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(/(\[click: [^\]]+\])/, '<span class="loc">$1</span>');
|
|
};
|
|
cpBody.innerHTML = `<span class="ln" style="opacity:1">${cmdLine(CONSOLE_CMD)}</span>${CONSOLE_LINES.map(l => `<span class="ln${l.final ? ' fin' : ''}">${cpLine(l)}</span>`).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]));
|
|
set(cpTerm, 'opacity', String(eoc(prog(r, -0.1, 0.2)) * out));
|
|
cpLns.forEach((el, i) => appear(el, r, CONSOLE_LINES[i].at - LEAD, 0.15, 5));
|
|
set(cpGlow, 'opacity', String(eoc(prog(r, CONSOLE_LINES.at(-1).at + 0.08, 0.2))));
|
|
}
|
|
|
|
// ---------- 6 scan: every finding turns green, the re-scan is clean ----------
|
|
const scanBody = $('#scanterm .body');
|
|
scanBody.innerHTML = `<span class="ln">${cmdLine(SCAN_CMD)}</span>`
|
|
+ `<span class="ln num">${esc(RISKS_LINE).replace(/(2 blocker, 3 high)/, '<span class="bad">$1</span>')}</span>`
|
|
+ FINDINGS.map(f => {
|
|
const m = f.match(/^(\S+)(\s+)(.*)$/);
|
|
return `<div class="fd"><span class="tint b"></span><span class="tint g"></span><span class="pi i-stop"></span><span class="tx"><span class="sev">${m[1]}</span>${m[2]}${esc(m[3])}</span></div>`;
|
|
}).join('')
|
|
+ `<span class="ln" style="margin-top:14px">${cmdLine(SCAN_CMD)} <span class="tag">(after the fixes)</span></span>`
|
|
+ `<span class="ln okk">${esc(RESCAN_LINE)}</span>`;
|
|
const scanLns = [...scanBody.querySelectorAll(':scope > .ln')], fdEls = [...scanBody.querySelectorAll('.fd')];
|
|
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.1, 0.2, 10);
|
|
appear(scanLns[0], r, -LEAD, 0.12, 4);
|
|
appear(scanLns[1], r, 0.05, 0.15, 6);
|
|
fdEls.forEach((el, i) => {
|
|
appear(el, r, FINDINGS_AT - LEAD + i * FINDINGS_STEP, 0.2, 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)));
|
|
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.25, 0.2, 6);
|
|
appear(scanLns[3], r, RESCAN_AT, 0.25, 6);
|
|
}
|
|
|
|
// ---------- 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 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 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 = `<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">${d.groups.map((g, gi) => `<div class="grp" data-g="${gi}"><img src="out/desktop/${g.frame}.jpg" alt=""></div>`).join('')}</div></div></div>
|
|
${d.rings.map(c => `<div class="ring${c.green ? ' green' : ''}"></div>`).join('')}
|
|
${d.pointer ? '<span class="dcur" aria-hidden="true"><span class="pi i-cursor"></span></span>' : ''}`;
|
|
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, 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, { WIN } = dk;
|
|
const p = ecam(clamp(r / len));
|
|
const { from, to } = dk.d.cam;
|
|
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 = (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, 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)})`);
|
|
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.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)})`);
|
|
}
|
|
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];
|
|
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 desktop = t => desks.forEach(dk => deskScene(dk, t));
|
|
|
|
// ---------- 9 end card: the logo, the install line typed out, the site; the last thing on screen ----------
|
|
const brand = $('#brand'), endCmd = $('#brand .cmdl .t'), endCaret = $('#brand .cmdl .caret');
|
|
brand.querySelector('.url').textContent = END_URL;
|
|
const brandEls = [['.mark', END.markAt], ['.word', END.wordAt], ['.cmdl', END.cmdAt - 0.05], ['.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;
|
|
for (const [el, at] of brandEls) {
|
|
const p = prog(r, at - LEAD, END.d);
|
|
set(el, 'opacity', String(clamp(p * 2)));
|
|
set(el, 'transform', `scale(${lerp(1.25, 1, eoc(p)).toFixed(4)})`);
|
|
}
|
|
const n = clamp(Math.floor((r - END.cmdAt) * END.cps), 0, END_CMD.length);
|
|
html(endCmd, esc(END_CMD.slice(0, n)));
|
|
// the caret: solid while typing, then a steady blink (two per second, on the beat) through the hold
|
|
set(endCaret, 'opacity', r < END.full || Math.floor((r - END.full) * 2) % 2 === 0 ? '1' : '0');
|
|
}
|
|
/** The reject cards' boxes (and the stamp's) as drawn at time t, for the safe-margin check. */
|
|
function rejectLayout(t) {
|
|
seek(t);
|
|
const k = W / stage.getBoundingClientRect().width;
|
|
const box = el => { const b = el.getBoundingClientRect(); return { x: b.x * k, y: b.y * k, w: b.width * k, h: b.height * k }; };
|
|
return { t, cards: rjEls.filter(el => Number(el.style.opacity) > 0).map(box), stamp: Number(stampEl.style.opacity) > 0 ? box(stampEl) : null };
|
|
}
|
|
/** Text overflow at time t: every visible text node must fit its element (scroll size <= client size) and lie inside
|
|
* its container (a card, the stamp, the prompt, else the frame). Returns the problems found (empty = all fits). */
|
|
function textCheck(t) {
|
|
seek(t);
|
|
const k = W / stage.getBoundingClientRect().width, sr = stage.getBoundingClientRect(), bad = [];
|
|
const shown = el => { for (let e = el; e && e !== stage; e = e.parentElement) { const cs = getComputedStyle(e); if (cs.visibility === 'hidden' || cs.display === 'none' || Number(cs.opacity) === 0) return false; } return true; };
|
|
// measure in layout space: transforms (camera, card tilts, the stamp's rotation) off while measuring
|
|
// first, with the transforms on: every text container (card, terminal, stamp) is inside the frame as drawn
|
|
for (const c of new Set([...stage.querySelectorAll('.rj, .card, #stamp, #prompt')].filter(shown))) {
|
|
const b = c.getBoundingClientRect();
|
|
if (b.width && (b.left < sr.left - 1 || b.right > sr.right + 1 || b.top < sr.top - 1 || b.bottom > sr.bottom + 1)) bad.push({ t, id: c.id || c.className, text: c.textContent.trim().slice(0, 60), why: `outside the frame ${(b.left - sr.left) | 0},${(b.top - sr.top) | 0} ${b.width | 0}x${b.height | 0}` });
|
|
}
|
|
const tf = [...stage.querySelectorAll('*')].filter(e => e.style.transform).map(e => [e, e.style.transform]);
|
|
for (const [e] of tf) e.style.transform = 'none';
|
|
const walk = document.createTreeWalker(stage, NodeFilter.SHOW_TEXT, { acceptNode: n => n.textContent.trim() ? 1 : 3 });
|
|
for (let n; (n = walk.nextNode());) {
|
|
const el = n.parentElement; if (!shown(el)) continue;
|
|
const r = document.createRange(); r.selectNodeContents(n); const b = r.getBoundingClientRect();
|
|
if (!b.width) continue;
|
|
const box = el.closest('[data-box], .rj, .card, #stamp, #prompt, #cap, #edge, #brand') ?? stage;
|
|
const c = box.getBoundingClientRect(), cs = getComputedStyle(el);
|
|
const text = n.textContent.trim().slice(0, 60), id = box.id || box.className || 'frame';
|
|
if (cs.display !== 'inline' && (el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1)) bad.push({ t, id, text, why: `scroll ${el.scrollWidth}x${el.scrollHeight} > ${el.clientWidth}x${el.clientHeight}` });
|
|
if (b.left < c.left - 1 || b.right > c.right + 1 || b.top < c.top - 1 || b.bottom > c.bottom + 1) bad.push({ t, id, text, why: `text box outside ${id}: ${((b.left - sr.left) * k) | 0},${((b.top - sr.top) * k) | 0} ${(b.width * k) | 0}x${(b.height * k) | 0}` });
|
|
}
|
|
for (const [e, v] of tf) e.style.transform = v;
|
|
return bad;
|
|
}
|
|
/** Layout data for the animatic checks: the end card's install line, as drawn at time t. */
|
|
function layout(t) {
|
|
seek(t);
|
|
const sc = document.querySelector('#s-end'), el = $('#brand .cmdl'), b = el.getBoundingClientRect();
|
|
return { t, endVisible: getComputedStyle(sc).visibility === 'visible' && Number(sc.style.opacity) > 0.99, text: endCmd.textContent,
|
|
opacity: Number(el.style.opacity), box: { x: b.x, y: b.y, w: b.width, h: b.height }, fontPx: parseFloat(getComputedStyle(el).fontSize) };
|
|
}
|
|
|
|
function seek(t) {
|
|
scenes(t);
|
|
background(t);
|
|
game(t);
|
|
pile(t);
|
|
reject(t);
|
|
ask(t);
|
|
builds(t);
|
|
shots(t);
|
|
products(t);
|
|
forms(t);
|
|
consoles(t);
|
|
scan(t);
|
|
desktop(t);
|
|
end(t);
|
|
captions(t);
|
|
}
|
|
|
|
const ready = (async () => {
|
|
await document.fonts.ready;
|
|
// the pile's cards show only the rows that fit whole (no row cut by the card's edge)
|
|
for (const { rows } of pileCards) if (rows) {
|
|
const h = rows.parentElement.clientHeight;
|
|
for (const d of [...rows.children]) if (d.offsetTop + d.offsetHeight > h - 14) d.remove();
|
|
}
|
|
const imgs = [...document.images];
|
|
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++) {
|
|
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, layout, rejectLayout, textCheck, 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();
|
|
});
|