Docs/Skills and code/skill video-visual-production
.claude/skills/video-visual-production/render/draw.js
What the file says about itself
Drawing helpers: theme, fonts, easing, text with layout boxes, icons. Scenes are pure functions of (env, t, props).
// Drawing helpers: theme, fonts, easing, text with layout boxes, icons. Scenes are pure functions of (env, t, props).
const path = require('path');
const { createCanvas, GlobalFonts } = require('@napi-rs/canvas');
const W = 1920, H = 1080;
// One accent, one "good" colour, red reserved for loss, alleged, not found (rule C4).
const C = {
bg0: '#15110e', bg1: '#251d16', panel: '#2b2219', panelEdge: '#4a3d2e',
ink: '#f3ecdd', muted: '#a99d88', dim: '#665c4d',
accent: '#e8a826', good: '#63b8a4', loss: '#dc4b3c', lossText: '#ff8274',
paper: '#f6f1e6', paperInk: '#2a241c', mark: '#f2c14e',
};
const FONT = { reg: 'PoppinsRegular', med: 'PoppinsMedium', semi: 'PoppinsSemi', bold: 'PoppinsBold', ital: 'PoppinsItalic' };
let fontsReady = false;
function registerFonts() {
if (fontsReady) return;
const dir = path.join(__dirname, 'fonts');
const map = { PoppinsRegular: 'Poppins-Regular.ttf', PoppinsMedium: 'Poppins-Medium.ttf', PoppinsSemi: 'Poppins-SemiBold.ttf', PoppinsBold: 'Poppins-Bold.ttf', PoppinsItalic: 'Poppins-Italic.ttf' };
for (const [fam, file] of Object.entries(map)) {
if (!GlobalFonts.registerFromPath(path.join(dir, file), fam)) throw new Error('could not register font ' + file);
}
fontsReady = true;
}
// ---- easing and progress
const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
const ease = {
lin: (x) => clamp(x),
out3: (x) => 1 - Math.pow(1 - clamp(x), 3),
inOut: (x) => { x = clamp(x); return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; },
back: (x) => { x = clamp(x); const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2); },
};
// progress of an animation that starts at t0 and lasts d seconds
const prog = (t, t0, d) => clamp((t - t0) / d);
const lerp = (a, b, k) => a + (b - a) * k;
// ---- environment: one per render; `boxes` collects every drawn text box when auditing
function makeEnv(canvas, scale = 1) {
const ctx = canvas.getContext('2d');
return { canvas, ctx, W, H, scale, boxes: null, assets: {}, bgCanvas: null };
}
function roundPath(ctx, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function background(env) {
const { ctx } = env;
if (!env.bgCanvas) {
const b = createCanvas(W, H);
const g = b.getContext('2d');
const rg = g.createRadialGradient(W / 2, H * 0.42, 80, W / 2, H * 0.5, 1250);
rg.addColorStop(0, C.bg1); rg.addColorStop(1, C.bg0);
g.fillStyle = rg; g.fillRect(0, 0, W, H);
env.bgCanvas = b;
}
ctx.globalAlpha = 1;
ctx.drawImage(env.bgCanvas, 0, 0);
}
// Text with an optional recorded layout box. o: font, size, color, align, base, alpha, role, spacing
function txt(env, s, x, y, o = {}) {
const { ctx } = env;
const size = o.size || 48;
ctx.font = `${size}px ${o.font || FONT.reg}`;
ctx.textAlign = o.align || 'left';
ctx.textBaseline = o.base || 'alphabetic';
if ('letterSpacing' in ctx) ctx.letterSpacing = o.spacing ? o.spacing + 'px' : '0px';
const w = ctx.measureText(s).width;
if (!o.noclamp && w < 1728) { // keep inside the 5% safe margins
if (o.align === 'center') x = Math.min(1824 - w / 2, Math.max(96 + w / 2, x));
else if (o.align === 'right') x = Math.max(96 + w, Math.min(1824, x));
else x = Math.max(96, Math.min(1824 - w, x));
}
const a = o.alpha == null ? 1 : o.alpha;
if (a > 0.001) {
ctx.globalAlpha = a;
ctx.fillStyle = o.color || C.ink;
ctx.fillText(s, x, y);
ctx.globalAlpha = 1;
}
if (env.boxes && a > 0.3 && o.role !== 'ignore') {
const left = o.align === 'center' ? x - w / 2 : o.align === 'right' ? x - w : x;
const top = (o.base === 'middle') ? y - size / 2 : y - size * 0.8;
env.boxes.push({ text: s, x: left, y: top, w, h: size, size, alpha: a, role: o.role || 'label', color: o.color || C.ink });
}
return w;
}
// Greedy wrap by measured width
function wrap(ctx, s, maxW, font, size) {
ctx.font = `${size}px ${font}`;
const words = s.split(' ');
const lines = [];
let cur = '';
for (const w of words) {
const t = cur ? cur + ' ' + w : w;
if (ctx.measureText(t).width > maxW && cur) { lines.push(cur); cur = w; } else cur = t;
}
if (cur) lines.push(cur);
return lines;
}
function panel(ctx, x, y, w, h, o = {}) {
roundPath(ctx, x, y, w, h, o.r == null ? 26 : o.r);
ctx.globalAlpha = o.alpha == null ? 1 : o.alpha;
if (o.fill) { ctx.fillStyle = o.fill; ctx.fill(); }
if (o.stroke) {
ctx.lineWidth = o.lw || 3; ctx.strokeStyle = o.stroke;
ctx.setLineDash(o.dash || []);
ctx.stroke(); ctx.setLineDash([]);
}
ctx.globalAlpha = 1;
}
// ---- icons (all flat, faceless, no photographic texture: class M)
function person(ctx, cx, cy, s, color, alpha = 1) {
ctx.globalAlpha = alpha; ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(cx, cy - 0.2 * s, 0.17 * s, 0, Math.PI * 2); ctx.fill();
ctx.beginPath();
ctx.moveTo(cx - 0.32 * s, cy + 0.38 * s);
ctx.quadraticCurveTo(cx - 0.32 * s, cy + 0.0 * s, cx, cy + 0.0 * s);
ctx.quadraticCurveTo(cx + 0.32 * s, cy + 0.0 * s, cx + 0.32 * s, cy + 0.38 * s);
ctx.closePath(); ctx.fill();
ctx.globalAlpha = 1;
}
function factory(ctx, cx, cy, s, color, o = {}) {
const a = o.alpha == null ? 1 : o.alpha;
ctx.globalAlpha = a; ctx.fillStyle = color;
const x0 = cx - 0.5 * s, y1 = cy + 0.3 * s;
ctx.fillRect(x0, cy - 0.02 * s, s, 0.32 * s); // body
for (let i = 0; i < 3; i++) { // sawtooth roof
const bx = x0 + i * (s * 0.62 / 3);
ctx.beginPath(); ctx.moveTo(bx, cy - 0.02 * s); ctx.lineTo(bx, cy - 0.26 * s); ctx.lineTo(bx + s * 0.62 / 3, cy - 0.02 * s); ctx.closePath(); ctx.fill();
}
ctx.fillRect(cx + 0.30 * s, cy - 0.5 * s, 0.11 * s, 0.5 * s); // chimney
ctx.fillStyle = o.window || C.bg0;
for (let i = 0; i < 4; i++) ctx.fillRect(x0 + 0.08 * s + i * 0.23 * s, cy + 0.08 * s, 0.13 * s, 0.1 * s);
if (o.smokeT != null) { // smoke puffs rising from the chimney
ctx.fillStyle = o.smokeColor || C.muted;
for (let k = 0; k < 4; k++) {
const ph = ((o.smokeT * 0.55 + k / 4) % 1);
ctx.globalAlpha = a * (1 - ph) * 0.6;
ctx.beginPath(); ctx.arc(cx + 0.355 * s + Math.sin(ph * 5 + k) * 0.04 * s, cy - 0.5 * s - ph * 0.55 * s, (0.05 + 0.07 * ph) * s, 0, Math.PI * 2); ctx.fill();
}
}
ctx.globalAlpha = 1;
return y1;
}
function clockIcon(ctx, cx, cy, r, color, o = {}) {
ctx.globalAlpha = o.alpha == null ? 1 : o.alpha;
ctx.strokeStyle = color; ctx.lineWidth = r * 0.1; ctx.lineCap = 'round';
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
for (let i = 0; i < 12; i++) {
const a = (i / 12) * Math.PI * 2;
ctx.lineWidth = r * (i % 3 === 0 ? 0.08 : 0.04);
ctx.beginPath(); ctx.moveTo(cx + Math.sin(a) * r * 0.82, cy - Math.cos(a) * r * 0.82); ctx.lineTo(cx + Math.sin(a) * r * 0.93, cy - Math.cos(a) * r * 0.93); ctx.stroke();
}
const hand = (ang, len, w) => { ctx.lineWidth = r * w; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.sin(ang) * r * len, cy - Math.cos(ang) * r * len); ctx.stroke(); };
const m = o.minute == null ? 0.9 : o.minute;
hand(m * Math.PI * 2, 0.68, 0.07);
hand(m * Math.PI * 2 / 12 + 1.2, 0.42, 0.09);
ctx.fillStyle = color; ctx.beginPath(); ctx.arc(cx, cy, r * 0.07, 0, Math.PI * 2); ctx.fill();
ctx.lineCap = 'butt'; ctx.globalAlpha = 1;
}
function glass(ctx, cx, cy, s, color, alpha = 1) {
ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = s * 0.045; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.beginPath(); ctx.moveTo(cx - 0.42 * s, cy - 0.42 * s); ctx.lineTo(cx + 0.42 * s, cy - 0.42 * s); ctx.lineTo(cx, cy + 0.1 * s); ctx.closePath(); ctx.stroke();
ctx.globalAlpha = alpha * 0.22; ctx.beginPath(); ctx.moveTo(cx - 0.33 * s, cy - 0.34 * s); ctx.lineTo(cx + 0.33 * s, cy - 0.34 * s); ctx.lineTo(cx, cy + 0.04 * s); ctx.closePath(); ctx.fill();
ctx.globalAlpha = alpha;
ctx.beginPath(); ctx.moveTo(cx, cy + 0.1 * s); ctx.lineTo(cx, cy + 0.5 * s); ctx.moveTo(cx - 0.22 * s, cy + 0.5 * s); ctx.lineTo(cx + 0.22 * s, cy + 0.5 * s); ctx.stroke();
ctx.beginPath(); ctx.arc(cx + 0.1 * s, cy - 0.16 * s, 0.065 * s, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(cx + 0.02 * s, cy - 0.02 * s); ctx.lineTo(cx + 0.2 * s, cy - 0.36 * s); ctx.lineWidth = s * 0.02; ctx.stroke();
ctx.lineCap = 'butt'; ctx.globalAlpha = 1;
}
function bulb(ctx, cx, cy, s, color, alpha = 1) {
ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.lineWidth = s * 0.05; ctx.lineCap = 'round';
ctx.beginPath(); ctx.arc(cx, cy - 0.1 * s, 0.28 * s, 0.75 * Math.PI, 2.25 * Math.PI); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx - 0.15 * s, cy + 0.23 * s); ctx.lineTo(cx - 0.12 * s, cy + 0.4 * s); ctx.lineTo(cx + 0.12 * s, cy + 0.4 * s); ctx.lineTo(cx + 0.15 * s, cy + 0.23 * s); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx - 0.09 * s, cy + 0.5 * s); ctx.lineTo(cx + 0.09 * s, cy + 0.5 * s); ctx.stroke();
for (let i = -2; i <= 2; i++) {
const a = -Math.PI / 2 + i * 0.5;
ctx.beginPath(); ctx.moveTo(cx + Math.cos(a) * 0.42 * s, cy - 0.1 * s + Math.sin(a) * 0.42 * s); ctx.lineTo(cx + Math.cos(a) * 0.55 * s, cy - 0.1 * s + Math.sin(a) * 0.55 * s); ctx.stroke();
}
ctx.lineCap = 'butt'; ctx.globalAlpha = 1;
}
function check(ctx, cx, cy, s, color, p = 1, lw) {
ctx.strokeStyle = color; ctx.lineWidth = lw || s * 0.14; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
const pts = [[cx - 0.45 * s, cy + 0.0 * s], [cx - 0.12 * s, cy + 0.33 * s], [cx + 0.5 * s, cy - 0.38 * s]];
const l1 = Math.hypot(pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]), l2 = Math.hypot(pts[2][0] - pts[1][0], pts[2][1] - pts[1][1]);
const d = p * (l1 + l2);
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
if (d <= l1) ctx.lineTo(pts[0][0] + (pts[1][0] - pts[0][0]) * d / l1, pts[0][1] + (pts[1][1] - pts[0][1]) * d / l1);
else { ctx.lineTo(pts[1][0], pts[1][1]); const k = (d - l1) / l2; ctx.lineTo(pts[1][0] + (pts[2][0] - pts[1][0]) * k, pts[1][1] + (pts[2][1] - pts[1][1]) * k); }
ctx.stroke(); ctx.lineCap = 'butt';
}
function cross(ctx, cx, cy, s, color, p = 1, lw) {
ctx.strokeStyle = color; ctx.lineWidth = lw || s * 0.14; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(cx - s / 2, cy - s / 2); ctx.lineTo(cx - s / 2 + s * p, cy - s / 2 + s * p); ctx.stroke();
if (p > 0.5) { const q = (p - 0.5) * 2; ctx.beginPath(); ctx.moveTo(cx + s / 2, cy - s / 2); ctx.lineTo(cx + s / 2 - s * q, cy - s / 2 + s * q); ctx.stroke(); }
ctx.lineCap = 'butt';
}
function pin(ctx, cx, cy, s, color, alpha = 1) {
ctx.globalAlpha = alpha; ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(cx, cy - 0.15 * s, 0.22 * s, Math.PI * 0.85, Math.PI * 2.15); ctx.lineTo(cx, cy + 0.4 * s); ctx.closePath(); ctx.fill();
ctx.fillStyle = C.bg0; ctx.beginPath(); ctx.arc(cx, cy - 0.15 * s, 0.08 * s, 0, Math.PI * 2); ctx.fill();
ctx.globalAlpha = 1;
}
function arrowHead(ctx, x, y, ang, size, color) {
ctx.fillStyle = color; ctx.beginPath();
ctx.moveTo(x, y); ctx.lineTo(x - Math.cos(ang - 0.45) * size, y - Math.sin(ang - 0.45) * size); ctx.lineTo(x - Math.cos(ang + 0.45) * size, y - Math.sin(ang + 0.45) * size); ctx.closePath(); ctx.fill();
}
// Quadratic bezier point and tangent
function qpt(p0, p1, p2, k) {
const u = 1 - k;
return { x: u * u * p0[0] + 2 * u * k * p1[0] + k * k * p2[0], y: u * u * p0[1] + 2 * u * k * p1[1] + k * k * p2[1], ang: Math.atan2(2 * u * (p1[1] - p0[1]) + 2 * k * (p2[1] - p1[1]), 2 * u * (p1[0] - p0[0]) + 2 * k * (p2[0] - p1[0])) };
}
// Typeset fallback for a quoted passage: paper-coloured canvas, our own typeface (never styled as the paper's page).
function typesetPassage(text, phrases, width = 1500, size = 44) {
const probe = createCanvas(10, 10).getContext('2d');
probe.font = `${size}px ${FONT.reg}`;
const padX = 64, padY = 40, lineH = size * 1.62;
const toks = text.split(' ').map((w) => ({ w }));
const lines = [];
let cur = [], curW = 0;
const sp = probe.measureText(' ').width;
for (const t of toks) {
const tw = probe.measureText(t.w).width;
if (curW + tw > width - 2 * padX && cur.length) { lines.push(cur); cur = []; curW = 0; }
t.x = curW; t.tw = tw; cur.push(t); curW += tw + sp;
}
if (cur.length) lines.push(cur);
const h = Math.round(lines.length * lineH + 2 * padY);
const cv = createCanvas(width, h);
const g = cv.getContext('2d');
g.fillStyle = C.paper; g.fillRect(0, 0, width, h);
g.fillStyle = C.paperInk; g.font = `${size}px ${FONT.reg}`; g.textBaseline = 'alphabetic';
const flat = [];
lines.forEach((ln, li) => ln.forEach((t) => { t.line = li; g.fillText(t.w, padX + t.x, padY + li * lineH + size); flat.push(t); }));
const norm = (s) => s.replace(/[.,"'“”‘’]/g, '').toLowerCase();
const boxes = {};
for (const ph of phrases) {
const pw = ph.split(' ').map(norm);
const rects = [];
outer: for (let i = 0; i + pw.length <= flat.length; i++) {
for (let k = 0; k < pw.length; k++) if (norm(flat[i + k].w) !== pw[k]) continue outer;
const run = flat.slice(i, i + pw.length);
for (let li = run[0].line; li <= run[run.length - 1].line; li++) {
const seg = run.filter((t) => t.line === li);
rects.push({ x: Math.round(padX + seg[0].x - 4), y: Math.round(padY + li * lineH + size * 0.05), w: Math.round(seg[seg.length - 1].x + seg[seg.length - 1].tw - seg[0].x + 8), h: Math.round(size * 1.15) });
}
break;
}
boxes[ph] = rects.length ? rects : null;
}
return { canvas: cv, w: width, h, boxes };
}
module.exports = { W, H, C, FONT, registerFonts, clamp, ease, prog, lerp, makeEnv, roundPath, background, txt, wrap, panel, person, factory, clockIcon, glass, bulb, check, cross, pin, arrowHead, qpt, typesetPassage, createCanvas };
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.