Docs/Skills and code/skill video-visual-production
.claude/skills/video-visual-production/render/scenes.js
What the file says about itself
Scene library. Each scene is a pure function (env, t, props, S): t is seconds since the scene started; S = { cues: { name: seconds }, dur, assets: { id: { img, rec, typeset } } }. Nothing here knows about files or ffmpeg.
// Scene library. Each scene is a pure function (env, t, props, S): t is seconds since the scene started;
// S = { cues: { name: seconds }, dur, assets: { id: { img, rec, typeset } } }. Nothing here knows about files or ffmpeg.
const D = require('./draw');
const K = require('./contacts');
const { C, FONT, ease, prog, lerp, clamp, txt, panel, background, roundPath } = D;
const hex = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
const mixc = (a, b, k) => { const x = hex(a), y = hex(b); return `rgb(${Math.round(lerp(x[0], y[0], k))},${Math.round(lerp(x[1], y[1], k))},${Math.round(lerp(x[2], y[2], k))})`; };
const fade = (t, t0, d = 0.35) => ease.out3(prog(t, t0, d));
const TONE = { muted: C.muted, accent: C.accent, ink: C.ink, good: C.good };
// ---------------------------------------------------------------- 01 date
function date(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const rp = ease.out3(prog(t, 0.15, 0.8));
ctx.strokeStyle = C.dim; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(960 - 340 * rp, 780); ctx.lineTo(960 + 340 * rp, 780); ctx.stroke();
txt(env, P.chapter, 120, 140, { font: FONT.med, size: 34, color: C.muted, spacing: 7, alpha: fade(t, 0.3, 0.5), role: 'chapter' });
if (t >= q.year) {
const e = t - q.year;
const sc = 1.16 - 0.16 * ease.out3(e / K.stamp);
const shake = e < 0.3 ? Math.sin(e * 90) * 6 * (1 - e / 0.3) : 0;
txt(env, P.year, 960 + shake, 540, { font: FONT.bold, size: 400 * sc, color: C.accent, align: 'center', base: 'middle', alpha: ease.out3(e / 0.1), role: 'number' });
}
}
// ---------------------------------------------------------------- 02 cast
function cast(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const cp = fade(t, 0, 0.5);
const x0 = 150 - 60 * (1 - cp), cx = x0 + 350;
panel(ctx, x0, 220, 700, 640, { fill: C.panel, stroke: C.panelEdge, lw: 3, alpha: cp });
D.person(ctx, cx, 400, 230, C.accent, cp);
txt(env, P.name, cx, 610, { font: FONT.semi, size: 70, align: 'center', alpha: cp, role: 'name' });
txt(env, P.role, cx, 664, { font: FONT.reg, size: 38, color: C.muted, align: 'center', alpha: fade(t, 0.25, 0.4), role: 'label' });
const ap = prog(t, q.age, 0.25);
if (ap > 0) {
const sc = 0.7 + 0.3 * ease.back(ap);
const w = 440 * sc, h = 100 * sc;
panel(ctx, cx - w / 2, 750 - h / 2, w, h, { r: h / 2, fill: C.bg0, stroke: C.accent, lw: 4, dash: P.age.status === 'derived' ? [4, 10] : [], alpha: ease.out3(ap * 2) });
txt(env, P.age.text, cx, 750, { font: FONT.semi, size: 72 * sc, color: C.accent, align: 'center', base: 'middle', alpha: ease.out3(ap * 2), role: 'number' });
}
// right: the place and the factory
const fx = 1390;
const gp = fade(t, q.calcutta, 0.4);
if (gp > 0) {
D.factory(ctx, fx, 470, 400, C.dim, { alpha: 0.5 * gp * (t < q.factory ? 1 : 0.0), window: C.bg1 });
txt(env, P.place, fx, 744, { font: FONT.bold, size: 84, align: 'center', alpha: gp, role: 'label' });
}
txt(env, P.placeSub, fx, 808, { font: FONT.reg, size: 42, color: C.muted, align: 'center', alpha: fade(t, q.kolkata, 0.4), role: 'label' });
const fp = fade(t, q.factory, 0.45);
if (fp > 0) {
const running = t >= q.running;
D.factory(ctx, fx, 470 + 24 * (1 - fp), 400, C.ink, { alpha: fp, window: C.bg1, smokeT: running ? t - q.running : null, smokeColor: C.muted });
}
const rp = fade(t, q.running, 0.3);
if (rp > 0) {
const pulse = 0.65 + 0.35 * Math.sin((t - q.running) * 5);
ctx.globalAlpha = rp; ctx.fillStyle = C.good; ctx.beginPath(); ctx.arc(fx - 72, 884, 12 * (0.8 + 0.2 * pulse), 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1;
txt(env, 'running', fx - 44, 898, { font: FONT.med, size: 40, color: C.good, alpha: rp, role: 'label' });
}
}
// ---------------------------------------------------------------- 03 / 06 / 13 doc (capture, or typeset when the capture failed)
function doc(env, t, P, S) {
background(env);
const { ctx } = env;
const a = S.assets[P.asset];
const ts = a.typeset;
const src = ts ? ts.canvas : a.img;
const boxes = ts ? ts.boxes : a.rec.boxes;
const iw = ts ? ts.w : a.rec.image_w, ih = ts ? ts.h : a.rec.image_h;
const INS = ts ? 0 : 6; // image px trimmed top and bottom
const sh = ih - 2 * INS;
const dw = 1560, sc = dw / iw, dh = sh * sc, x = (1920 - dw) / 2;
const enter = ease.out3(prog(t, 0, 0.5));
const y = 150 + (1 - enter) * 44;
const drift = 1 + 0.025 * clamp(t / S.dur);
ctx.save();
ctx.translate(960, y + dh / 2); ctx.scale(drift, drift); ctx.translate(-960, -(y + dh / 2));
ctx.globalAlpha = enter;
ctx.shadowColor = 'rgba(0,0,0,0.55)'; ctx.shadowBlur = 40; ctx.shadowOffsetY = 12;
roundPath(ctx, x, y, dw, dh, 18); ctx.fillStyle = C.paper; ctx.fill();
ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
ctx.save();
roundPath(ctx, x, y, dw, dh, 18); ctx.clip();
ctx.drawImage(src, 0, INS, iw, sh, x, y, dw, dh);
// spotlight: dim the passage once the first highlight starts, then relight each highlighted phrase
const hs = P.highlights.map((h) => ({ ...h, t0: S.cues[h.cue], rects: boxes[h.phrase] || [] }));
const first = Math.min(...hs.map((h) => h.t0));
const dim = 0.76 * ease.out3(prog(t, first - 0.1, 0.4));
if (dim > 0) { ctx.globalAlpha = enter * dim; ctx.fillStyle = C.paper; ctx.fillRect(x, y, dw, dh); }
for (const h of hs) {
const k = prog(t, h.t0, 0.45);
if (k <= 0) continue;
const total = h.rects.reduce((s, r) => s + r.w, 0);
let run = k * total;
for (const r of h.rects) {
const take = Math.min(r.w, Math.max(0, run)); run -= r.w;
if (take <= 0) continue;
const rx = x + (r.x - 6) * sc, ry = y + (r.y - 2 - INS) * sc, rw = (take + 12) * sc, rh = (r.h + 4) * sc;
ctx.save();
ctx.beginPath(); ctx.rect(rx, ry, rw, rh); ctx.clip();
ctx.globalAlpha = enter; ctx.drawImage(src, 0, INS, iw, sh, x, y, dw, dh);
ctx.globalCompositeOperation = 'multiply'; ctx.fillStyle = C.mark; ctx.fillRect(rx, ry, rw, rh);
ctx.restore();
}
}
ctx.restore();
ctx.restore();
ctx.globalAlpha = 1;
// source line: outlet and date, and a plain note when the passage is typeset rather than captured
txt(env, P.tag + (ts ? (a.rec.typeset_by_choice ? ' (typeset quote)' : ' (typeset from the passage)') : ''), x, y + dh + 62, { font: FONT.med, size: 34, color: C.muted, alpha: fade(t, 0.35, 0.4), role: 'source' });
// pull quote: the phrase being discussed, lifted out of the passage
const cur = hs.filter((h) => t >= h.t0).sort((p, q) => q.t0 - p.t0)[0];
if (cur) {
const e = t - cur.t0;
const alpha = ease.out3(e / 0.35);
const lines = D.wrap(ctx, cur.phrase, 1480, FONT.semi, 78);
const top = 700 - ((lines.length - 1) * 98) / 2 + (1 - alpha) * 26;
ctx.globalAlpha = alpha; ctx.fillStyle = C.accent; ctx.fillRect(960 - 760, top - 62, 10, lines.length * 98 - 12); ctx.globalAlpha = 1;
lines.forEach((ln, i) => txt(env, ln, 960 - 720, top + i * 98, { font: FONT.semi, size: 78, color: C.ink, alpha, role: 'evidence' }));
}
}
// ---------------------------------------------------------------- 04 crowd
function crowd(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const n = P.count, gap = 170, x0 = 960 - ((n - 1) / 2) * gap, mid = Math.floor((n - 1) / 2);
const outP = ease.inOut(prog(t, q.out, 0.9));
for (let i = 0; i < n; i++) {
const a = fade(t, q.row + i * 0.06, 0.35);
let col = C.dim, s = 150, dy = 0;
if (i === mid) { col = mixc(C.dim, C.accent, fade(t, q.you, 0.35)); dy = outP * 170; s = 150 * (1 + 0.4 * outP); }
else col = mixc(C.dim, C.bg1, 0.35 * outP);
D.person(ctx, x0 + i * gap, 540 + 30 * (1 - a) + dy, s, col, a);
}
const yp = fade(t, q.you, 0.3);
txt(env, P.you, 960, 340 - 10 * (1 - yp), { font: FONT.bold, size: 72, color: C.accent, align: 'center', alpha: yp, role: 'label' });
const rp = prog(t, q.ring, 1.2);
if (rp > 0 && rp < 1) {
ctx.globalAlpha = 0.6 * (1 - rp); ctx.strokeStyle = C.accent; ctx.lineWidth = 5;
ctx.beginPath(); ctx.arc(960, 540 + 170 + 20, 90 + 260 * ease.out3(rp), 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1;
}
txt(env, P.line, 960, 905, { font: FONT.semi, size: 54, color: C.ink, align: 'center', alpha: fade(t, q.out + 0.3, 0.45), role: 'keyword' });
}
// ---------------------------------------------------------------- 05 timeline (a staircase of steps, in order, not to scale)
function timeline(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const cxs = [420, 960, 1500], cys = [690, 580, 470];
P.steps.forEach((st, i) => {
const t0 = Math.min(q[st.cueYear] == null ? 1e9 : q[st.cueYear], q[st.cueTitle]);
const a = fade(t, t0, 0.4);
if (a <= 0) return;
const cx = cxs[i], cy = cys[i] + 24 * (1 - a);
const last = i === P.steps.length - 1;
panel(ctx, cx - 250, cy - 125, 500, 250, { fill: C.panel, stroke: last ? C.accent : C.panelEdge, lw: last ? 5 : 3, alpha: a });
if (st.year) {
const yp = prog(t, q[st.cueYear], 0.25);
txt(env, st.year, cx, cy - 150, { font: FONT.bold, size: 88 * (0.85 + 0.15 * ease.back(yp)), color: C.accent, align: 'center', alpha: ease.out3(yp * 2), role: 'number' });
}
const tp = fade(t, q[st.cueTitle], 0.35);
const subLines = D.wrap(ctx, st.sub, 440, FONT.reg, 38);
txt(env, st.title, cx, cy - 18 - (subLines.length - 1) * 20, { font: FONT.semi, size: 54, align: 'center', alpha: tp, role: 'label' });
subLines.forEach((ln, k) => txt(env, ln, cx, cy + 46 - (subLines.length - 1) * 20 + k * 46, { font: FONT.reg, size: 38, color: C.muted, align: 'center', alpha: tp, role: 'label' }));
if (i > 0) {
const pa = fade(t, t0 - 0.05, 0.4);
ctx.globalAlpha = pa * 0.8; ctx.strokeStyle = C.dim; ctx.lineWidth = 4; ctx.setLineDash([4, 12]);
ctx.beginPath(); ctx.moveTo(cxs[i - 1] + 255, cys[i - 1] - 30); ctx.quadraticCurveTo(cxs[i - 1] + 275, cys[i] - 30, cx - 255, cy - 30); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1;
}
});
const mp = fade(t, q[P.making.cue], 0.45);
if (mp > 0) {
D.factory(ctx, 1500, 715 - 16 * (1 - mp), 150, C.good, { alpha: mp, window: C.bg0 });
txt(env, P.making.text, 1500, 830, { font: FONT.semi, size: 48, color: C.good, align: 'center', alpha: mp, role: 'label' });
}
}
// ---------------------------------------------------------------- 07 / 08 slam (plain typography)
function slam(env, t, P, S) {
background(env);
const { ctx } = env;
const n = P.lines.length;
const gaps = n === 1 ? [0] : [-70, 60];
P.lines.forEach((ln, i) => {
const a = fade(t, S.cues[ln.cue], 0.5);
let size = ln.size;
ctx.font = `${size}px ${ln.tone === 'accent' ? FONT.bold : FONT.semi}`;
while (ctx.measureText(ln.text).width > 1620 && size > 40) { size -= 4; ctx.font = `${size}px ${FONT.semi}`; }
txt(env, ln.text, 960, 540 + gaps[i] + 26 * (1 - a), { font: ln.tone === 'accent' ? FONT.bold : FONT.semi, size, color: TONE[ln.tone] || C.ink, align: 'center', base: 'middle', alpha: a, role: 'keyword' });
});
}
// ---------------------------------------------------------------- 09 route (no national outline: two places and an arc)
function route(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const from = [1400, 700], to = [520, 360], ctl = [1000, 300];
const pa = fade(t, q.places, 0.5);
const dimK = ease.out3(prog(t, q.stops, 0.5));
// Calcutta
const fc = mixc(C.ink, C.dim, dimK);
D.factory(ctx, from[0], from[1] - 120, 170, fc, { alpha: pa, window: C.bg0 });
ctx.globalAlpha = pa; ctx.fillStyle = fc; ctx.beginPath(); ctx.arc(from[0], from[1], 26, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1;
txt(env, P.from, from[0], from[1] + 96, { font: FONT.bold, size: 58, color: mixc(C.ink, C.muted, dimK), align: 'center', alpha: pa, role: 'label' });
if (dimK > 0) {
D.cross(ctx, from[0] + 150, from[1] - 190, 46, C.muted, dimK, 8);
txt(env, P.stops, from[0], from[1] + 160, { font: FONT.med, size: 44, color: C.muted, align: 'center', alpha: dimK, role: 'label' });
}
// Delhi
const gp = prog(t, q.grow, 1.4);
if (gp > 0) {
ctx.strokeStyle = C.good; ctx.lineWidth = 4;
for (let k = 0; k < 2; k++) { const r = (gp * 1.0 + k * 0.5) % 1; ctx.globalAlpha = 0.22 * (1 - r); ctx.beginPath(); ctx.arc(to[0], to[1], 40 + 150 * r, 0, Math.PI * 2); ctx.stroke(); }
ctx.globalAlpha = 1;
}
const lit = ease.out3(prog(t, q.restart, 0.5));
ctx.globalAlpha = pa; ctx.lineWidth = 6; ctx.strokeStyle = mixc(C.dim, C.good, lit); ctx.setLineDash(lit < 1 ? [10, 10] : []);
ctx.beginPath(); ctx.arc(to[0], to[1], 34, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
if (lit > 0) { ctx.globalAlpha = lit; ctx.fillStyle = C.good; ctx.beginPath(); ctx.arc(to[0], to[1], 22, 0, Math.PI * 2); ctx.fill(); }
ctx.globalAlpha = 1;
txt(env, P.to, to[0], to[1] + 100, { font: FONT.bold, size: 58, color: mixc(C.muted, C.ink, lit), align: 'center', alpha: pa, role: 'label' });
if (lit > 0) D.factory(ctx, to[0], to[1] - 120, 170 * lit, C.good, { alpha: lit, window: C.bg0 });
if (prog(t, q.grow, 1.4) > 0) txt(env, P.grows, to[0], to[1] + 168, { font: FONT.med, size: 44, color: C.good, align: 'center', alpha: fade(t, q.grow, 0.35), role: 'label' });
// the arc
const ap = ease.inOut(prog(t, q.leave, 1.0));
if (ap > 0) {
ctx.strokeStyle = C.accent; ctx.lineWidth = 9; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(from[0] - 40, from[1] - 20);
const steps = 48; let last = null;
for (let i = 1; i <= steps; i++) { const k = (i / steps) * ap; const p = D.qpt([from[0] - 40, from[1] - 20], ctl, [to[0] + 44, to[1] + 24], k); ctx.lineTo(p.x, p.y); last = p; }
ctx.stroke(); ctx.lineCap = 'butt';
if (last && ap > 0.04) D.arrowHead(ctx, last.x, last.y, last.ang, 34, C.accent);
txt(env, P.label, 980, 330, { font: FONT.bold, size: 92, color: C.accent, align: 'center', alpha: fade(t, q.leave + 0.15, 0.3), role: 'number' });
}
}
// ---------------------------------------------------------------- 10 / 14 ladder (the pivot ladder: first use is defined here)
function ladder(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const lx = 1250, y1 = 720, y2 = 500, rail = 120;
const worked = P.mode === 'worked';
const wp = worked ? ease.out3(prog(t, q.worked, 0.35)) : 0;
ctx.fillStyle = C.dim;
ctx.fillRect(lx - rail - 7, 300, 14, 560); ctx.fillRect(lx + rail - 7, 300, 14, 560);
// rung 2 (hollow: not yet)
ctx.strokeStyle = C.dim; ctx.lineWidth = 4; ctx.setLineDash([12, 12]); ctx.strokeRect(lx - rail, y2 - 11, rail * 2, 22); ctx.setLineDash([]);
// rung 1
let col = C.accent, ra = 1;
if (worked) { col = mixc(C.accent, C.good, wp); }
else { ra = fade(t, q.rung, 0.35); }
if (ra > 0) {
ctx.globalAlpha = ra; ctx.fillStyle = col; roundPath(ctx, lx - rail, y1 - 11, rail * 2, 22, 8); ctx.fill(); ctx.globalAlpha = 1;
if (!worked) txt(env, P.rungText, lx - rail - 40, y1 + 14, { font: FONT.med, size: 44, color: C.ink, align: 'right', alpha: ra, role: 'label' });
}
if (!worked) {
const cp = prog(t, q.tick, K.check);
if (cp > 0) D.check(ctx, lx, y1 - 70, 80, C.accent, ease.out3(cp), 12);
const lp = prog(t, q.label, K.label);
if (lp > 0) txt(env, P.label, 520, 560, { font: FONT.bold, size: 150 * (1.14 - 0.14 * ease.out3(lp)), color: C.accent, align: 'center', base: 'middle', alpha: ease.out3(lp * 3), role: 'number' });
return;
}
// worked mode: a factory in a city that closes in, then opens
txt(env, P.label, lx, 255, { font: FONT.semi, size: 56, color: C.accent, align: 'center', alpha: fade(t, q.city, 0.4), role: 'label' });
const fx = 560;
const cl = ease.inOut(prog(t, q.city, 1.0, 0)) ;
const squeeze = ease.inOut(prog(t, q.city, q.tight - q.city + 0.4));
const open = ease.inOut(prog(t, q.worked, 0.6));
const gapW = lerp(330, 150, squeeze) + 190 * open;
const fa = fade(t, q.city, 0.4);
D.factory(ctx, fx, 540, 250, mixc(C.ink, C.muted, squeeze * (1 - open)), { alpha: fa, window: C.bg0 });
ctx.globalAlpha = fa; ctx.fillStyle = C.dim;
ctx.fillRect(fx - gapW - 46, 330, 46, 420); ctx.fillRect(fx + gapW, 330, 46, 420); ctx.globalAlpha = 1;
if (wp > 0) {
D.check(ctx, lx + rail + 74, y1, 70, C.good, ease.out3(prog(t, q.worked, K.check)), 12);
txt(env, P.worked, fx, 860, { font: FONT.bold, size: 80, color: C.good, align: 'center', alpha: wp, role: 'label' });
}
}
// ---------------------------------------------------------------- 11 photo (licensed, captioned)
function photo(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const a = S.assets[P.asset];
const pp = ease.out3(prog(t, q.photo, 0.5));
const mv = ease.inOut(prog(t, q.photo, 0.55));
// "Delhi" starts large on the left, then settles into the right column when the photo arrives
txt(env, P.city, lerp(620, 1500, mv), lerp(560, 215, mv), { font: FONT.bold, size: lerp(120, 64, mv), color: C.accent, align: 'center', base: 'middle', alpha: fade(t, q.delhi, 0.4), role: 'label' });
if (pp > 0) {
const px = 140, py = 150 - 26 * (1 - pp), pw = 960, ph = 640;
ctx.save(); ctx.globalAlpha = pp;
ctx.shadowColor = 'rgba(0,0,0,0.55)'; ctx.shadowBlur = 36; ctx.shadowOffsetY = 10; roundPath(ctx, px, py, pw, ph, 16); ctx.fillStyle = C.bg0; ctx.fill(); ctx.shadowColor = 'transparent';
roundPath(ctx, px, py, pw, ph, 16); ctx.clip();
const k = 1.0 + 0.07 * clamp((t - q.photo) / (S.dur - q.photo));
const iw = a.img.width, ih = a.img.height, base = Math.max(pw / iw, ph / ih);
const dw = iw * base * k, dh = ih * base * k;
ctx.drawImage(a.img, px + (pw - dw) / 2, py + (ph - dh) / 2, dw, dh);
ctx.restore();
const swissOn = t >= q.swiss;
txt(env, P.caption, px, py + ph + 62, { font: FONT.semi, size: 42, color: swissOn ? C.accent : C.ink, alpha: fade(t, q.photo + 0.25, 0.4), role: 'label' });
txt(env, P.caption2, px, py + ph + 112, { font: FONT.reg, size: 38, color: C.muted, alpha: fade(t, q.photo + 0.45, 0.4), role: 'label' });
txt(env, 'Photo: Roland Zumbuehl', 1500, 850, { font: FONT.reg, size: 34, color: C.muted, align: 'center', alpha: fade(t, q.photo + 0.4, 0.4), role: 'source' });
txt(env, 'CC BY-SA 4.0, Wikimedia Commons', 1500, 894, { font: FONT.reg, size: 34, color: C.muted, align: 'center', alpha: fade(t, q.photo + 0.4, 0.4), role: 'source' });
}
const cp = fade(t, q.clocks, 0.45);
if (cp > 0) {
D.clockIcon(ctx, 1500, 520 + 20 * (1 - cp), 130, C.ink, { alpha: cp, minute: ((t - q.clocks) * 0.15) % 1 });
txt(env, P.clockLabel, 1500, 740, { font: FONT.semi, size: 48, align: 'center', alpha: cp, role: 'label' });
}
}
// ---------------------------------------------------------------- 12 merge (a stake bought out, the name kept)
function merge(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const ta = fade(t, q.tiles, 0.4);
const mv = ease.inOut(prog(t, q.buy, K.merge));
const done = t >= q.buy + K.merge;
const aw = 620, ah = 250, ay = 400;
const ax = lerp(290, 650, ease.inOut(prog(t, q.buy + K.merge + 0.1, 0.6)));
const pulse = done ? Math.max(0, 1 - (t - q.buy - K.merge) / 0.35) * 0.05 : 0;
// tile A (Puri's company), becomes the merged tile
const named = ease.out3(prog(t, q.name, 0.35));
ctx.save(); ctx.translate(ax + aw / 2, ay + ah / 2); ctx.scale(1 + pulse, 1 + pulse); ctx.translate(-(ax + aw / 2), -(ay + ah / 2));
panel(ctx, ax, ay, aw, ah, { fill: C.panel, stroke: C.accent, lw: 5, dash: named > 0.5 && P.status === 'single-source' ? [4, 10] : [], alpha: ta });
ctx.restore();
if (named < 1) txt(env, P.a, ax + aw / 2, ay + ah / 2, { font: FONT.semi, size: 54, align: 'center', base: 'middle', alpha: ta * (1 - named), role: 'label' });
if (named > 0) txt(env, P.result, ax + aw / 2, ay + ah / 2, { font: FONT.bold, size: 88, color: C.accent, align: 'center', base: 'middle', alpha: named, role: 'label' });
// tile B (the Swiss side) slides into A and is absorbed
const bx = lerp(1010, ax + 60, mv), bs = lerp(1, 0.82, mv), bal = (1 - ease.out3(prog(t, q.buy + K.merge - 0.1, 0.12))) * ta;
if (bal > 0) {
panel(ctx, bx, ay + ah * (1 - bs) / 2, aw * bs, ah * bs, { fill: C.panel, stroke: C.muted, lw: 4, alpha: bal });
txt(env, 'Moser-Baer', bx + aw * bs / 2, ay + ah / 2 - 22, { font: FONT.semi, size: 52 * bs, color: C.ink, align: 'center', base: 'middle', alpha: bal, role: 'label' });
txt(env, 'Switzerland', bx + aw * bs / 2, ay + ah / 2 + 40, { font: FONT.reg, size: 42 * bs, color: C.muted, align: 'center', base: 'middle', alpha: bal, role: 'label' });
}
if (t < q.buy + 0.2) txt(env, '+', 960, ay + ah / 2 + 4, { font: FONT.bold, size: 90, color: C.dim, align: 'center', base: 'middle', alpha: ta, role: 'ignore' });
}
// ---------------------------------------------------------------- 15 ceiling (a time-clock company only goes so far)
function ceiling(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const cyTop = 330;
const rise = ease.inOut(prog(t, q.clock, q.stop - q.clock));
const cy = lerp(800, 484, rise);
const bump = t > q.stop ? Math.max(0, 1 - (t - q.stop) / 0.3) * 10 * Math.sin((t - q.stop) * 40) : 0;
const ap = fade(t, q.clock, 0.4);
D.clockIcon(ctx, 960, cy + bump, 130, C.ink, { alpha: ap, minute: ((t - q.clock) * 0.2) % 1 });
txt(env, P.clockLabel, 960, 700 + 0 * cy, { font: FONT.semi, size: 48, align: 'center', alpha: ap * (1 - 0.0), role: 'label' });
const lp = ease.out3(prog(t, q.ceiling, 0.6));
if (lp > 0) {
ctx.strokeStyle = C.accent; ctx.lineWidth = 6; ctx.setLineDash([22, 14]);
ctx.beginPath(); ctx.moveTo(960 - 520 * lp, cyTop); ctx.lineTo(960 + 520 * lp, cyTop); ctx.stroke(); ctx.setLineDash([]);
}
const np = fade(t, q.new, 0.5);
if (np > 0) {
ctx.globalAlpha = np; ctx.strokeStyle = C.good; ctx.lineWidth = 6; ctx.setLineDash([10, 12]);
ctx.beginPath(); ctx.arc(790, 190, 74 * (0.8 + 0.2 * np), 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1;
txt(env, P.newLabel, 900, 207, { font: FONT.semi, size: 58, color: C.good, alpha: np, role: 'label' });
}
}
// ---------------------------------------------------------------- 16 cocktail (the next idea came from a party)
function cocktail(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const bp = fade(t, q.idea, 0.4);
const gp = ease.out3(prog(t, q.glass, 0.35));
D.bulb(ctx, 640, 500, 380, C.muted, bp * (1 - gp));
if (gp > 0) {
D.glass(ctx, 640, 500, 400, C.accent, gp);
const k = prog(t, q.glass + K.glass - 0.1, 0.5);
if (k > 0 && k < 1) {
ctx.strokeStyle = C.accent; ctx.lineWidth = 5; ctx.lineCap = 'round'; ctx.globalAlpha = 1 - k;
for (let i = 0; i < 5; i++) { const a = -Math.PI / 2 + (i - 2) * 0.42; const r0 = 250 + 20 * k, r1 = r0 + 50 * (1 - k) + 30; ctx.beginPath(); ctx.moveTo(640 + Math.cos(a) * r0, 440 + Math.sin(a) * r0); ctx.lineTo(640 + Math.cos(a) * r1, 440 + Math.sin(a) * r1); ctx.stroke(); }
ctx.globalAlpha = 1; ctx.lineCap = 'butt';
}
}
txt(env, P.idea, 1060, 380, { font: FONT.med, size: 56, color: C.muted, alpha: fade(t, q.idea, 0.4), role: 'label' });
const pa = fade(t, q.place, 0.45);
const pl = P.place.split(' ');
const l1 = pl.slice(0, 1).join(' '), l2 = pl.slice(1).join(' ');
D.pin(ctx, 1090, 520 + 8 * (1 - pa), 100, C.accent, pa);
txt(env, l1, 1160, 548 + 12 * (1 - pa), { font: FONT.bold, size: 100, color: C.ink, alpha: pa, role: 'keyword' });
txt(env, l2, 1160, 664 + 12 * (1 - pa), { font: FONT.bold, size: 100, color: C.ink, alpha: pa, role: 'keyword' });
}
const REGISTRY = { date, cast, doc, crowd, timeline, slam, route, ladder, photo, merge, ceiling, cocktail, ...require('./scenes-b0'), ...require('./scenes-data'), ...require('./scenes-more') };
delete REGISTRY.EXAMPLES; // the example props of the generic scene files are not a scene type
module.exports = { REGISTRY };
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.