Docs/Skills and code/skill video-visual-production
.claude/skills/video-visual-production/render/scenes-more.js
What the file says about itself
More generic scenes for beats B6 to B13: calendar, deadline, layers, objects, cards, queue, stopbtn. Same contract as scenes.js (pure functions of env, t, props, S). EXAMPLES drives render/selftest-scenes.js.
// More generic scenes for beats B6 to B13: calendar, deadline, layers, objects, cards, queue, stopbtn.
// Same contract as scenes.js (pure functions of env, t, props, S). EXAMPLES drives render/selftest-scenes.js.
const D = require('./draw');
const K = require('./contacts');
const { C, FONT, ease, prog, lerp, clamp, txt, panel, background, roundPath } = D;
const fade = (t, t0, d = 0.35) => ease.out3(prog(t, t0, d));
const toneColor = (tone) => ({ accent: C.accent, good: C.good, loss: C.lossText || C.loss, muted: C.muted, dim: C.muted, ink: C.ink }[tone] || C.accent); // dim text would fail the contrast check, so dim maps to muted
function fitSize(ctx, s, font, size, maxW, min = 38) { let z = size; ctx.font = `${z}px ${font}`; while (ctx.measureText(s).width > maxW && z > min) { z -= 4; ctx.font = `${z}px ${font}`; } return z; }
// ------------------------------------------------------------ calendar: a date page, circled and stamped
function calendar(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const a = fade(t, q.page, 0.5), x = 560, y = 170 + 30 * (1 - a), w = 800, h = 700;
ctx.save(); ctx.globalAlpha = a; ctx.shadowColor = 'rgba(0,0,0,0.55)'; ctx.shadowBlur = 36; ctx.shadowOffsetY = 10;
roundPath(ctx, x, y, w, h, 24); ctx.fillStyle = C.paper; ctx.fill(); ctx.shadowColor = 'transparent';
roundPath(ctx, x, y, w, 150, 24); ctx.fillStyle = C.accent; ctx.fill(); ctx.fillRect(x, y + 100, w, 50);
ctx.restore();
txt(env, P.month, 960, y + 108, { font: FONT.bold, size: 84, color: C.bg0, align: 'center', alpha: a, role: 'label', spacing: 8 });
if (P.day) txt(env, P.day, 960, y + 420, { font: FONT.bold, size: 300, color: C.paperInk, align: 'center', base: 'middle', alpha: a, role: 'number' });
if (P.year) txt(env, P.year, 960, y + 620, { font: FONT.semi, size: 88, color: C.paperInk, align: 'center', alpha: a, role: 'number' });
const cp = ease.inOut(prog(t, q.circle, 0.7));
if (cp > 0) { ctx.strokeStyle = '#a3261c'; ctx.lineWidth = 12; ctx.lineCap = 'round'; ctx.beginPath(); ctx.ellipse(960, y + 405, 250, 200, -0.05, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * cp); ctx.stroke(); ctx.lineCap = 'butt'; }
const sp = prog(t, q.stamp, K.stamp);
if (sp > 0 && P.stamp) {
const sc = 1.4 - 0.4 * ease.out3(sp); ctx.save(); ctx.translate(1450, y + 360); ctx.rotate(-0.22); ctx.scale(sc, sc); ctx.globalAlpha = ease.out3(sp * 2);
roundPath(ctx, -270, -62, 540, 124, 16); ctx.fillStyle = C.paper; ctx.fill(); ctx.strokeStyle = '#a3261c'; ctx.lineWidth = 10; ctx.stroke(); ctx.restore(); ctx.globalAlpha = 1;
txt(env, P.stamp, 1450, y + 360, { font: FONT.bold, size: fitSize(ctx, P.stamp, FONT.bold, 62, 480, 40) * sc, color: '#a3261c', align: 'center', base: 'middle', alpha: ease.out3(sp * 2), role: 'label' });
}
}
// ------------------------------------------------------------ deadline: a pie clock with a big day count and its two end dates
function deadline(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const a = fade(t, q.clock, 0.5), cx = 600, cy = 520, r = 250;
const eaten = P.shrink ? ease.lin(prog(t, q.clock + 0.6, S.dur - q.clock - 0.6)) * P.shrink : 0;
ctx.globalAlpha = a; ctx.fillStyle = C.panel; ctx.beginPath(); ctx.arc(cx, cy, r + 14, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = C.accent; ctx.globalAlpha = a * 0.9; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, r, -Math.PI / 2 + Math.PI * 2 * eaten, Math.PI * 1.5); ctx.closePath(); ctx.fill();
ctx.globalAlpha = a; ctx.strokeStyle = C.dim; ctx.lineWidth = 6; ctx.beginPath(); ctx.arc(cx, cy, r + 14, 0, Math.PI * 2); ctx.stroke(); ctx.globalAlpha = 1;
txt(env, P.value, 1380, 500, { font: FONT.bold, size: fitSize(ctx, P.value, FONT.bold, 230, 800, 72), color: C.accent, align: 'center', base: 'middle', alpha: fade(t, q.value != null ? q.value : q.clock + 0.3, 0.5), role: 'number' });
txt(env, P.unit, 1380, 640, { font: FONT.semi, size: 60, align: 'center', alpha: fade(t, q.value != null ? q.value : q.clock + 0.3, 0.5), role: 'label' });
if (P.fromLabel) txt(env, P.fromLabel, 600, 880, { font: FONT.med, size: 46, color: C.muted, align: 'center', alpha: fade(t, q.from != null ? q.from : q.clock, 0.4), role: 'label' });
if (P.toLabel) txt(env, P.toLabel, 1380, 760, { font: FONT.semi, size: fitSize(ctx, P.toLabel, FONT.semi, 54, 800, 38), color: C.ink, align: 'center', alpha: fade(t, q.to != null ? q.to : q.clock + 1, 0.4), role: 'label' });
}
// ------------------------------------------------------------ layers: a stack of thin layers drawn one on another (a cross-section)
function layers(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const n = P.layers.length, lh = Math.min(86, 520 / n), x0 = 460, w = 760, yBase = 820;
if (P.title) txt(env, P.title, 960, 200, { font: FONT.semi, size: 58, color: C.muted, align: 'center', alpha: fade(t, q.title != null ? q.title : 0, 0.5), role: 'label' });
P.layers.forEach((l, i) => {
const a = fade(t, q[l.cue], 0.4), y = yBase - (i + 1) * lh - 40 * (1 - a);
if (a <= 0) return;
ctx.globalAlpha = a; ctx.fillStyle = toneColor(l.tone || (i % 2 ? 'muted' : 'accent')); roundPath(ctx, x0, y, w, lh - 8, 8); ctx.fill(); ctx.globalAlpha = 1;
txt(env, l.label, x0 + w + 50, y + lh / 2 + 10, { font: FONT.med, size: 44, alpha: a, role: 'label' });
});
}
// ------------------------------------------------------------ objects: two or three things with a tag each (a price beside a price)
function objects(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const n = P.items.length, gap = 640, x0 = 960 - ((n - 1) / 2) * gap;
P.items.forEach((it, i) => {
const a = fade(t, q[it.cue], 0.45), x = x0 + i * gap, y = 470 + 24 * (1 - a);
if (a <= 0) return;
ctx.globalAlpha = a;
if (it.icon === 'bar') { ctx.fillStyle = '#6b4a2f'; roundPath(ctx, x - 130, y - 100, 260, 200, 14); ctx.fill(); ctx.strokeStyle = '#3d2918'; ctx.lineWidth = 5; for (let k = 1; k < 3; k++) { ctx.beginPath(); ctx.moveTo(x - 130 + k * 87, y - 100); ctx.lineTo(x - 130 + k * 87, y + 100); ctx.stroke(); } ctx.beginPath(); ctx.moveTo(x - 130, y); ctx.lineTo(x + 130, y); ctx.stroke(); }
else if (it.icon === 'box') { ctx.fillStyle = C.panelEdge; roundPath(ctx, x - 140, y - 105, 280, 210, 12); ctx.fill(); ctx.fillStyle = C.accent; ctx.fillRect(x - 140, y - 20, 280, 22); }
else { ctx.fillStyle = '#b4babf'; ctx.beginPath(); ctx.arc(x, y, 120, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#1d1712'; ctx.beginPath(); ctx.arc(x, y, 22, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(x, y, 62, 0, Math.PI * 2); ctx.stroke(); }
ctx.globalAlpha = 1;
const tp = ease.back(prog(t, q[it.tagCue != null ? it.tagCue : it.cue] + 0.3, 0.35));
if (it.tag && tp > 0) { panel(ctx, x + 40, y + 80, 230 * Math.max(0.3, tp), 100, { r: 14, fill: C.accent, alpha: clamp(tp * 2) }); txt(env, it.tag, x + 155, y + 130, { font: FONT.bold, size: 72, color: C.bg0, align: 'center', base: 'middle', alpha: clamp(tp * 2), role: 'number' }); }
txt(env, it.label, x, y + 250, { font: FONT.semi, size: 54, align: 'center', alpha: a, role: 'label' });
});
}
// ------------------------------------------------------------ cards: up to three problem cards, each with a title, a line and a source
function cards(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const n = P.cards.length, w = n === 3 ? 540 : 700, gap = 50, x0 = 960 - (n * w + (n - 1) * gap) / 2;
P.cards.forEach((c, i) => {
const a = fade(t, q[c.cue], 0.45), x = x0 + i * (w + gap), y = 250 + 40 * (1 - a);
if (a <= 0) return;
panel(ctx, x, y, w, 560, { r: 28, fill: C.panel, stroke: toneColor(c.tone || 'accent'), lw: 4, alpha: a });
txt(env, String(i + 1), x + 50, y + 100, { font: FONT.bold, size: 90, color: toneColor(c.tone || 'accent'), alpha: a, role: 'number' });
const lines = D.wrap(ctx, c.title, w - 90, FONT.semi, 52);
lines.slice(0, 3).forEach((ln, k) => txt(env, ln, x + 45, y + 200 + k * 64, { font: FONT.semi, size: 52, alpha: a, role: 'label' }));
if (c.sub) txt(env, c.sub, x + 45, y + 420, { font: FONT.reg, size: 40, color: C.muted, alpha: a, role: 'label' });
if (c.source) txt(env, c.source, x + 45, y + 505, { font: FONT.med, size: 34, color: C.muted, alpha: a, role: 'source' });
});
}
// ------------------------------------------------------------ queue: faceless figures in a line to a counter; the line grows (a commitment that keeps getting bigger)
function queue(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const a0 = fade(t, q.counter, 0.5);
ctx.globalAlpha = a0; ctx.fillStyle = C.panelEdge; roundPath(ctx, 1380, 380, 320, 300, 20); ctx.fill(); ctx.globalAlpha = 1;
txt(env, P.counter, 1540, 560, { font: FONT.semi, size: fitSize(ctx, P.counter, FONT.semi, 52, 280, 34), align: 'center', base: 'middle', alpha: a0, role: 'label' });
const n = P.count, per = P.every || 0.22;
for (let i = 0; i < n; i++) { const a = ease.out3(prog(t, q.line + i * per, 0.3)); if (a > 0) D.person(ctx, 1280 - i * (1120 / Math.max(1, n - 1)), 560 + 30 * (1 - a), 120, i < (P.committed || 3) ? C.accent : C.dim, a); }
if (P.label) txt(env, P.label, 820, 850, { font: FONT.semi, size: 56, align: 'center', alpha: fade(t, q.label != null ? q.label : q.line, 0.5), role: 'keyword' });
}
// ------------------------------------------------------------ stopbtn: the stop button that is not pressed
function stopbtn(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const a = fade(t, q.button, 0.5) * (1 - ease.out3(prog(t, q.fade != null ? q.fade : 1e9, 1.2)));
ctx.globalAlpha = a; ctx.fillStyle = C.panel; ctx.beginPath(); ctx.arc(960, 500, 230, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = C.loss; ctx.beginPath(); ctx.arc(960, 500, 190, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = C.paper; roundPath(ctx, 880, 420, 160, 160, 18); ctx.fill(); ctx.globalAlpha = 1;
txt(env, P.label, 960, 840, { font: FONT.semi, size: 62, align: 'center', alpha: a, role: 'keyword' });
}
// ------------------------------------------------------------ nested: a part inside a whole (an alleged amount inside the total claims), the rest ghosted
function nested(env, t, P, S) {
background(env);
const { ctx } = env, q = S.cues;
const X = 260, Y = 330, W = 1400, H = 430;
const a = fade(t, q.outer, 0.5);
panel(ctx, X, Y, W, H, { r: 24, fill: C.panel, stroke: C.accent, lw: 5, alpha: a });
txt(env, P.outer.text, 960, 285, { font: FONT.bold, size: 100, color: C.accent, align: 'center', alpha: a, role: 'number' });
if (P.outer.label) txt(env, P.outer.label, 960, Y + H + 70, { font: FONT.med, size: 44, color: C.muted, align: 'center', alpha: a, role: 'label' });
const ia = fade(t, q.inner, 0.5), iw = Math.max(260, W * ((P.inner.value || 0) / (P.outer.value || 1)));
if (ia > 0) {
ctx.globalAlpha = ia; ctx.fillStyle = C.bg0; roundPath(ctx, X + 20, Y + 20, iw - 20, H - 40, 16); ctx.fill(); ctx.globalAlpha = 1;
if (P.inner.alleged) { ctx.save(); ctx.globalAlpha = ia; ctx.strokeStyle = C.accent; ctx.lineWidth = 5; roundPath(ctx, X + 20, Y + 20, iw - 20, H - 40, 16); ctx.clip(); for (let k = -H; k < iw; k += 24) { ctx.beginPath(); ctx.moveTo(X + 20 + k, Y + H - 20); ctx.lineTo(X + 20 + k + H, Y + 20); ctx.stroke(); } ctx.restore(); ctx.globalAlpha = ia; ctx.strokeStyle = C.accent; ctx.setLineDash([14, 10]); ctx.lineWidth = 4; roundPath(ctx, X + 20, Y + 20, iw - 20, H - 40, 16); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1; }
panel(ctx, X + 20 + (iw - 20) / 2 - 150, Y + H / 2 - 80, 300, 160, { r: 14, fill: C.bg0, alpha: ia });
txt(env, P.inner.text, X + 20 + (iw - 20) / 2, Y + H / 2 - 12, { font: FONT.bold, size: 76, align: 'center', base: 'middle', alpha: ia, role: 'number' });
if (P.inner.label) txt(env, P.inner.label, X + 20 + (iw - 20) / 2, Y + H / 2 + 50, { font: FONT.med, size: 40, color: C.muted, align: 'center', base: 'middle', alpha: ia, role: 'label' });
}
const ra = fade(t, q.rest, 0.5);
if (ra > 0 && P.rest) { panel(ctx, X + iw + 10, Y + 20, W - iw - 30, H - 40, { r: 16, stroke: C.loss, lw: 4, dash: [14, 10], alpha: ra }); txt(env, P.rest.label, X + iw + 10 + (W - iw - 30) / 2, Y + H / 2, { font: FONT.med, size: 46, color: C.lossText, align: 'center', base: 'middle', alpha: ra, role: 'label' }); }
if (P.chip) { ctx.font = '44px ' + FONT.med; const w = ctx.measureText(P.chip.text).width + 70, ca = fade(t, q[P.chip.cue], 0.4); panel(ctx, 960 - w / 2, 60, w, 80, { r: 40, fill: C.bg0, stroke: C.loss, lw: 3, dash: [10, 8], alpha: ca }); txt(env, P.chip.text, 960, 102, { font: FONT.med, size: 44, color: C.lossText, align: 'center', base: 'middle', alpha: ca, role: 'label' }); }
}
const EXAMPLES = {
calendar: { props: { month: 'JUNE', day: '21', year: '2012', stamp: 'BOND DUE' }, cues: { page: 0.2, circle: 1.0, stamp: 2.0 } },
deadline: { props: { value: '197', unit: 'days', fromLabel: '7 Dec 2011', toLabel: '21 Jun 2012', shrink: 0.18 }, cues: { clock: 0.2, value: 0.8, from: 0.8, to: 1.6 } },
layers: { props: { title: 'a plate coated in thin layers', layers: [{ label: 'base plate', cue: 'a' }, { label: 'thin film', cue: 'b' }, { label: 'contact', cue: 'c' }] }, cues: { title: 0.1, a: 0.4, b: 1.0, c: 1.6 } },
objects: { props: { items: [{ icon: 'disc', label: 'a film on disc', tag: '34', cue: 'a' }, { icon: 'bar', label: 'a chocolate', tag: '28', cue: 'b' }] }, cues: { a: 0.3, b: 1.4 } },
cards: { props: { cards: [{ title: 'the discs lost money', sub: 'FY08', source: 'Source: Storage Newsletter', cue: 'a' }, { title: 'solar lost three years running', sub: 'to 2011', source: 'Source: Business Today', cue: 'b' }, { title: 'a bond fell due', sub: '21 June 2012', source: 'Source: Business Today', cue: 'c', tone: 'loss' }] }, cues: { a: 0.3, b: 1.3, c: 2.3 } },
queue: { props: { counter: 'the counter', count: 10, committed: 3, label: 'a longer line, not a shorter one' }, cues: { counter: 0.2, line: 0.6, label: 3 } },
stopbtn: { props: { label: 'the stop button' }, cues: { button: 0.3, fade: 3 } },
nested: { props: { outer: { text: '4,828', value: 4828, label: 'crore claimed in all' }, inner: { text: '1,101', label: 'crore', value: 1101, alleged: true }, rest: { label: 'no allegation found' }, chip: { text: 'alleged, not proved', cue: 'inner' } }, cues: { outer: 0.2, inner: 1.0, rest: 1.8 } },
'stat-alleged': { type: 'stat', props: { value: '354', label: 'crore, one case', sub: 'as alleged by the CBI', status: 'alleged', source: 'Source: The Week, 2019' }, cues: { stat: 0.3, label: 1.0 } },
'chips-gone': { type: 'chips', props: { items: [{ text: 'insurance cover', cue: 'a', gone: 'g1' }, { text: 'medical cover', cue: 'b', gone: 'g2' }, { text: 'company buses', cue: 'c', gone: 'g3' }, { text: 'salary on time', cue: 'd', gone: 'g4' }] }, cues: { a: 0.2, b: 0.4, c: 0.6, d: 0.8, g1: 1.2, g2: 1.8, g3: 2.4, g4: 3.0 } },
'unitgrid-cells': { type: 'unitgrid', props: { icon: 'cell', count: 100, cols: 10, lit: 7, legend: 'a share of a hundred, not a payout', value: '7', span: 1.2 }, cues: { grid: 0.2, lit: 1.6, legend: 0.8, value: 1.6 } },
};
module.exports = { calendar, deadline, layers, objects, cards, queue, stopbtn, nested, EXAMPLES };
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.